diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index af010089e4892..d92725b233e3e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,7 @@ /x-pack/plugins/discover_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app +/x-pack/plugins/vis_type_timeseries_enhanced/ @elastic/kibana-app /src/plugins/advanced_settings/ @elastic/kibana-app /src/plugins/charts/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app diff --git a/docs/developer/contributing/index.asciidoc b/docs/developer/contributing/index.asciidoc index ba4ab89d17c27..bbf2903491bf6 100644 --- a/docs/developer/contributing/index.asciidoc +++ b/docs/developer/contributing/index.asciidoc @@ -2,7 +2,7 @@ == Contributing Whether you want to fix a bug, implement a feature, or add some other improvements or apis, the following sections will -guide you on the process. +guide you on the process. After committing your code, check out the link:https://www.elastic.co/community/contributor[Elastic Contributor Program] where you can earn points and rewards for your contributions. Read <> to get your environment up and running, then read <>. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index e89b6d86361c7..198b0372d9254 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -479,6 +479,10 @@ Elastic. |Welcome to the Kibana rollup plugin! This plugin provides Kibana support for Elasticsearch's rollup feature. Please refer to the Elasticsearch documentation to understand rollup indices and how to create rollup jobs. +|{kib-repo}blob/{branch}/x-pack/plugins/runtime_fields/README.md[runtimeFields] +|Welcome to the home of the runtime field editor and everything related to runtime fields! + + |{kib-repo}blob/{branch}/x-pack/plugins/saved_objects_tagging/README.md[savedObjectsTagging] |Add tagging capability to saved objects @@ -554,6 +558,10 @@ in their infrastructure. |NOTE: This plugin contains implementation of URL drilldown. For drilldowns infrastructure code refer to ui_actions_enhanced plugin. +|{kib-repo}blob/{branch}/x-pack/plugins/vis_type_timeseries_enhanced/README.md[visTypeTimeseriesEnhanced] +|The vis_type_timeseries_enhanced plugin is the x-pack counterpart to the OSS vis_type_timeseries plugin. + + |{kib-repo}blob/{branch}/x-pack/plugins/watcher/README.md[watcher] |This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8b1bdcdee3be..6a90fd49f1d66 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -82,6 +82,11 @@ The plugin integrates with the core system via lifecycle events: `setup` | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | | | [OverlayBannersStart](./kibana-plugin-core-public.overlaybannersstart.md) | | +| [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) | | +| [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md) | APIs to open and manage fly-out dialogs. | +| [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) | | +| [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) | | +| [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) | APIs to open and manage modal dialogs. | | [OverlayRef](./kibana-plugin-core-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-core-public.overlaystart.md) methods for closing a mounted overlay. | | [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | | | [Plugin](./kibana-plugin-core-public.plugin.md) | The interface that should be returned by a PluginInitializer. | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md new file mode 100644 index 0000000000000..d583aae0e0b19 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) + +## OverlayFlyoutOpenOptions."data-test-subj" property + +Signature: + +```typescript +'data-test-subj'?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.classname.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.classname.md new file mode 100644 index 0000000000000..26f6db77cccea --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.classname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) + +## OverlayFlyoutOpenOptions.className property + +Signature: + +```typescript +className?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md new file mode 100644 index 0000000000000..44014b7f0d816 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) + +## OverlayFlyoutOpenOptions.closeButtonAriaLabel property + +Signature: + +```typescript +closeButtonAriaLabel?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md new file mode 100644 index 0000000000000..5945bca01f55f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) + +## OverlayFlyoutOpenOptions interface + + +Signature: + +```typescript +export interface OverlayFlyoutOpenOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | string | | +| [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | string | | +| [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | +| [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md new file mode 100644 index 0000000000000..337ce2c48e1d9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) + +## OverlayFlyoutOpenOptions.ownFocus property + +Signature: + +```typescript +ownFocus?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.md new file mode 100644 index 0000000000000..790fd57320413 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md) + +## OverlayFlyoutStart interface + +APIs to open and manage fly-out dialogs. + +Signature: + +```typescript +export interface OverlayFlyoutStart +``` + +## Methods + +| Method | Description | +| --- | --- | +| [open(mount, options)](./kibana-plugin-core-public.overlayflyoutstart.open.md) | Opens a flyout panel with the given mount point inside. You can use close() on the returned FlyoutRef to close the flyout. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.open.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.open.md new file mode 100644 index 0000000000000..1f740410ca282 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutstart.open.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md) > [open](./kibana-plugin-core-public.overlayflyoutstart.open.md) + +## OverlayFlyoutStart.open() method + +Opens a flyout panel with the given mount point inside. You can use `close()` on the returned FlyoutRef to close the flyout. + +Signature: + +```typescript +open(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| mount | MountPoint | | +| options | OverlayFlyoutOpenOptions | | + +Returns: + +`OverlayRef` + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md new file mode 100644 index 0000000000000..3569b2153c3da --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > ["data-test-subj"](./kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md) + +## OverlayModalConfirmOptions."data-test-subj" property + +Signature: + +```typescript +'data-test-subj'?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md new file mode 100644 index 0000000000000..5c827e19e42e1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [buttonColor](./kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md) + +## OverlayModalConfirmOptions.buttonColor property + +Signature: + +```typescript +buttonColor?: EuiConfirmModalProps['buttonColor']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md new file mode 100644 index 0000000000000..0c0b9fd48dbd6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [cancelButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md) + +## OverlayModalConfirmOptions.cancelButtonText property + +Signature: + +```typescript +cancelButtonText?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md new file mode 100644 index 0000000000000..0a622aeaac418 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [className](./kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md) + +## OverlayModalConfirmOptions.className property + +Signature: + +```typescript +className?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md new file mode 100644 index 0000000000000..8a321a0b07b4c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md) + +## OverlayModalConfirmOptions.closeButtonAriaLabel property + +Signature: + +```typescript +closeButtonAriaLabel?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md new file mode 100644 index 0000000000000..f84d834369f5b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [confirmButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md) + +## OverlayModalConfirmOptions.confirmButtonText property + +Signature: + +```typescript +confirmButtonText?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md new file mode 100644 index 0000000000000..c5edf48b54ea8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [defaultFocusedButton](./kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md) + +## OverlayModalConfirmOptions.defaultFocusedButton property + +Signature: + +```typescript +defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md new file mode 100644 index 0000000000000..488b4eb3794fb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [maxWidth](./kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md) + +## OverlayModalConfirmOptions.maxWidth property + +Sets the max-width of the modal. Set to `true` to use the default (`euiBreakpoints 'm'`), set to `false` to not restrict the width, set to a number for a custom width in px, set to a string for a custom width in custom measurement. + +Signature: + +```typescript +maxWidth?: boolean | number | string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.md new file mode 100644 index 0000000000000..83405a151a372 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) + +## OverlayModalConfirmOptions interface + + +Signature: + +```typescript +export interface OverlayModalConfirmOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| ["data-test-subj"](./kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md) | string | | +| [buttonColor](./kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md) | EuiConfirmModalProps['buttonColor'] | | +| [cancelButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md) | string | | +| [className](./kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md) | string | | +| [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md) | string | | +| [confirmButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md) | string | | +| [defaultFocusedButton](./kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md) | EuiConfirmModalProps['defaultFocusedButton'] | | +| [maxWidth](./kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md) | boolean | number | string | Sets the max-width of the modal. Set to true to use the default (euiBreakpoints 'm'), set to false to not restrict the width, set to a number for a custom width in px, set to a string for a custom width in custom measurement. | +| [title](./kibana-plugin-core-public.overlaymodalconfirmoptions.title.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.title.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.title.md new file mode 100644 index 0000000000000..cfbe41e0a7e9f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalconfirmoptions.title.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) > [title](./kibana-plugin-core-public.overlaymodalconfirmoptions.title.md) + +## OverlayModalConfirmOptions.title property + +Signature: + +```typescript +title?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md new file mode 100644 index 0000000000000..f0eba659dc62b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) > ["data-test-subj"](./kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md) + +## OverlayModalOpenOptions."data-test-subj" property + +Signature: + +```typescript +'data-test-subj'?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.classname.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.classname.md new file mode 100644 index 0000000000000..769387b8c35ff --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.classname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) > [className](./kibana-plugin-core-public.overlaymodalopenoptions.classname.md) + +## OverlayModalOpenOptions.className property + +Signature: + +```typescript +className?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md new file mode 100644 index 0000000000000..4e685055b9e17 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) > [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md) + +## OverlayModalOpenOptions.closeButtonAriaLabel property + +Signature: + +```typescript +closeButtonAriaLabel?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md new file mode 100644 index 0000000000000..5c0ef8fb1ec86 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) + +## OverlayModalOpenOptions interface + + +Signature: + +```typescript +export interface OverlayModalOpenOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| ["data-test-subj"](./kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md) | string | | +| [className](./kibana-plugin-core-public.overlaymodalopenoptions.classname.md) | string | | +| [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.md new file mode 100644 index 0000000000000..1d8fe1a92dd90 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) + +## OverlayModalStart interface + +APIs to open and manage modal dialogs. + +Signature: + +```typescript +export interface OverlayModalStart +``` + +## Methods + +| Method | Description | +| --- | --- | +| [open(mount, options)](./kibana-plugin-core-public.overlaymodalstart.open.md) | Opens a modal panel with the given mount point inside. You can use close() on the returned OverlayRef to close the modal. | +| [openConfirm(message, options)](./kibana-plugin-core-public.overlaymodalstart.openconfirm.md) | Opens a confirmation modal with the given text or mountpoint as a message. Returns a Promise resolving to true if user confirmed or false otherwise. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.open.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.open.md new file mode 100644 index 0000000000000..1c6b82e37a624 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.open.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) > [open](./kibana-plugin-core-public.overlaymodalstart.open.md) + +## OverlayModalStart.open() method + +Opens a modal panel with the given mount point inside. You can use `close()` on the returned OverlayRef to close the modal. + +Signature: + +```typescript +open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| mount | MountPoint | | +| options | OverlayModalOpenOptions | | + +Returns: + +`OverlayRef` + diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.openconfirm.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.openconfirm.md new file mode 100644 index 0000000000000..b0052c0f6460e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalstart.openconfirm.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) > [openConfirm](./kibana-plugin-core-public.overlaymodalstart.openconfirm.md) + +## OverlayModalStart.openConfirm() method + +Opens a confirmation modal with the given text or mountpoint as a message. Returns a Promise resolving to `true` if user confirmed or `false` otherwise. + +Signature: + +```typescript +openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| message | MountPoint | string | | +| options | OverlayModalConfirmOptions | | + +Returns: + +`Promise` + diff --git a/package.json b/package.json index b45789172cee9..2560be4f55d08 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "7.10.0-rc.1", "@elastic/ems-client": "7.10.0", - "@elastic/eui": "30.1.1", + "@elastic/eui": "30.2.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/node-crypto": "1.2.1", @@ -598,7 +598,7 @@ "broadcast-channel": "^3.0.3", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^86.0.0", + "chromedriver": "^87.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compare-versions": "3.5.1", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index e326c8e2cac39..7cdbe844c2901 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -102,4 +102,5 @@ pageLoadAssetSize: visualizations: 295025 visualize: 57431 watcher: 43598 + runtimeFields: 41752 stackAlerts: 29684 diff --git a/src/cli/dev.js b/src/cli/dev.js index a284c82dfeb6e..99b7c1a696e49 100644 --- a/src/cli/dev.js +++ b/src/cli/dev.js @@ -19,4 +19,5 @@ require('../apm')(process.env.ELASTIC_APM_SERVICE_NAME || 'kibana-proxy'); require('../setup_node_env'); +require('../setup_node_env/root'); require('./cli'); diff --git a/src/cli/dist.js b/src/cli/dist.js index 05f0a68aa495c..bc14a3b530356 100644 --- a/src/cli/dist.js +++ b/src/cli/dist.js @@ -19,4 +19,5 @@ require('../apm')(); require('../setup_node_env/dist'); +require('../setup_node_env/root'); require('./cli'); diff --git a/src/cli_keystore/dev.js b/src/cli_keystore/dev.js index 12dc51134aad7..c229d26439bb5 100644 --- a/src/cli_keystore/dev.js +++ b/src/cli_keystore/dev.js @@ -17,5 +17,5 @@ * under the License. */ -require('../setup_node_env'); +require('../setup_node_env/no_transpilation'); require('./cli_keystore'); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 1393e69d55e51..564bbd712c535 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -167,7 +167,16 @@ export { IHttpResponseInterceptorOverrides, } from './http'; -export { OverlayStart, OverlayBannersStart, OverlayRef } from './overlays'; +export { + OverlayStart, + OverlayBannersStart, + OverlayRef, + OverlayFlyoutStart, + OverlayFlyoutOpenOptions, + OverlayModalOpenOptions, + OverlayModalConfirmOptions, + OverlayModalStart, +} from './overlays'; export { Toast, diff --git a/src/core/public/overlays/index.ts b/src/core/public/overlays/index.ts index 417486f78f719..31b524d85abbe 100644 --- a/src/core/public/overlays/index.ts +++ b/src/core/public/overlays/index.ts @@ -20,5 +20,5 @@ export { OverlayRef } from './types'; export { OverlayBannersStart } from './banners'; export { OverlayFlyoutStart, OverlayFlyoutOpenOptions } from './flyout'; -export { OverlayModalStart, OverlayModalOpenOptions } from './modal'; +export { OverlayModalStart, OverlayModalOpenOptions, OverlayModalConfirmOptions } from './modal'; export { OverlayService, OverlayStart } from './overlay_service'; diff --git a/src/core/public/overlays/modal/index.ts b/src/core/public/overlays/modal/index.ts index 9ef4492af3a3a..4e270838eae44 100644 --- a/src/core/public/overlays/modal/index.ts +++ b/src/core/public/overlays/modal/index.ts @@ -17,4 +17,9 @@ * under the License. */ -export { ModalService, OverlayModalStart, OverlayModalOpenOptions } from './modal_service'; +export { + ModalService, + OverlayModalStart, + OverlayModalOpenOptions, + OverlayModalConfirmOptions, +} from './modal_service'; diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index f3bbd5c94bdb4..4c0c205ae5438 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -70,6 +70,14 @@ export interface OverlayModalConfirmOptions { 'data-test-subj'?: string; defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton']; buttonColor?: EuiConfirmModalProps['buttonColor']; + /** + * Sets the max-width of the modal. + * Set to `true` to use the default (`euiBreakpoints 'm'`), + * set to `false` to not restrict the width, + * set to a number for a custom width in px, + * set to a string for a custom width in custom measurement. + */ + maxWidth?: boolean | number | string; } /** diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 28a20845426d9..37e57a9ee606e 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -862,6 +862,60 @@ export interface OverlayBannersStart { replace(id: string | undefined, mount: MountPoint, priority?: number): string; } +// @public (undocumented) +export interface OverlayFlyoutOpenOptions { + // (undocumented) + 'data-test-subj'?: string; + // (undocumented) + className?: string; + // (undocumented) + closeButtonAriaLabel?: string; + // (undocumented) + ownFocus?: boolean; +} + +// @public +export interface OverlayFlyoutStart { + open(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; +} + +// @public (undocumented) +export interface OverlayModalConfirmOptions { + // (undocumented) + 'data-test-subj'?: string; + // (undocumented) + buttonColor?: EuiConfirmModalProps['buttonColor']; + // (undocumented) + cancelButtonText?: string; + // (undocumented) + className?: string; + // (undocumented) + closeButtonAriaLabel?: string; + // (undocumented) + confirmButtonText?: string; + // (undocumented) + defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton']; + maxWidth?: boolean | number | string; + // (undocumented) + title?: string; +} + +// @public (undocumented) +export interface OverlayModalOpenOptions { + // (undocumented) + 'data-test-subj'?: string; + // (undocumented) + className?: string; + // (undocumented) + closeButtonAriaLabel?: string; +} + +// @public +export interface OverlayModalStart { + open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef; + openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise; +} + // @public export interface OverlayRef { close(): Promise; @@ -874,12 +928,8 @@ export interface OverlayStart { banners: OverlayBannersStart; // (undocumented) openConfirm: OverlayModalStart['openConfirm']; - // Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts - // // (undocumented) openFlyout: OverlayFlyoutStart['open']; - // Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts - // // (undocumented) openModal: OverlayModalStart['open']; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 8679cce9b11fc..19487efe1366c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -40,7 +40,7 @@ export async function runDockerGenerator( ubi: boolean = false ) { // UBI var config - const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:8.2' : 'centos:8'; + const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile index d17b597eb6648..9c78821f331e4 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -46,16 +46,9 @@ EXPOSE 5601 {{#ubi}} # https://github.com/rpm-software-management/microdnf/issues/50 RUN mkdir -p /run/user/$(id -u) - - # crypto-policies not currently compatible with libnss :sadpanda: - RUN printf "[main]\nexclude=crypto-policies" > /etc/dnf/dnf.conf {{/ubi}} RUN for iter in {1..10}; do \ - {{#ubi}} - # update microdnf to have exclusion feature for dnf configuration - {{packageManager}} update microdnf --setopt=tsflags=nodocs -y && \ - {{/ubi}} {{packageManager}} update --setopt=tsflags=nodocs -y && \ {{packageManager}} install --setopt=tsflags=nodocs -y \ fontconfig freetype shadow-utils libnss3.so {{#ubi}}findutils{{/ubi}} && \ diff --git a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh index 6eb111e066c83..83ed630058041 100644 --- a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh +++ b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh @@ -44,9 +44,11 @@ case $1 in IS_UPGRADE=true fi + PACKAGE=deb setup ;; abort-deconfigure|abort-upgrade|abort-remove) + PACKAGE=deb ;; # Red Hat @@ -63,7 +65,8 @@ case $1 in if [ "$1" = "2" ]; then IS_UPGRADE=true fi - + + PACKAGE=rpm setup ;; @@ -86,3 +89,13 @@ if [ "$IS_UPGRADE" = "true" ]; then echo " OK" fi fi + +# the equivalent code for rpm is in posttrans +if [ "$PACKAGE" = "deb" ]; then + if [ ! -f "${KBN_PATH_CONF}"/kibana.keystore ]; then + /usr/share/kibana/bin/kibana-keystore create + chown root:<%= group %> "${KBN_PATH_CONF}"/kibana.keystore + chmod 660 "${KBN_PATH_CONF}"/kibana.keystore + md5sum "${KBN_PATH_CONF}"/kibana.keystore > "${KBN_PATH_CONF}"/.kibana.keystore.initial_md5sum + fi +fi diff --git a/src/dev/build/tasks/os_packages/package_scripts/post_trans.sh b/src/dev/build/tasks/os_packages/package_scripts/post_trans.sh new file mode 100644 index 0000000000000..3c1bd3ccf88b4 --- /dev/null +++ b/src/dev/build/tasks/os_packages/package_scripts/post_trans.sh @@ -0,0 +1,8 @@ +export KBN_PATH_CONF=${KBN_PATH_CONF:-<%= configDir %>} + +if [ ! -f "${KBN_PATH_CONF}"/kibana.keystore ]; then + /usr/share/kibana/bin/kibana-keystore create + chown root:<%= group %> "${KBN_PATH_CONF}"/kibana.keystore + chmod 660 "${KBN_PATH_CONF}"/kibana.keystore + md5sum "${KBN_PATH_CONF}"/kibana.keystore > "${KBN_PATH_CONF}"/.kibana.keystore.initial_md5sum +fi diff --git a/src/dev/build/tasks/os_packages/run_fpm.ts b/src/dev/build/tasks/os_packages/run_fpm.ts index 7dff592eb9b83..def0289f53641 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.ts +++ b/src/dev/build/tasks/os_packages/run_fpm.ts @@ -45,6 +45,8 @@ export async function runFpm( } }; + const envFolder = type === 'rpm' ? 'sysconfig' : 'default'; + const args = [ // Force output even if it will overwrite an existing file '--force', @@ -92,6 +94,8 @@ export async function runFpm( resolve(__dirname, 'package_scripts/pre_remove.sh'), '--after-remove', resolve(__dirname, 'package_scripts/post_remove.sh'), + '--rpm-posttrans', + resolve(__dirname, 'package_scripts/post_trans.sh'), // tell fpm about the config file so that it is called out in the package definition '--config-files', @@ -140,6 +144,11 @@ export async function runFpm( // copy package configurations `${resolveWithTrailingSlash(__dirname, 'service_templates/systemd/')}=/`, + + `${resolveWithTrailingSlash( + __dirname, + 'service_templates/env/kibana' + )}=/etc/${envFolder}/kibana`, ]; log.debug('calling fpm with args:', args); diff --git a/src/dev/build/tasks/os_packages/service_templates/systemd/etc/default/kibana b/src/dev/build/tasks/os_packages/service_templates/env/kibana similarity index 100% rename from src/dev/build/tasks/os_packages/service_templates/systemd/etc/default/kibana rename to src/dev/build/tasks/os_packages/service_templates/env/kibana diff --git a/src/fixtures/telemetry_collectors/nested_collector.ts b/src/fixtures/telemetry_collectors/nested_collector.ts index bde89fe4a7060..ce563b46b0c4e 100644 --- a/src/fixtures/telemetry_collectors/nested_collector.ts +++ b/src/fixtures/telemetry_collectors/nested_collector.ts @@ -29,7 +29,7 @@ interface Usage { } export class NestedInside { - collector?: UsageCollector; + collector?: UsageCollector; createMyCollector() { this.collector = collectorSet.makeUsageCollector({ type: 'my_nested_collector', diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index 31ef011835917..dac84c87faf97 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -297,7 +297,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` paddingSize="none" >
{ componentProps?: Record; readDefaultValueOnForm?: boolean; onChange?: (value: I) => void; - children?: (field: FieldHook) => JSX.Element; + children?: (field: FieldHook) => JSX.Element | null; [key: string]: any; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index c1457c64080a6..6cb104416ef58 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -19,7 +19,7 @@ import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; import { - CollectorOptions, + Collector, createUsageCollectionSetupMock, } from '../../../../usage_collection/server/usage_collection.mock'; @@ -40,11 +40,11 @@ describe('telemetry_application_usage', () => { const logger = loggingSystemMock.createLogger(); - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts index e8efa9997c459..e31437a744e29 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts @@ -18,20 +18,22 @@ */ import { - CollectorOptions, + Collector, createUsageCollectionSetupMock, } from '../../../../usage_collection/server/usage_collection.mock'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerCoreUsageCollector } from '.'; -import { coreUsageDataServiceMock } from '../../../../../core/server/mocks'; +import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; import { CoreUsageData } from 'src/core/server/'; +const logger = loggingSystemMock.createLogger(); + describe('telemetry_core', () => { - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts index 03184d7385861..2851382f7559a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts @@ -19,8 +19,13 @@ import { CspConfig, ICspConfig } from '../../../../../core/server'; import { createCspCollector } from './csp_collector'; -import { httpServiceMock } from '../../../../../core/server/mocks'; -import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { httpServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { + Collector, + createCollectorFetchContextMock, +} from 'src/plugins/usage_collection/server/mocks'; + +const logger = loggingSystemMock.createLogger(); describe('csp collector', () => { let httpMock: ReturnType; @@ -36,7 +41,7 @@ describe('csp collector', () => { }); test('fetches whether strict mode is enabled', async () => { - const collector = createCspCollector(httpMock); + const collector = new Collector(logger, createCspCollector(httpMock)); expect((await collector.fetch(mockedFetchContext)).strict).toEqual(true); @@ -45,7 +50,7 @@ describe('csp collector', () => { }); test('fetches whether the legacy browser warning is enabled', async () => { - const collector = createCspCollector(httpMock); + const collector = new Collector(logger, createCspCollector(httpMock)); expect((await collector.fetch(mockedFetchContext)).warnLegacyBrowsers).toEqual(true); @@ -54,7 +59,7 @@ describe('csp collector', () => { }); test('fetches whether the csp rules have been changed or not', async () => { - const collector = createCspCollector(httpMock); + const collector = new Collector(logger, createCspCollector(httpMock)); expect((await collector.fetch(mockedFetchContext)).rulesChangedFromDefault).toEqual(false); @@ -63,7 +68,7 @@ describe('csp collector', () => { }); test('does not include raw csp rules under any property names', async () => { - const collector = createCspCollector(httpMock); + const collector = new Collector(logger, createCspCollector(httpMock)); // It's important that we do not send the value of csp.rules here as it // can be customized with values that can be identifiable to given diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts index 88ccb2016d420..16a60eca60f47 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts @@ -17,20 +17,25 @@ * under the License. */ -import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks'; import { - CollectorOptions, + loggingSystemMock, + pluginInitializerContextConfigMock, +} from '../../../../../core/server/mocks'; +import { + Collector, createUsageCollectionSetupMock, } from '../../../../usage_collection/server/usage_collection.mock'; import { createCollectorFetchContextMock } from '../../../../usage_collection/server/mocks'; import { registerKibanaUsageCollector } from './'; +const logger = loggingSystemMock.createLogger(); + describe('telemetry_kibana', () => { - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts index e671f739ee083..0aafee01cf49d 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts @@ -17,21 +17,23 @@ * under the License. */ -import { uiSettingsServiceMock } from '../../../../../core/server/mocks'; +import { loggingSystemMock, uiSettingsServiceMock } from '../../../../../core/server/mocks'; import { - CollectorOptions, + Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, } from '../../../../usage_collection/server/usage_collection.mock'; import { registerManagementUsageCollector } from './'; +const logger = loggingSystemMock.createLogger(); + describe('telemetry_application_usage_collector', () => { - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts index 61990730812cc..8db7010e64026 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts @@ -19,20 +19,23 @@ import { Subject } from 'rxjs'; import { - CollectorOptions, + Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, } from '../../../../usage_collection/server/usage_collection.mock'; import { registerOpsStatsCollector } from './'; import { OpsMetrics } from '../../../../../core/server'; +import { loggingSystemMock } from '../../../../../core/server/mocks'; + +const logger = loggingSystemMock.createLogger(); describe('telemetry_ops_stats', () => { - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeStatsCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeStatsCollector(config); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts index 48e4e0d99d3cd..90e3b7110e1dc 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts @@ -17,21 +17,23 @@ * under the License. */ -import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; import { - CollectorOptions, + Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, } from '../../../../usage_collection/server/usage_collection.mock'; import { registerUiMetricUsageCollector } from './'; +const logger = loggingSystemMock.createLogger(); + describe('telemetry_ui_metric', () => { - let collector: CollectorOptions; + let collector: Collector; const usageCollectionMock = createUsageCollectionSetupMock(); usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; + collector = new Collector(logger, config); return createUsageCollectionSetupMock().makeUsageCollector(config); }); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 654c5435650cf..207a467ca5fd0 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -21,6 +21,7 @@ import { omit } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ISavedObjectsRepository, + KibanaRequest, LegacyAPICaller, SavedObjectsClientContract, } from 'kibana/server'; @@ -89,8 +90,14 @@ export async function getKibana( usageCollection: UsageCollectionSetup, callWithInternalUser: LegacyAPICaller, asInternalUser: ElasticsearchClient, - soClient: SavedObjectsClientContract | ISavedObjectsRepository + soClient: SavedObjectsClientContract | ISavedObjectsRepository, + kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ): Promise { - const usage = await usageCollection.bulkFetch(callWithInternalUser, asInternalUser, soClient); + const usage = await usageCollection.bulkFetch( + callWithInternalUser, + asInternalUser, + soClient, + kibanaRequest + ); return usageCollection.toObject(usage); } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index 9298b36ac3ea6..6231fd29e7d3d 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -24,7 +24,7 @@ import { usageCollectionPluginMock, createCollectorFetchContextMock, } from '../../../usage_collection/server/mocks'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks'; function mockUsageCollection(kibanaUsage = {}) { const usageCollection = usageCollectionPluginMock.createSetupContract(); @@ -87,6 +87,7 @@ function mockStatsCollectionConfig(clusterInfo: any, clusterStats: any, kibana: ...createCollectorFetchContextMock(), esClient: mockGetLocalStats(clusterInfo, clusterStats), usageCollection: mockUsageCollection(kibana), + kibanaRequest: httpServerMock.createKibanaRequest(), timestamp: Date.now(), }; } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index 4aeefb1d81d6a..a3666683a05a1 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -62,16 +62,16 @@ export type TelemetryLocalStats = ReturnType; /** * Get statistics for all products joined by Elasticsearch cluster. - * @param {Array} cluster uuids - * @param {Object} config contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end + * @param {Array} cluster uuids array of cluster uuid's + * @param {Object} config contains the usageCollection, callCluster (deprecated), the esClient and Saved Objects client scoped to the request or the internal repository, and the kibana request * @param {Object} StatsCollectionContext contains logger and version (string) */ export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( - clustersDetails, // array of cluster uuid's - config, // contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end and the saved objects client scoped to the request or the internal repository - context // StatsCollectionContext contains logger and version (string) + clustersDetails, + config, + context ) => { - const { callCluster, usageCollection, esClient, soClient } = config; + const { callCluster, usageCollection, esClient, soClient, kibanaRequest } = config; return await Promise.all( clustersDetails.map(async (clustersDetail) => { @@ -79,7 +79,7 @@ export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( getClusterInfo(esClient), // cluster info getClusterStats(esClient), // cluster stats (not to be confused with cluster _state_) getNodesUsage(esClient), // nodes_usage info - getKibana(usageCollection, callCluster, esClient, soClient), + getKibana(usageCollection, callCluster, esClient, soClient, kibanaRequest), getDataTelemetry(esClient), ]); return handleLocalStats( diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index e1e1379097adf..2cd06f13a8855 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -157,7 +157,10 @@ export class TelemetryCollectionManagerPlugin const soClient = config.unencrypted ? collectionSoService.getScopedClient(config.request) : collectionSoService.createInternalRepository(); - return { callCluster, timestamp, usageCollection, esClient, soClient }; + // Provide the kibanaRequest so opted-in plugins can scope their custom clients only if the request is not encrypted + const kibanaRequest = config.unencrypted ? request : void 0; + + return { callCluster, timestamp, usageCollection, esClient, soClient, kibanaRequest }; } private async getOptInStats(optInStatus: boolean, config: StatsGetterConfig) { diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 0100fdbbb3970..7d25b8c8261c4 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -79,6 +79,7 @@ export interface StatsCollectionConfig { timestamp: number; esClient: ElasticsearchClient; soClient: SavedObjectsClientContract | ISavedObjectsRepository; + kibanaRequest: KibanaRequest | undefined; // intentionally `| undefined` to enforce providing the parameter } export interface BasicStatsPayload { diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 2ac3de510f8ae..5e6ed901c7647 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -31,7 +31,7 @@ Then you need to make the Telemetry service aware of the collector by registerin ```ts // server/plugin.ts import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; - import { CoreSetup, CoreStart } from 'kibana/server'; + import { CoreSetup, CoreStart } from 'src/core/server'; class Plugin { public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { @@ -46,7 +46,7 @@ Then you need to make the Telemetry service aware of the collector by registerin ```ts // server/collectors/register.ts import { UsageCollectionSetup, CollectorFetchContext } from 'src/plugins/usage_collection/server'; - import { APICluster } from 'kibana/server'; + import { APICluster } from 'src/core/server'; interface Usage { my_objects: { @@ -95,8 +95,8 @@ Some background: - `isReady` (added in v7.2.0 and v6.8.4) is a way for a usage collector to announce that some async process must finish first before it can return data in the `fetch` method (e.g. a client needs to ne initialized, or the task manager needs to run a task first). If any collector reports that it is not ready when we call its `fetch` method, we reset a flag to try again and, after a set amount of time, collect data from those collectors that are ready and skip any that are not. This means that if a collector returns `true` for `isReady` and it actually isn't ready to return data, there won't be telemetry data from that collector in that telemetry report (usually once per day). You should consider what it means if your collector doesn't return data in the first few documents when Kibana starts or, if we should wait for any other reason (e.g. the task manager needs to run your task first). If you need to tell telemetry collection to wait, you should implement this function with custom logic. If your `fetch` method can run without the need of any previous dependencies, then you can return true for `isReady` as shown in the example below. -- The `fetch` method needs to support multiple contexts in which it is called. For example, when stats are pulled from a Kibana Metricbeat module, the Beat calls Kibana's stats API to invoke usage collection. -In this case, the `fetch` method is called as a result of an HTTP API request and `callCluster` wraps `callWithRequest` or `esClient` wraps `asCurrentUser`, where the request headers are expected to have read privilege on the entire `.kibana' index. The `fetch` method also exposes the saved objects client that will have the correct scope when the collectors' `fetch` method is called. +- The `fetch` method needs to support multiple contexts in which it is called. For example, when a user requests the example of what we collect in the **Kibana>Advanced Settings>Usage data** section, the clients provided in the context of the function (`CollectorFetchContext`) are scoped to that user's privileges. The reason is to avoid exposing via telemetry any data that user should not have access to (i.e.: if the user does not have access to certain indices, they shouldn't be allowed to see the number of documents that exists in it). In this case, the `fetch` method receives the clients `callCluster`, `esClient` and `soClient` scoped to the user who performed the HTTP API request. Alternatively, when requesting the usage data to be reported to the Remote Telemetry Service, the clients are scoped to the internal Kibana user (`kibana_system`). Please, mind it might have lower-level access than the default super-admin `elastic` test user. +In some scenarios, your collector might need to maintain its own client. An example of that is the `monitoring` plugin, that maintains a connection to the Remote Monitoring Cluster to push its monitoring data. If that's the case, your plugin can opt-in to receive the additional `kibanaRequest` parameter by adding `extendFetchContext.kibanaRequest: true` to the collector's config: it will be appended to the context of the `fetch` method only if the request needs to be scoped to a user other than Kibana Internal, so beware that your collector will need to work for both scenarios (especially for the scenario when `kibanaRequest` is missing). Note: there will be many cases where you won't need to use the `callCluster`, `esClient` or `soClient` 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. @@ -105,7 +105,7 @@ In the case of using a custom SavedObjects client, it is up to the plugin to ini ```ts // server/plugin.ts import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CoreSetup, CoreStart } from 'kibana/server'; +import { CoreSetup, CoreStart } from 'src/core/server'; class Plugin { private savedObjectsRepository?: ISavedObjectsRepository; diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index c04b087d4adf5..797fdaa06a620 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -23,7 +23,8 @@ import { ElasticsearchClient, ISavedObjectsRepository, SavedObjectsClientContract, -} from 'kibana/server'; + KibanaRequest, +} from 'src/core/server'; export type CollectorFormatForBulkUpload = (result: T) => { type: string; payload: U }; @@ -46,26 +47,71 @@ export type MakeSchemaFrom = { : RecursiveMakeSchemaFrom[Key]>; }; -export interface CollectorFetchContext { +/** + * The context for the `fetch` method: It includes the most commonly used clients in the collectors (ES and SO clients). + * Both are scoped based on the request and the context: + * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they shouldn't read + * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user + * + * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster. + */ +export type CollectorFetchContext = { /** - * @depricated Scoped Legacy Elasticsearch client: use esClient instead + * @deprecated Scoped Legacy Elasticsearch client: use esClient instead */ callCluster: LegacyAPICaller; /** - * Request-scoped Elasticsearch client: - * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they should't read - * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user + * Request-scoped Elasticsearch client + * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext}) */ esClient: ElasticsearchClient; /** - * Request-scoped Saved Objects client: - * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they should't read - * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user + * Request-scoped Saved Objects client + * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext}) */ soClient: SavedObjectsClientContract | ISavedObjectsRepository; +} & (WithKibanaRequest extends true + ? { + /** + * The KibanaRequest that can be used to scope the requests: + * It is provided only when your custom clients need to be scoped. If not available, you should use the Internal Client. + * More information about when scoping is needed: {@link CollectorFetchContext} + * @remark You should only use this if you implement your collector to deal with both scenarios: when provided and, especially, when not provided. When telemetry payload is sent to the remote service the `kibanaRequest` will not be provided. + */ + kibanaRequest?: KibanaRequest; + } + : {}); + +export type CollectorFetchMethod< + WithKibanaRequest extends boolean | undefined, + TReturn, + ExtraOptions extends object = {} +> = ( + this: Collector & ExtraOptions, // Specify the context of `this` for this.log and others to become available + context: CollectorFetchContext +) => Promise | TReturn; + +export interface ICollectorOptionsFetchExtendedContext { + /** + * Set to `true` if your `fetch` method requires the `KibanaRequest` object to be added in its context {@link CollectorFetchContextWithRequest}. + * @remark You should fully understand acknowledge that by using the `KibanaRequest` in your collector, you need to ensure it should specially work without it because it won't be provided when building the telemetry payload actually sent to the remote telemetry service. + */ + kibanaRequest?: WithKibanaRequest; } -export interface CollectorOptions { +export type CollectorOptionsFetchExtendedContext< + WithKibanaRequest extends boolean +> = ICollectorOptionsFetchExtendedContext & + (WithKibanaRequest extends true // If enforced to true via Types, the config must be expected + ? Required, 'kibanaRequest'>> + : {}); + +export type CollectorOptions< + TFetchReturn = unknown, + UFormatBulkUploadPayload = TFetchReturn, // TODO: Once we remove bulk_uploader's dependency on usageCollection, we'll be able to remove this type + WithKibanaRequest extends boolean = boolean, + ExtraOptions extends object = {} +> = { /** * Unique string identifier for the collector */ @@ -78,23 +124,42 @@ export interface CollectorOptions { /** * Schema definition of the output of the `fetch` method. */ - schema?: MakeSchemaFrom; - fetch: (collectorFetchContext: CollectorFetchContext) => Promise | T; - /* + schema?: MakeSchemaFrom; + /** + * The method that will collect and return the data in the final format. + * @param collectorFetchContext {@link CollectorFetchContext} + */ + fetch: CollectorFetchMethod; + /** * A hook for allowing the fetched data payload to be organized into a typed * data model for internal bulk upload. See defaultFormatterForBulkUpload for * a generic example. * @deprecated Used only by the Legacy Monitoring collection (to be removed in 8.0) */ - formatForBulkUpload?: CollectorFormatForBulkUpload; -} + formatForBulkUpload?: CollectorFormatForBulkUpload; +} & ExtraOptions & + (WithKibanaRequest extends true // If enforced to true via Types, the config must be enforced + ? { + extendFetchContext: CollectorOptionsFetchExtendedContext; + } + : { + extendFetchContext?: CollectorOptionsFetchExtendedContext; + }); -export class Collector { - public readonly type: CollectorOptions['type']; - public readonly init?: CollectorOptions['init']; - public readonly fetch: CollectorOptions['fetch']; - private readonly _formatForBulkUpload?: CollectorFormatForBulkUpload; - public readonly isReady: CollectorOptions['isReady']; +export class Collector< + TFetchReturn, + UFormatBulkUploadPayload = TFetchReturn, + ExtraOptions extends object = {} +> { + public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; + public readonly type: CollectorOptions['type']; + public readonly init?: CollectorOptions['init']; + public readonly fetch: CollectorFetchMethod; + public readonly isReady: CollectorOptions['isReady']; + private readonly _formatForBulkUpload?: CollectorFormatForBulkUpload< + TFetchReturn, + UFormatBulkUploadPayload + >; /* * @param {Object} logger - logger object * @param {String} options.type - property name as the key for the data @@ -105,8 +170,16 @@ export class Collector { * @param {Function} options.rest - optional other properties */ constructor( - protected readonly log: Logger, - { type, init, fetch, formatForBulkUpload, isReady, ...options }: CollectorOptions + public readonly log: Logger, + { + type, + init, + fetch, + formatForBulkUpload, + isReady, + extendFetchContext = {}, + ...options + }: CollectorOptions ) { if (type === undefined) { throw new Error('Collector must be instantiated with a options.type string property'); @@ -126,10 +199,11 @@ export class Collector { this.init = init; this.fetch = fetch; this.isReady = typeof isReady === 'function' ? isReady : () => true; + this.extendFetchContext = extendFetchContext; this._formatForBulkUpload = formatForBulkUpload; } - public formatForBulkUpload(result: T) { + public formatForBulkUpload(result: TFetchReturn) { if (this._formatForBulkUpload) { return this._formatForBulkUpload(result); } else { @@ -137,10 +211,10 @@ export class Collector { } } - protected defaultFormatterForBulkUpload(result: T) { + protected defaultFormatterForBulkUpload(result: TFetchReturn) { return { type: this.type, - payload: (result as unknown) as U, + payload: (result as unknown) as UFormatBulkUploadPayload, }; } } diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index 359a2d214f991..fc17ce131430c 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -47,6 +47,7 @@ describe('CollectorSet', () => { const mockCallCluster = jest.fn().mockResolvedValue({ passTest: 1000 }); const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const mockSoClient = savedObjectsRepositoryMock.create(); + const req = void 0; // No need to instantiate any KibanaRequest in these tests it('should throw an error if non-Collector type of object is registered', () => { const collectors = new CollectorSet({ logger }); @@ -93,7 +94,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req); expect(loggerSpies.debug).toHaveBeenCalledTimes(1); expect(loggerSpies.debug).toHaveBeenCalledWith( 'Fetching data from MY_TEST_COLLECTOR collector' @@ -118,7 +119,7 @@ describe('CollectorSet', () => { let result; try { - result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); + result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req); } catch (err) { // Do nothing } @@ -136,7 +137,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -154,7 +155,7 @@ describe('CollectorSet', () => { } as any) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -177,7 +178,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -274,4 +275,272 @@ describe('CollectorSet', () => { expect(collectors.isUsageCollector(void 0)).toEqual(false); }); }); + + describe('makeStatsCollector', () => { + const collectorSet = new CollectorSet({ logger }); + test('TS should hide kibanaRequest when not opted-in', () => { + collectorSet.makeStatsCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + }); + }); + + test('TS should hide kibanaRequest when not opted-in (explicit false)', () => { + collectorSet.makeStatsCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: false, + }, + }); + }); + + test('TS should allow using kibanaRequest when opted-in (explicit true)', () => { + collectorSet.makeStatsCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: true, + }, + }); + }); + + test('fetch can use the logger (TS allows it)', () => { + const collector = collectorSet.makeStatsCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch() { + this.log.info("I can use the Collector's class logger!"); + return { test: 1 }; + }, + }); + expect( + collector.fetch( + // @ts-expect-error: the test implementation is not using it + {} + ) + ).toStrictEqual({ test: 1 }); + }); + }); + + describe('makeUsageCollector', () => { + const collectorSet = new CollectorSet({ logger }); + describe('TS validations', () => { + describe('when types are inferred', () => { + test('TS should hide kibanaRequest when not opted-in', () => { + collectorSet.makeUsageCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + }); + }); + + test('TS should hide kibanaRequest when not opted-in (explicit false)', () => { + collectorSet.makeUsageCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: false, + }, + }); + }); + + test('TS should allow using kibanaRequest when opted-in (explicit true)', () => { + collectorSet.makeUsageCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: true, + }, + }); + }); + }); + + describe('when types are explicit', () => { + test('TS should hide `kibanaRequest` from ctx when undefined or false', () => { + collectorSet.makeUsageCollector<{ test: number }>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, false>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: false, + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, false>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + }); + }); + test('TS should not allow `true` when types declare false', () => { + // false is the default when at least 1 type is specified + collectorSet.makeUsageCollector<{ test: number }>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + // @ts-expect-error + kibanaRequest: true, + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, false>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + // @ts-expect-error + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + // @ts-expect-error + kibanaRequest: true, + }, + }); + }); + + test('TS should allow `true` when types explicitly declare `true` and do not allow `false` or undefined', () => { + // false is the default when at least 1 type is specified + collectorSet.makeUsageCollector<{ test: number }, unknown, true>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + kibanaRequest: true, + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, true>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + // @ts-expect-error + kibanaRequest: false, + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, true>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + extendFetchContext: { + // @ts-expect-error + kibanaRequest: undefined, + }, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, true>({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + // @ts-expect-error + extendFetchContext: {}, + }); + collectorSet.makeUsageCollector<{ test: number }, unknown, true>( + // @ts-expect-error + { + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch: (ctx) => { + const { kibanaRequest } = ctx; + return { test: kibanaRequest ? 1 : 0 }; + }, + } + ); + }); + }); + }); + + test('fetch can use the logger (TS allows it)', () => { + const collector = collectorSet.makeUsageCollector({ + type: 'MY_TEST_COLLECTOR', + isReady: () => true, + schema: { test: { type: 'long' } }, + fetch() { + this.log.info("I can use the Collector's class logger!"); + return { test: 1 }; + }, + }); + expect( + collector.fetch( + // @ts-expect-error: the test implementation is not using it + {} + ) + ).toStrictEqual({ test: 1 }); + }); + }); }); diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index c52830cda6513..fe4f3536ffed6 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -24,50 +24,79 @@ import { ElasticsearchClient, ISavedObjectsRepository, SavedObjectsClientContract, -} from 'kibana/server'; + KibanaRequest, +} from 'src/core/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; +type AnyCollector = Collector; +type AnyUsageCollector = UsageCollector; + interface CollectorSetConfig { logger: Logger; maximumWaitTimeForAllCollectorsInS?: number; - collectors?: Array>; + collectors?: AnyCollector[]; } export class CollectorSet { private _waitingForAllCollectorsTimestamp?: number; private readonly logger: Logger; private readonly maximumWaitTimeForAllCollectorsInS: number; - private readonly collectors: Map>; + private readonly collectors: Map; constructor({ logger, maximumWaitTimeForAllCollectorsInS, collectors = [] }: CollectorSetConfig) { this.logger = logger; this.collectors = new Map(collectors.map((collector) => [collector.type, collector])); this.maximumWaitTimeForAllCollectorsInS = maximumWaitTimeForAllCollectorsInS || 60; } + /** + * Instantiates a stats collector with the definition provided in the options + * @param options Definition of the collector {@link CollectorOptions} + */ public makeStatsCollector = < - T, - U, - O extends CollectorOptions = CollectorOptions // Used to allow extra properties (the Collector constructor extends the class with the additional options provided) + TFetchReturn, + TFormatForBulkUpload, + WithKibanaRequest extends boolean, + ExtraOptions extends object = {} >( - options: O + options: CollectorOptions ) => { - return new Collector(this.logger, options); + return new Collector(this.logger, options); }; + + /** + * Instantiates an usage collector with the definition provided in the options + * @param options Definition of the collector {@link CollectorOptions} + */ public makeUsageCollector = < - T, - U = T, - O extends UsageCollectorOptions = UsageCollectorOptions + TFetchReturn, + TFormatForBulkUpload = { usage: { [key: string]: TFetchReturn } }, + // TODO: Right now, users will need to explicitly claim `true` for TS to allow `kibanaRequest` usage. + // If we improve `telemetry-check-tools` so plugins do not need to specify TFetchReturn, + // we'll be able to remove the type defaults and TS will successfully infer the config value as provided in JS. + WithKibanaRequest extends boolean = false, + ExtraOptions extends object = {} >( - options: O + options: UsageCollectorOptions< + TFetchReturn, + TFormatForBulkUpload, + WithKibanaRequest, + ExtraOptions + > ) => { - return new UsageCollector(this.logger, options); + return new UsageCollector( + this.logger, + options + ); }; - /* - * @param collector {Collector} collector object + /** + * Registers a collector to be used when collecting all the usage and stats data + * @param collector Collector to be added to the set (previously created via `makeUsageCollector` or `makeStatsCollector`) */ - public registerCollector = (collector: Collector) => { + public registerCollector = ( + collector: Collector + ) => { // check instanceof if (!(collector instanceof Collector)) { throw new Error('CollectorSet can only have Collector instances registered'); @@ -89,7 +118,7 @@ export class CollectorSet { return [...this.collectors.values()].find((c) => c.type === type); }; - public isUsageCollector = (x: UsageCollector | any): x is UsageCollector => { + public isUsageCollector = (x: AnyUsageCollector | any): x is AnyUsageCollector => { return x instanceof UsageCollector; }; @@ -144,15 +173,22 @@ export class CollectorSet { callCluster: LegacyAPICaller, esClient: ElasticsearchClient, soClient: SavedObjectsClientContract | ISavedObjectsRepository, - collectors: Map> = this.collectors + kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter + collectors: Map = this.collectors ) => { const responses = await Promise.all( [...collectors.values()].map(async (collector) => { this.logger.debug(`Fetching data from ${collector.type} collector`); try { + const context = { + callCluster, + esClient, + soClient, + ...(collector.extendFetchContext.kibanaRequest && { kibanaRequest }), + }; return { type: collector.type, - result: await collector.fetch({ callCluster, esClient, soClient }), + result: await collector.fetch(context), }; } catch (err) { this.logger.warn(err); @@ -169,7 +205,7 @@ export class CollectorSet { /* * @return {new CollectorSet} */ - public getFilteredCollectorSet = (filter: (col: Collector) => boolean) => { + public getFilteredCollectorSet = (filter: (col: AnyCollector) => boolean) => { const filtered = [...this.collectors.values()].filter(filter); return this.makeCollectorSetFromArray(filtered); }; @@ -177,13 +213,15 @@ export class CollectorSet { public bulkFetchUsage = async ( callCluster: LegacyAPICaller, esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository + savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ) => { const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector); return await this.bulkFetch( callCluster, esClient, savedObjectsClient, + kibanaRequest, usageCollectors.collectors ); }; @@ -239,7 +277,7 @@ export class CollectorSet { return [...this.collectors.values()].some(someFn); }; - private makeCollectorSetFromArray = (collectors: Collector[]) => { + private makeCollectorSetFromArray = (collectors: AnyCollector[]) => { return new CollectorSet({ logger: this.logger, maximumWaitTimeForAllCollectorsInS: this.maximumWaitTimeForAllCollectorsInS, diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts index 5bfc36537e0b0..a042ea113d5cc 100644 --- a/src/plugins/usage_collection/server/collector/usage_collector.ts +++ b/src/plugins/usage_collection/server/collector/usage_collector.ts @@ -22,25 +22,39 @@ import { KIBANA_STATS_TYPE } from '../../common/constants'; import { Collector, CollectorOptions } from './collector'; // Enforce the `schema` property for UsageCollectors -export type UsageCollectorOptions = CollectorOptions & - Required, 'schema'>>; +export type UsageCollectorOptions< + TFetchReturn = unknown, + UFormatBulkUploadPayload = { usage: { [key: string]: TFetchReturn } }, + WithKibanaRequest extends boolean = false, + ExtraOptions extends object = {} +> = CollectorOptions & + Required, 'schema'>>; -export class UsageCollector extends Collector< - T, - U -> { - constructor(protected readonly log: Logger, collectorOptions: UsageCollectorOptions) { +export class UsageCollector< + TFetchReturn, + UFormatBulkUploadPayload = { usage: { [key: string]: TFetchReturn } }, + ExtraOptions extends object = {} +> extends Collector { + constructor( + public readonly log: Logger, + collectorOptions: UsageCollectorOptions< + TFetchReturn, + UFormatBulkUploadPayload, + any, + ExtraOptions + > + ) { super(log, collectorOptions); } - protected defaultFormatterForBulkUpload(result: T) { + protected defaultFormatterForBulkUpload(result: TFetchReturn) { return { type: KIBANA_STATS_TYPE, payload: ({ usage: { [this.type]: result, }, - } as unknown) as U, + } as unknown) as UFormatBulkUploadPayload, }; } } diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index 76379d9385cff..09b0e05025e63 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -18,7 +18,7 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; +import { PluginConfigDescriptor } from 'src/core/server'; import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants'; export const configSchema = schema.object({ diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index f7a08fdb5e9dd..48ea9afa13976 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 'kibana/server'; +import { PluginInitializerContext } from 'src/core/server'; import { UsageCollectionPlugin } from './plugin'; export { diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts index d08db1eaec0e1..3d89380f629dc 100644 --- a/src/plugins/usage_collection/server/mocks.ts +++ b/src/plugins/usage_collection/server/mocks.ts @@ -20,7 +20,7 @@ import { loggingSystemMock } from '../../../core/server/mocks'; import { UsageCollectionSetup } from './plugin'; import { CollectorSet } from './collector'; -export { createCollectorFetchContextMock } from './usage_collection.mock'; +export { Collector, createCollectorFetchContextMock } from './usage_collection.mock'; const createSetupContract = () => { return { diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index 74e70d5ea9d35..9a8876446d01e 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -25,7 +25,7 @@ import { CoreStart, ISavedObjectsRepository, Plugin, -} from 'kibana/server'; +} from 'src/core/server'; import { ConfigType } from './config'; import { CollectorSet } from './collector'; import { setupRoutes } from './routes'; diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts index c40622831eeee..d9aac23fd1ff0 100644 --- a/src/plugins/usage_collection/server/report/store_report.ts +++ b/src/plugins/usage_collection/server/report/store_report.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ISavedObjectsRepository, SavedObject } from 'kibana/server'; +import { ISavedObjectsRepository, SavedObject } from 'src/core/server'; import { ReportSchemaType } from './schema'; export async function storeReport( diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts index b367ddc184be7..15d408ff3723b 100644 --- a/src/plugins/usage_collection/server/routes/index.ts +++ b/src/plugins/usage_collection/server/routes/index.ts @@ -22,7 +22,7 @@ import { ISavedObjectsRepository, MetricsServiceSetup, ServiceStatus, -} from 'kibana/server'; +} from 'src/core/server'; import { Observable } from 'rxjs'; import { CollectorSet } from '../collector'; import { registerUiMetricRoute } from './report_metrics'; diff --git a/src/plugins/usage_collection/server/routes/report_metrics.ts b/src/plugins/usage_collection/server/routes/report_metrics.ts index a72222968eabf..590c3340697b8 100644 --- a/src/plugins/usage_collection/server/routes/report_metrics.ts +++ b/src/plugins/usage_collection/server/routes/report_metrics.ts @@ -18,7 +18,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, ISavedObjectsRepository } from 'kibana/server'; +import { IRouter, ISavedObjectsRepository } from 'src/core/server'; import { storeReport, reportSchema } from '../report'; export function registerUiMetricRoute( diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index d38250067053c..16a1c2c742f04 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -27,6 +27,7 @@ import { ElasticsearchClient, IRouter, ISavedObjectsRepository, + KibanaRequest, LegacyAPICaller, MetricsServiceSetup, SavedObjectsClientContract, @@ -67,9 +68,15 @@ export function registerStatsRoute({ const getUsage = async ( callCluster: LegacyAPICaller, esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository + savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + kibanaRequest: KibanaRequest ): Promise => { - const usage = await collectorSet.bulkFetchUsage(callCluster, esClient, savedObjectsClient); + const usage = await collectorSet.bulkFetchUsage( + callCluster, + esClient, + savedObjectsClient, + kibanaRequest + ); return collectorSet.toObject(usage); }; @@ -115,7 +122,7 @@ export function registerStatsRoute({ } const usagePromise = shouldGetUsage - ? getUsage(callCluster, asCurrentUser, savedObjectsClient) + ? getUsage(callCluster, asCurrentUser, savedObjectsClient, req) : Promise.resolve({}); const [usage, clusterUuid] = await Promise.all([ usagePromise, diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts index c31756c60e32d..05dae8fa85164 100644 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ b/src/plugins/usage_collection/server/usage_collection.mock.ts @@ -19,13 +19,17 @@ import { elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, savedObjectsRepositoryMock, } from '../../../../src/core/server/mocks'; -import { CollectorOptions } from './collector/collector'; +import { CollectorOptions, Collector, UsageCollector } from './collector'; import { UsageCollectionSetup, CollectorFetchContext } from './index'; -export { CollectorOptions }; +export { CollectorOptions, Collector }; + +const logger = loggingSystemMock.createLogger(); export const createUsageCollectionSetupMock = () => { const usageCollectionSetupMock: jest.Mocked = { @@ -37,13 +41,13 @@ export const createUsageCollectionSetupMock = () => { // @ts-ignore jest.fn doesn't play nice with type guards isUsageCollector: jest.fn(), makeCollectorSetFromArray: jest.fn(), - makeStatsCollector: jest.fn(), map: jest.fn(), maximumWaitTimeForAllCollectorsInS: 0, some: jest.fn(), toApiFieldNames: jest.fn(), toObject: jest.fn(), - makeUsageCollector: jest.fn(), + makeStatsCollector: jest.fn().mockImplementation((cfg) => new Collector(logger, cfg)), + makeUsageCollector: jest.fn().mockImplementation((cfg) => new UsageCollector(logger, cfg)), registerCollector: jest.fn(), }; @@ -51,11 +55,23 @@ export const createUsageCollectionSetupMock = () => { return usageCollectionSetupMock; }; -export function createCollectorFetchContextMock(): jest.Mocked { - const collectorFetchClientsMock: jest.Mocked = { +export function createCollectorFetchContextMock(): jest.Mocked> { + const collectorFetchClientsMock: jest.Mocked> = { + callCluster: elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser, + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsRepositoryMock.create(), + }; + return collectorFetchClientsMock; +} + +export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< + CollectorFetchContext +> { + const collectorFetchClientsMock: jest.Mocked> = { callCluster: elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, soClient: savedObjectsRepositoryMock.create(), + kibanaRequest: httpServerMock.createKibanaRequest(), }; return collectorFetchClientsMock; } diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx index a448b58afe8a4..baf3365a514a6 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx @@ -33,6 +33,8 @@ import { SERIES_ID_ATTR, colors, Axis, + ACTIVE_CURSOR, + eventBus, } from '../helpers/panel_utils'; import { Series, Sheet } from '../helpers/timelion_request_handler'; @@ -338,16 +340,40 @@ function TimelionVisComponent({ }); }, [legendCaption, legendValueNumbers]); + const plotHover = useCallback( + (pos: Position) => { + (plot as CrosshairPlot).setCrosshair(pos); + debouncedSetLegendNumbers(pos); + }, + [plot, debouncedSetLegendNumbers] + ); + const plotHoverHandler = useCallback( (event: JQuery.TriggeredEvent, pos: Position) => { if (!plot) { return; } - (plot as CrosshairPlot).setCrosshair(pos); - debouncedSetLegendNumbers(pos); + plotHover(pos); + eventBus.trigger(ACTIVE_CURSOR, [event, pos]); }, - [plot, debouncedSetLegendNumbers] + [plot, plotHover] ); + + useEffect(() => { + const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => { + if (!plot) { + return; + } + plotHover(pos); + }; + + eventBus.on(ACTIVE_CURSOR, updateCursor); + + return () => { + eventBus.off(ACTIVE_CURSOR, updateCursor); + }; + }, [plot, plotHover]); + const mouseLeaveHandler = useCallback(() => { if (!plot) { return; diff --git a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts index 860b4e9f2dbde..ba363cf30a079 100644 --- a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts +++ b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts @@ -18,6 +18,7 @@ */ import { cloneDeep, defaults, mergeWith, compact } from 'lodash'; +import $ from 'jquery'; import moment, { Moment } from 'moment-timezone'; import { TimefilterContract } from 'src/plugins/data/public'; @@ -50,6 +51,9 @@ interface TimeRangeBounds { max: Moment | undefined; } +export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION'; +export const eventBus = $({}); + const colors = [ '#01A4A4', '#C66', diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js index c12e518a9dcd3..f936710bf2b81 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -161,6 +161,10 @@ export class TimeseriesVisualization extends Component { const yAxis = []; let mainDomainAdded = false; + const allSeriesHaveSameFormatters = seriesModel.every( + (seriesGroup) => seriesGroup.formatter === seriesModel[0].formatter + ); + this.showToastNotification = null; seriesModel.forEach((seriesGroup) => { @@ -211,7 +215,7 @@ export class TimeseriesVisualization extends Component { }); } else if (!mainDomainAdded) { TimeseriesVisualization.addYAxis(yAxis, { - tickFormatter: series.length === 1 ? undefined : (val) => val, + tickFormatter: allSeriesHaveSameFormatters ? seriesGroupTickFormatter : (val) => val, id: yAxisIdGenerator('main'), groupId: mainAxisGroupId, position: model.axis_position, diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 333ed0ff64fdb..1037dc81b2b17 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -43,7 +43,9 @@ export { AbstractSearchStrategy, ReqFacade, } from './lib/search_strategies/strategies/abstract_search_strategy'; -// @ts-ignore + +export { VisPayload } from '../common/types'; + export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index dc49e280a2bb7..8f87318222f2b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -38,7 +38,7 @@ export async function getFields( // removes the need to refactor many layers of dependencies on "req", and instead just augments the top // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. - const reqFacade: ReqFacade = { + const reqFacade: ReqFacade<{}> = { requestContext, ...request, framework, diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index 5eef2b53e2431..fcb66d2e12fd1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -64,7 +64,7 @@ export function getVisData( // removes the need to refactor many layers of dependencies on "req", and instead just augments the top // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. - const reqFacade: ReqFacade = { + const reqFacade: ReqFacade = { requestContext, ...request, framework, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts similarity index 90% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts index b9b7759711567..a570e02ada8d1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts @@ -17,13 +17,15 @@ * under the License. */ import { DefaultSearchCapabilities } from './default_search_capabilities'; +import { ReqFacade } from './strategies/abstract_search_strategy'; +import { VisPayload } from '../../../common/types'; describe('DefaultSearchCapabilities', () => { - let defaultSearchCapabilities; - let req; + let defaultSearchCapabilities: DefaultSearchCapabilities; + let req: ReqFacade; beforeEach(() => { - req = {}; + req = {} as ReqFacade; defaultSearchCapabilities = new DefaultSearchCapabilities(req); }); @@ -45,13 +47,13 @@ describe('DefaultSearchCapabilities', () => { }); test('should return Search Timezone', () => { - defaultSearchCapabilities.request = { + defaultSearchCapabilities.request = ({ payload: { timerange: { timezone: 'UTC', }, }, - }; + } as unknown) as ReqFacade; expect(defaultSearchCapabilities.searchTimezone).toEqual('UTC'); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts similarity index 69% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts index 02a710fef897f..73b701379aee0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts @@ -16,40 +16,43 @@ * specific language governing permissions and limitations * under the License. */ +import { Unit } from '@elastic/datemath'; import { convertIntervalToUnit, parseInterval, getSuitableUnit, } from '../vis_data/helpers/unit_to_seconds'; import { RESTRICTIONS_KEYS } from '../../../common/ui_restrictions'; +import { ReqFacade } from './strategies/abstract_search_strategy'; +import { VisPayload } from '../../../common/types'; -const getTimezoneFromRequest = (request) => { +const getTimezoneFromRequest = (request: ReqFacade) => { return request.payload.timerange.timezone; }; export class DefaultSearchCapabilities { - constructor(request, fieldsCapabilities = {}) { - this.request = request; - this.fieldsCapabilities = fieldsCapabilities; - } + constructor( + public request: ReqFacade, + public fieldsCapabilities: Record = {} + ) {} - get defaultTimeInterval() { + public get defaultTimeInterval() { return null; } - get whiteListedMetrics() { + public get whiteListedMetrics() { return this.createUiRestriction(); } - get whiteListedGroupByFields() { + public get whiteListedGroupByFields() { return this.createUiRestriction(); } - get whiteListedTimerangeModes() { + public get whiteListedTimerangeModes() { return this.createUiRestriction(); } - get uiRestrictions() { + public get uiRestrictions() { return { [RESTRICTIONS_KEYS.WHITE_LISTED_METRICS]: this.whiteListedMetrics, [RESTRICTIONS_KEYS.WHITE_LISTED_GROUP_BY_FIELDS]: this.whiteListedGroupByFields, @@ -57,36 +60,36 @@ export class DefaultSearchCapabilities { }; } - get searchTimezone() { + public get searchTimezone() { return getTimezoneFromRequest(this.request); } - createUiRestriction(restrictionsObject) { + createUiRestriction(restrictionsObject?: Record) { return { '*': !restrictionsObject, ...(restrictionsObject || {}), }; } - parseInterval(interval) { + parseInterval(interval: string) { return parseInterval(interval); } - getSuitableUnit(intervalInSeconds) { + getSuitableUnit(intervalInSeconds: string | number) { return getSuitableUnit(intervalInSeconds); } - convertIntervalToUnit(intervalString, unit) { + convertIntervalToUnit(intervalString: string, unit: Unit) { const parsedInterval = this.parseInterval(intervalString); - if (parsedInterval.unit !== unit) { + if (parsedInterval?.unit !== unit) { return convertIntervalToUnit(intervalString, unit); } return parsedInterval; } - getValidTimeInterval(intervalString) { + getValidTimeInterval(intervalString: string) { // Default search capabilities doesn't have any restrictions for the interval string return intervalString; } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index 66ea4f017dd90..4c3dcbd17bbd9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -27,10 +27,10 @@ import { DefaultSearchCapabilities } from './default_search_capabilities'; class MockSearchStrategy extends AbstractSearchStrategy { checkForViability() { - return { + return Promise.resolve({ isViable: true, capabilities: {}, - }; + }); } } @@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy('es'); + const anotherSearchStrategy = new MockSearchStrategy(); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {}; const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy('es'); + const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index b1e21edf8b588..71461d319f2b6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -46,16 +46,8 @@ export interface ReqFacade extends FakeRequest { getEsShardTimeout: () => Promise; } -export class AbstractSearchStrategy { - public indexType?: string; - public additionalParams: any; - - constructor(type?: string, additionalParams: any = {}) { - this.indexType = type; - this.additionalParams = additionalParams; - } - - async search(req: ReqFacade, bodies: any[], options = {}) { +export abstract class AbstractSearchStrategy { + async search(req: ReqFacade, bodies: any[], indexType?: string) { const requests: any[] = []; const { sessionId } = req.payload; @@ -64,15 +56,13 @@ export class AbstractSearchStrategy { req.requestContext .search!.search( { + indexType, params: { ...body, - ...this.additionalParams, }, - indexType: this.indexType, }, { sessionId, - ...options, } ) .toPromise() @@ -81,7 +71,18 @@ export class AbstractSearchStrategy { return Promise.all(requests); } - async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) { + checkForViability( + req: ReqFacade, + indexPattern: string + ): Promise<{ isViable: boolean; capabilities: unknown }> { + throw new TypeError('Must override method'); + } + + async getFieldsForWildcard( + req: ReqFacade, + indexPattern: string, + capabilities?: unknown + ) { const { indexPatternsService } = req.pre; return await indexPatternsService!.getFieldsForWildcard({ @@ -89,11 +90,4 @@ export class AbstractSearchStrategy { fieldCapsOptions: { allow_no_indices: true }, }); } - - checkForViability( - req: ReqFacade, - indexPattern: string - ): { isViable: boolean; capabilities: any } { - throw new TypeError('Must override method'); - } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts similarity index 79% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index a9994ba3e1f75..d8ea6c9c8a526 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -17,13 +17,15 @@ * under the License. */ import { DefaultSearchStrategy } from './default_search_strategy'; +import { ReqFacade } from './abstract_search_strategy'; +import { VisPayload } from '../../../../common/types'; describe('DefaultSearchStrategy', () => { - let defaultSearchStrategy; - let req; + let defaultSearchStrategy: DefaultSearchStrategy; + let req: ReqFacade; beforeEach(() => { - req = {}; + req = {} as ReqFacade; defaultSearchStrategy = new DefaultSearchStrategy(); }); @@ -34,8 +36,8 @@ describe('DefaultSearchStrategy', () => { expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); }); - test('should check a strategy for viability', () => { - const value = defaultSearchStrategy.checkForViability(req); + test('should check a strategy for viability', async () => { + const value = await defaultSearchStrategy.checkForViability(req); expect(value.isViable).toBe(true); expect(value.capabilities).toEqual({ diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts similarity index 82% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index 8e57c117637bf..e1f519456d373 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -17,16 +17,17 @@ * under the License. */ -import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../default_search_capabilities'; +import { VisPayload } from '../../../../common/types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { name = 'default'; - checkForViability(req) { - return { + checkForViability(req: ReqFacade) { + return Promise.resolve({ isViable: true, capabilities: new DefaultSearchCapabilities(req), - }; + }); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js index 53f0b84b8ec3b..c021ba3cebc66 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js @@ -42,14 +42,18 @@ const calculateBucketData = (timeInterval, capabilities) => { } // Check decimal - if (parsedInterval.value % 1 !== 0) { + if (parsedInterval && parsedInterval.value % 1 !== 0) { if (parsedInterval.unit !== 'ms') { - const { value, unit } = convertIntervalToUnit( + const converted = convertIntervalToUnit( intervalString, ASCENDING_UNIT_ORDER[ASCENDING_UNIT_ORDER.indexOf(parsedInterval.unit) - 1] ); - intervalString = value + unit; + if (converted) { + intervalString = converted.value + converted.unit; + } + + intervalString = undefined; } else { intervalString = '1ms'; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts similarity index 86% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts index 5b533178949f1..278e557209a21 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Unit } from '@elastic/datemath'; import { getUnitValue, @@ -51,22 +52,13 @@ describe('unit_to_seconds', () => { })); test('should not parse "gm" interval (negative)', () => - expect(parseInterval('gm')).toEqual({ - value: undefined, - unit: undefined, - })); + expect(parseInterval('gm')).toBeUndefined()); test('should not parse "-1d" interval (negative)', () => - expect(parseInterval('-1d')).toEqual({ - value: undefined, - unit: undefined, - })); + expect(parseInterval('-1d')).toBeUndefined()); test('should not parse "M" interval (negative)', () => - expect(parseInterval('M')).toEqual({ - value: undefined, - unit: undefined, - })); + expect(parseInterval('M')).toBeUndefined()); }); describe('convertIntervalToUnit()', () => { @@ -95,16 +87,10 @@ describe('unit_to_seconds', () => { })); test('should not convert "30m" interval to "0" unit (positive)', () => - expect(convertIntervalToUnit('30m', 'o')).toEqual({ - value: undefined, - unit: undefined, - })); + expect(convertIntervalToUnit('30m', 'o' as Unit)).toBeUndefined()); test('should not convert "m" interval to "s" unit (positive)', () => - expect(convertIntervalToUnit('m', 's')).toEqual({ - value: undefined, - unit: undefined, - })); + expect(convertIntervalToUnit('m', 's')).toBeUndefined()); }); describe('getSuitableUnit()', () => { @@ -155,8 +141,5 @@ describe('unit_to_seconds', () => { expect(getSuitableUnit(stringValue)).toBeUndefined(); }); - - test('should return "undefined" in case of no input value(negative)', () => - expect(getSuitableUnit()).toBeUndefined()); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts similarity index 60% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts index be8f1741627ba..8950e05c85d4f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts @@ -16,12 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { INTERVAL_STRING_RE } from '../../../../common/interval_regexp'; import { sortBy, isNumber } from 'lodash'; +import { Unit } from '@elastic/datemath'; + +/** @ts-ignore */ +import { INTERVAL_STRING_RE } from '../../../../common/interval_regexp'; export const ASCENDING_UNIT_ORDER = ['ms', 's', 'm', 'h', 'd', 'w', 'M', 'y']; -const units = { +const units: Record = { ms: 0.001, s: 1, m: 60, @@ -32,49 +35,53 @@ const units = { y: 86400 * 7 * 4 * 12, // Leap year? }; -const sortedUnits = sortBy(Object.keys(units), (key) => units[key]); +const sortedUnits = sortBy(Object.keys(units), (key: Unit) => units[key]); -export const parseInterval = (intervalString) => { - let value; - let unit; +interface ParsedInterval { + value: number; + unit: Unit; +} +export const parseInterval = (intervalString: string): ParsedInterval | undefined => { if (intervalString) { const matches = intervalString.match(INTERVAL_STRING_RE); if (matches) { - value = Number(matches[1]); - unit = matches[2]; + return { + value: Number(matches[1]), + unit: matches[2] as Unit, + }; } } - - return { value, unit }; }; -export const convertIntervalToUnit = (intervalString, newUnit) => { +export const convertIntervalToUnit = ( + intervalString: string, + newUnit: Unit +): ParsedInterval | undefined => { const parsedInterval = parseInterval(intervalString); - let value; - let unit; - if (parsedInterval.value && units[newUnit]) { - value = Number( - ((parsedInterval.value * units[parsedInterval.unit]) / units[newUnit]).toFixed(2) - ); - unit = newUnit; + if (parsedInterval && units[newUnit]) { + return { + value: Number( + ((parsedInterval.value * units[parsedInterval.unit!]) / units[newUnit]).toFixed(2) + ), + unit: newUnit, + }; } - - return { value, unit }; }; -export const getSuitableUnit = (intervalInSeconds) => +export const getSuitableUnit = (intervalInSeconds: string | number) => sortedUnits.find((key, index, array) => { - const nextUnit = array[index + 1]; + const nextUnit = array[index + 1] as Unit; const isValidInput = isNumber(intervalInSeconds) && intervalInSeconds > 0; const isLastItem = index + 1 === array.length; return ( isValidInput && - ((intervalInSeconds >= units[key] && intervalInSeconds < units[nextUnit]) || isLastItem) + ((intervalInSeconds >= units[key as Unit] && intervalInSeconds < units[nextUnit]) || + isLastItem) ); - }); + }) as Unit; -export const getUnitValue = (unit) => units[unit]; +export const getUnitValue = (unit: Unit) => units[unit]; diff --git a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts index e092fc8acfd71..fbef55df39719 100644 --- a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts +++ b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts @@ -59,9 +59,9 @@ describe('registerVegaUsageCollector', () => { it('makeUsageCollector config.fetch calls getStats', async () => { const mockCollectorSet = createUsageCollectionSetupMock(); registerVegaUsageCollector(mockCollectorSet, mockConfig, mockDeps); - const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value; const mockedCollectorFetchContext = createCollectorFetchContextMock(); - const fetchResult = await usageCollectorConfig.fetch(mockedCollectorFetchContext); + const fetchResult = await usageCollector.fetch(mockedCollectorFetchContext); expect(mockGetStats).toBeCalledTimes(1); expect(mockGetStats).toBeCalledWith( mockedCollectorFetchContext.callCluster, diff --git a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts index 7789e3de13e5a..380a86e15aa51 100644 --- a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts +++ b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts @@ -58,9 +58,9 @@ describe('registerVisualizationsCollector', () => { it('makeUsageCollector config.fetch calls getStats', async () => { const mockCollectorSet = createUsageCollectionSetupMock(); registerVisualizationsCollector(mockCollectorSet, mockConfig); - const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value; const mockCollectorFetchContext = createCollectorFetchContextMock(); - const fetchResult = await usageCollectorConfig.fetch(mockCollectorFetchContext); + const fetchResult = await usageCollector.fetch(mockCollectorFetchContext); expect(mockGetStats).toBeCalledTimes(1); expect(mockGetStats).toBeCalledWith(mockCollectorFetchContext.callCluster, mockIndex); expect(fetchResult).toBe(mockStats); diff --git a/src/setup_node_env/no_transpilation.js b/src/setup_node_env/no_transpilation.js index 71fdfa5ad29ea..e989fedcec66f 100644 --- a/src/setup_node_env/no_transpilation.js +++ b/src/setup_node_env/no_transpilation.js @@ -24,5 +24,4 @@ require('./harden'); require('symbol-observable'); require('source-map-support/register'); -require('./root'); require('./node_version_validator'); diff --git a/test/common/config.js b/test/common/config.js index 9d6d531ae4b37..6c7d64e3e0bc0 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -61,6 +61,10 @@ export default function () { `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'newsfeed')}`, `--newsfeed.service.urlRoot=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`, `--newsfeed.service.pathTemplate=/api/_newsfeed-FTS-external-service-simulators/kibana/v{VERSION}.json`, + // code coverage reporting plugin + ...(!!process.env.CODE_COVERAGE + ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] + : []), ], }, services, diff --git a/test/common/fixtures/plugins/coverage/kibana.json b/test/common/fixtures/plugins/coverage/kibana.json new file mode 100644 index 0000000000000..d80432534d746 --- /dev/null +++ b/test/common/fixtures/plugins/coverage/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "coverage-fixtures", + "version": "kibana", + "server": false, + "ui": true +} \ No newline at end of file diff --git a/test/common/fixtures/plugins/coverage/public/index.ts b/test/common/fixtures/plugins/coverage/public/index.ts new file mode 100644 index 0000000000000..ed164c2d6bb94 --- /dev/null +++ b/test/common/fixtures/plugins/coverage/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 { CodeCoverageReportingPlugin } from './plugin'; + +export function plugin() { + return new CodeCoverageReportingPlugin(); +} diff --git a/test/common/fixtures/plugins/coverage/public/plugin.ts b/test/common/fixtures/plugins/coverage/public/plugin.ts new file mode 100644 index 0000000000000..e4da6b257de9a --- /dev/null +++ b/test/common/fixtures/plugins/coverage/public/plugin.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin } from 'kibana/server'; + +declare global { + interface Window { + __coverage__: any; + flushCoverageToLog: any; + } +} + +export class CodeCoverageReportingPlugin implements Plugin { + constructor() {} + + public start() {} + + public setup() { + window.flushCoverageToLog = function () { + if (window.__coverage__) { + // eslint-disable-next-line no-console + console.log('coveragejson:' + btoa(JSON.stringify(window.__coverage__))); + } + }; + window.addEventListener('beforeunload', window.flushCoverageToLog); + } +} diff --git a/test/scripts/jenkins_build_plugins.sh b/test/scripts/jenkins_build_plugins.sh index 59df02d401167..cfb829ea2db54 100755 --- a/test/scripts/jenkins_build_plugins.sh +++ b/test/scripts/jenkins_build_plugins.sh @@ -7,5 +7,6 @@ node scripts/build_kibana_platform_plugins \ --oss \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ --workers 6 \ --verbose diff --git a/test/scripts/jenkins_xpack_build_plugins.sh b/test/scripts/jenkins_xpack_build_plugins.sh index 289e64f66c89b..37b6398598788 100755 --- a/test/scripts/jenkins_xpack_build_plugins.sh +++ b/test/scripts/jenkins_xpack_build_plugins.sh @@ -5,6 +5,7 @@ source src/dev/ci_setup/setup_env.sh echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ --scan-dir "$XPACK_DIR/test/alerting_api_integration/plugins" \ diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 2be68b797ba5f..6937862d20536 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -38,10 +38,11 @@ "xpack.maps": ["plugins/maps"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], - "xpack.remoteClusters": "plugins/remote_clusters", "xpack.painlessLab": "plugins/painless_lab", + "xpack.remoteClusters": "plugins/remote_clusters", "xpack.reporting": ["plugins/reporting"], "xpack.rollupJobs": ["plugins/rollup"], + "xpack.runtimeFields": "plugins/runtime_fields", "xpack.searchProfiler": "plugins/searchprofiler", "xpack.security": "plugins/security", "xpack.server": "legacy/server", diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx index 4ccfc5b3013e9..a2fd755b234ff 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx @@ -14,6 +14,9 @@ import { } from '@testing-library/react'; import * as apmApi from '../../../../../../services/rest/createCallApmApi'; +export const removeExternalLinkText = (str: string) => + str.replace(/\(opens in a new tab or window\)/g, ''); + describe('LinkPreview', () => { let callApmApiSpy: jest.SpyInstance; beforeAll(() => { @@ -53,7 +56,9 @@ describe('LinkPreview', () => { ); expect(getElementValue(container, 'preview-label')).toEqual('foo'); expect( - (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + removeExternalLinkText( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ) ).toEqual('https://baz.co'); }); }); @@ -69,7 +74,9 @@ describe('LinkPreview', () => { ); expect(getElementValue(container, 'preview-label')).toEqual('foo'); expect( - (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + removeExternalLinkText( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ) ).toEqual('https://baz.co?service.name={{invalid}'); expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); }); @@ -85,7 +92,9 @@ describe('LinkPreview', () => { await waitFor(() => expect(callApmApiSpy).toHaveBeenCalled()); expect(getElementValue(container, 'preview-label')).toEqual('foo'); expect( - (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + removeExternalLinkText( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ) ).toEqual('https://baz.co?transaction=foo'); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index 30d4bb34ea345..c453de709a5d2 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -19,6 +19,6 @@ test('MLLink produces the correct URL', async () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/jobs?_a=(queryText:'id:(something)%20groups:(apm)')&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` + `"/app/ml/jobs?_a=(jobs:(queryText:'id:(something)%20groups:(apm)'))&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` ); }); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot index e62785272a5ec..05339ca558562 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot @@ -12,7 +12,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` className="euiFlexItem" >
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"
Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap index 06cd77558b443..ba2ec28bf6bc1 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap @@ -50,7 +50,7 @@ exports[`policy table should show empty state when there are not any policies 1` class="euiPageBody" >
{ return ( - + path="script.source" config={getFieldConfig('script')}> {(scriptField) => { const error = scriptField.getErrorsMessages(); const isInvalid = error ? Boolean(error.length) : false; @@ -26,11 +27,10 @@ export const PainlessScriptParameter = ({ stack }: Props) => { const field = ( { return ( - + + path="runtime_type" + config={getFieldConfig('runtime_type')} + > {(runtimeTypeField) => { const { label, value, setValue } = runtimeTypeField; const typeDefinition = @@ -44,8 +47,14 @@ export const RuntimeTypeParameter = ({ stack }: Props) => { )} singleSelection={{ asPlainText: true }} options={RUNTIME_FIELD_OPTIONS} - selectedOptions={value as EuiComboBoxOptionOption[]} - onChange={setValue} + selectedOptions={value} + onChange={(newValue) => { + if (newValue.length === 0) { + // Don't allow clearing the type. One must always be selected + return; + } + setValue(newValue); + }} isClearable={false} fullWidth /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx index 25fdac5089b86..46292b7b2d357 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx @@ -28,35 +28,6 @@ export const FIELD_TYPES_OPTIONS = Object.entries(MAIN_DATA_TYPE_DEFINITION).map }) ) as ComboBoxOption[]; -export const RUNTIME_FIELD_OPTIONS = [ - { - label: 'Keyword', - value: 'keyword', - }, - { - label: 'Long', - value: 'long', - }, - { - label: 'Double', - value: 'double', - }, - { - label: 'Date', - value: 'date', - }, - { - label: 'IP', - value: 'ip', - }, - { - label: 'Boolean', - value: 'boolean', - }, -] as ComboBoxOption[]; - -export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; - interface SuperSelectOptionConfig { inputDisplay: string; dropdownDisplay: JSX.Element; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index 1434b7d4b4429..64f84ee2611a0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -16,11 +16,12 @@ import { ValidationFuncArg, fieldFormatters, FieldConfig, + RUNTIME_FIELD_OPTIONS, + RuntimeType, } from '../shared_imports'; import { AliasOption, DataType, - RuntimeType, ComboBoxOption, ParameterName, ParameterDefinition, @@ -28,7 +29,6 @@ import { import { documentationService } from '../../../services/documentation'; import { INDEX_DEFAULT } from './default_values'; import { TYPE_DEFINITION } from './data_types_definition'; -import { RUNTIME_FIELD_OPTIONS } from './field_options'; const { toInt } = fieldFormatters; const { emptyField, containsCharsField, numberGreaterThanField, isJsonField } = fieldValidators; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts index 54b2486108183..68b40e876f655 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts @@ -53,3 +53,5 @@ export { } from '../../../../../../../src/plugins/es_ui_shared/public'; export { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; + +export { RUNTIME_FIELD_OPTIONS, RuntimeType } from '../../../../../runtime_fields/public'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index ee4dd55a5801f..b143eedd4f9d4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -8,7 +8,7 @@ import { ReactNode } from 'react'; import { GenericObject } from './mappings_editor'; import { FieldConfig } from '../shared_imports'; -import { PARAMETERS_DEFINITION, RUNTIME_FIELD_TYPES } from '../constants'; +import { PARAMETERS_DEFINITION } from '../constants'; export interface DataTypeDefinition { label: string; @@ -76,8 +76,6 @@ export type SubType = NumericType | RangeType; export type DataType = MainType | SubType; -export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; - export type NumericType = | 'long' | 'integer' diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/index.tsx index 62a4d7ffc3d81..43d84497af9e9 100644 --- a/x-pack/plugins/infra/public/components/log_stream/index.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/index.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useEffect } from 'react'; import { noop } from 'lodash'; -import useMount from 'react-use/lib/useMount'; import { euiStyled } from '../../../../observability/public'; import { LogEntriesCursor } from '../../../common/http_api'; @@ -100,10 +99,13 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re const parsedHeight = typeof height === 'number' ? `${height}px` : height; // Component lifetime - useMount(() => { + useEffect(() => { loadSourceConfiguration(); + }, [loadSourceConfiguration]); + + useEffect(() => { fetchEntries(); - }); + }, [fetchEntries]); // Pagination handler const handlePagination = useCallback( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index f6e9d45c4d225..76512b8a366c5 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import useInterval from 'react-use/lib/useInterval'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; @@ -32,6 +32,7 @@ import { BottomDrawer } from './bottom_drawer'; import { Legend } from './waffle/legend'; export const Layout = () => { + const [showLoading, setShowLoading] = useState(true); const { sourceId, source } = useSourceContext(); const { currentView, shouldLoadDefault } = useSavedViewContext(); const { @@ -100,6 +101,16 @@ export const Layout = () => { } }, [reload, currentView, shouldLoadDefault]); + useEffect(() => { + setShowLoading(true); + }, [options.metric, nodeType]); + + useEffect(() => { + const hasNodes = nodes && nodes.length; + // Don't show loading screen when we're auto-reloading + setShowLoading(!hasNodes); + }, [nodes]); + return ( <> @@ -130,6 +141,7 @@ export const Layout = () => { options={options} nodeType={nodeType} loading={loading} + showLoading={showLoading} reload={reload} onDrilldown={applyFilterQuery} currentTime={currentTime} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index aa6157dc48d5c..9b6853dcdc751 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -37,6 +37,7 @@ interface Props { formatter: InfraFormatter; bottomMargin: number; topMargin: number; + showLoading: boolean; } export const NodesOverview = ({ @@ -53,6 +54,7 @@ export const NodesOverview = ({ onDrilldown, bottomMargin, topMargin, + showLoading, }: Props) => { const handleDrilldown = useCallback( (filter: string) => { @@ -66,7 +68,8 @@ export const NodesOverview = ({ ); const noData = !loading && nodes && nodes.length === 0; - if (loading) { + if (loading && showLoading) { + // Don't show loading screen when we're auto-reloading return (
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; -exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap index db37bc6cb98c4..719fce35a2a68 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features, request an extension now.

"`; +exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features, request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features, request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features, request an extension now.

"`; +exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap index 24598635b28e3..a0f3948785a80 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features.

"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features.

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features.

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 24872a888b090..52a2da596c10e 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 72c04992566bd..cc41a3a4b4e22 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -270,7 +270,7 @@ exports[`UploadLicense should display a modal when license requires acknowledgem paddingSize="l" >
{ [id: string]: TValue; } @@ -31,3 +33,15 @@ export type DeepReadonly = T extends Array type DeepReadonlyObject = { readonly [P in keyof T]: DeepReadonly; }; + +export interface ListingPageUrlState { + pageSize: number; + pageIndex: number; + sortField: string; + sortDirection: string; + queryText?: string; +} + +export type AppPageState = { + [key in MlPages]?: Partial; +}; diff --git a/x-pack/plugins/ml/common/util/string_utils.ts b/x-pack/plugins/ml/common/util/string_utils.ts index 4691bac0a065a..ffb8b19dc9aa1 100644 --- a/x-pack/plugins/ml/common/util/string_utils.ts +++ b/x-pack/plugins/ml/common/util/string_utils.ts @@ -45,5 +45,5 @@ export function getGroupQueryText(groupIds: string[]): string { } export function getJobQueryText(jobIds: string | string[]): string { - return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; + return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : `id:${jobIds}`; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 17ef84179ce63..63b7074ec3aaa 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -7,10 +7,10 @@ import React, { FC, useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { + EuiInMemoryTable, EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiInMemoryTable, EuiSearchBar, EuiSearchBarProps, EuiSpacer, @@ -30,13 +30,12 @@ import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; -import { getSelectedIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; import { filterAnalytics } from '../../../../common/search_bar_filters'; import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; -import { getGroupQueryText } from '../../../../../../../common/util/string_utils'; +import { ListingPageUrlState } from '../../../../../../../common/types/common'; const filters: EuiSearchBarProps['filters'] = [ { @@ -84,17 +83,28 @@ interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; blockRefresh?: boolean; + pageState: ListingPageUrlState; + updatePageState: (update: Partial) => void; } export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, blockRefresh = false, + pageState, + updatePageState, }) => { + const searchQueryText = pageState.queryText ?? ''; + const setSearchQueryText = useCallback( + (value) => { + updatePageState({ queryText: value }); + }, + [updatePageState] + ); + const [isInitialized, setIsInitialized] = useState(false); const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); const [filteredAnalytics, setFilteredAnalytics] = useState([]); - const [searchQueryText, setSearchQueryText] = useState(''); const [searchError, setSearchError] = useState(); const [analytics, setAnalytics] = useState([]); const [analyticsStats, setAnalyticsStats] = useState( @@ -102,9 +112,6 @@ export const DataFrameAnalyticsList: FC = ({ ); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); const [errorMessage, setErrorMessage] = useState(undefined); - // Query text/job_id based on url but only after getAnalytics is done first - // selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly - const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false); const disabled = !checkPermission('canCreateDataFrameAnalytics') || @@ -119,17 +126,20 @@ export const DataFrameAnalyticsList: FC = ({ isManagementTable ); - const updateFilteredItems = (queryClauses: any) => { - if (queryClauses.length) { - const filtered = filterAnalytics(analytics, queryClauses); - setFilteredAnalytics(filtered); - } else { - setFilteredAnalytics(analytics); - } - }; + const updateFilteredItems = useCallback( + (queryClauses: any[]) => { + if (queryClauses.length) { + const filtered = filterAnalytics(analytics, queryClauses); + setFilteredAnalytics(filtered); + } else { + setFilteredAnalytics(analytics); + } + }, + [analytics] + ); const filterList = () => { - if (searchQueryText !== '' && selectedIdFromUrlInitialized === true) { + if (searchQueryText !== '') { // trigger table filtering with query for job id to trigger table filter const query = EuiSearchBar.Query.parse(searchQueryText); let clauses: any = []; @@ -142,27 +152,9 @@ export const DataFrameAnalyticsList: FC = ({ } }; - useEffect(() => { - if (selectedIdFromUrlInitialized === false && analytics.length > 0) { - const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href); - let queryText = ''; - - if (groupIds !== undefined) { - queryText = getGroupQueryText(groupIds); - } else if (jobId !== undefined) { - queryText = jobId; - } - - setSelectedIdFromUrlInitialized(true); - setSearchQueryText(queryText); - } else { - filterList(); - } - }, [selectedIdFromUrlInitialized, analytics]); - useEffect(() => { filterList(); - }, [selectedIdFromUrlInitialized, searchQueryText]); + }, [searchQueryText]); const getAnalyticsCallback = useCallback(() => getAnalytics(true), []); @@ -183,19 +175,19 @@ export const DataFrameAnalyticsList: FC = ({ ); const { onTableChange, pagination, sorting } = useTableSettings( - DataFrameAnalyticsListColumn.id, - filteredAnalytics + filteredAnalytics, + pageState, + updatePageState ); const handleSearchOnChange: EuiSearchBarProps['onChange'] = (search) => { if (search.error !== null) { setSearchError(search.error.message); - return false; + return; } setSearchError(undefined); setSearchQueryText(search.queryText); - return true; }; // Before the analytics have been loaded for the first time, display the loading indicator only. @@ -251,6 +243,7 @@ export const DataFrameAnalyticsList: FC = ({ ); + const search: EuiSearchBarProps = { query: searchQueryText, onChange: handleSearchOnChange, @@ -284,15 +277,13 @@ export const DataFrameAnalyticsList: FC = ({
allowNeutralSort={false} - className="mlAnalyticsInMemoryTable" columns={columns} - error={searchError} hasActions={false} isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} isSelectable={false} items={analytics} itemId={DataFrameAnalyticsListColumn.id} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} loading={isLoading} onTableChange={onTableChange} pagination={pagination} @@ -302,6 +293,7 @@ export const DataFrameAnalyticsList: FC = ({ rowProps={(item) => ({ 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, })} + error={searchError} />
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 8c7c8b9db8b64..84c37ac8b816b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -116,14 +116,14 @@ export interface DataFrameAnalyticsListRow { } // Used to pass on attribute names to table columns -export enum DataFrameAnalyticsListColumn { - configDestIndex = 'config.dest.index', - configSourceIndex = 'config.source.index', - configCreateTime = 'config.create_time', - description = 'config.description', - id = 'id', - memoryStatus = 'stats.memory_usage.status', -} +export const DataFrameAnalyticsListColumn = { + configDestIndex: 'config.dest.index', + configSourceIndex: 'config.source.index', + configCreateTime: 'config.create_time', + description: 'config.description', + id: 'id', + memoryStatus: 'stats.memory_usage.status', +} as const; export type ItemIdToExpandedRowMap = Record; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 2b63b9e780819..93868ce0c17e6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -135,13 +135,13 @@ export const progressColumn = { 'data-test-subj': 'mlAnalyticsTableColumnProgress', }; -export const DFAnalyticsJobIdLink = ({ item }: { item: DataFrameAnalyticsListRow }) => { +export const DFAnalyticsJobIdLink = ({ jobId }: { jobId: string }) => { const href = useMlLink({ page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, - pageState: { jobId: item.id }, + pageState: { jobId }, }); - return {item.id}; + return {jobId}; }; export const useColumns = ( @@ -199,13 +199,17 @@ export const useColumns = ( 'data-test-subj': 'mlAnalyticsTableRowDetailsToggle', }, { - name: 'ID', + field: DataFrameAnalyticsListColumn.id, + name: i18n.translate('xpack.ml.dataframe.analyticsList.id', { + defaultMessage: 'ID', + }), sortable: (item: DataFrameAnalyticsListRow) => item.id, truncateText: true, 'data-test-subj': 'mlAnalyticsTableColumnId', scope: 'row', - render: (item: DataFrameAnalyticsListRow) => - isManagementTable ? : item.id, + render: (jobId: string) => { + return isManagementTable ? : jobId; + }, }, { field: DataFrameAnalyticsListColumn.description, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts index 5b7d71dacccf8..68774fb86fe96 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; import { Direction, EuiBasicTableProps, Pagination, PropertySort } from '@elastic/eui'; +import { useCallback, useMemo } from 'react'; +import { ListingPageUrlState } from '../../../../../../../common/types/common'; -const PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [10, 25, 50]; // Copying from EUI EuiBasicTable types as type is not correctly picked up for table's onChange @@ -29,15 +29,6 @@ export interface CriteriaWithPagination extends Criteria { }; } -interface AnalyticsBasicTableSettings { - pageIndex: number; - pageSize: number; - totalItemCount: number; - hidePerPageOptions: boolean; - sortField: keyof T; - sortDirection: Direction; -} - interface UseTableSettingsReturnValue { onTableChange: EuiBasicTableProps['onChange']; pagination: Pagination; @@ -45,49 +36,44 @@ interface UseTableSettingsReturnValue { } export function useTableSettings( - sortByField: keyof TypeOfItem, - items: TypeOfItem[] + items: TypeOfItem[], + pageState: ListingPageUrlState, + updatePageState: (update: Partial) => void ): UseTableSettingsReturnValue { - const [tableSettings, setTableSettings] = useState>({ - pageIndex: 0, - pageSize: PAGE_SIZE, - totalItemCount: 0, - hidePerPageOptions: false, - sortField: sortByField, - sortDirection: 'asc', - }); - - const onTableChange: EuiBasicTableProps['onChange'] = ({ - page = { index: 0, size: PAGE_SIZE }, - sort = { field: sortByField, direction: 'asc' }, - }: CriteriaWithPagination) => { - const { index, size } = page; - const { field, direction } = sort; - - setTableSettings({ - ...tableSettings, - pageIndex: index, - pageSize: size, - sortField: field, - sortDirection: direction, - }); - }; + const { pageIndex, pageSize, sortField, sortDirection } = pageState; - const { pageIndex, pageSize, sortField, sortDirection } = tableSettings; + const onTableChange: EuiBasicTableProps['onChange'] = useCallback( + ({ page, sort }: CriteriaWithPagination) => { + const result = { + pageIndex: page?.index ?? pageState.pageIndex, + pageSize: page?.size ?? pageState.pageSize, + sortField: (sort?.field as string) ?? pageState.sortField, + sortDirection: sort?.direction ?? pageState.sortDirection, + }; + updatePageState(result); + }, + [pageState, updatePageState] + ); - const pagination = { - pageIndex, - pageSize, - totalItemCount: items.length, - pageSizeOptions: PAGE_SIZE_OPTIONS, - }; + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: items.length, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }), + [items, pageIndex, pageSize] + ); - const sorting = { - sort: { - field: sortField as string, - direction: sortDirection, - }, - }; + const sorting = useMemo( + () => ({ + sort: { + field: sortField as string, + direction: sortDirection as Direction, + }, + }), + [sortField, sortDirection] + ); return { onTableChange, pagination, sorting }; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx index eaeae6cc64520..a5d3555fcc278 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx @@ -50,9 +50,12 @@ export const AnalyticsNavigationBar: FC<{ selectedTabId?: string; jobId?: string return navTabs; }, [jobId !== undefined]); - const onTabClick = useCallback(async (tab: Tab) => { - await navigateToPath(tab.path, true); - }, []); + const onTabClick = useCallback( + async (tab: Tab) => { + await navigateToPath(tab.path, true); + }, + [navigateToPath] + ); return ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts index 7c70a25071640..77c794dce10ce 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts @@ -6,8 +6,8 @@ export * from './models_list'; -export enum ModelsTableToConfigMapping { - id = 'model_id', - createdAt = 'create_time', - type = 'type', -} +export const ModelsTableToConfigMapping = { + id: 'model_id', + createdAt: 'create_time', + type: 'type', +} as const; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index a87f11df937d3..2d74d08c4550c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -52,6 +52,8 @@ import { filterAnalyticsModels } from '../../../../common/search_bar_filters'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; import { timeFormatter } from '../../../../../../../common/util/date_utils'; +import { ListingPageUrlState } from '../../../../../../../common/types/common'; +import { usePageUrlState } from '../../../../../util/url_state'; type Stats = Omit; @@ -63,6 +65,13 @@ export type ModelItem = TrainedModelConfigResponse & { export type ModelItemFull = Required; +export const getDefaultModelsListState = (): ListingPageUrlState => ({ + pageIndex: 0, + pageSize: 10, + sortField: ModelsTableToConfigMapping.id, + sortDirection: 'asc', +}); + export const ModelsList: FC = () => { const { services: { @@ -71,12 +80,24 @@ export const ModelsList: FC = () => { } = useMlKibana(); const urlGenerator = useMlUrlGenerator(); + const [pageState, updatePageState] = usePageUrlState( + ML_PAGES.DATA_FRAME_ANALYTICS_MODELS_MANAGE, + getDefaultModelsListState() + ); + + const searchQueryText = pageState.queryText ?? ''; + const setSearchQueryText = useCallback( + (value) => { + updatePageState({ queryText: value }); + }, + [updatePageState] + ); + const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; const trainedModelsApiService = useTrainedModelsApiService(); const { toasts } = useNotifications(); - const [searchQueryText, setSearchQueryText] = useState(''); const [filteredModels, setFilteredModels] = useState([]); const [isLoading, setIsLoading] = useState(false); const [items, setItems] = useState([]); @@ -432,8 +453,9 @@ export const ModelsList: FC = () => { : []; const { onTableChange, pagination, sorting } = useTableSettings( - ModelsTableToConfigMapping.id, - filteredModels + filteredModels, + pageState, + updatePageState ); const toolsLeft = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index c31a3b08aa756..5a17b91818a1c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -33,11 +33,27 @@ import { UpgradeWarning } from '../../../components/upgrade'; import { AnalyticsNavigationBar } from './components/analytics_navigation_bar'; import { ModelsList } from './components/models_management'; import { JobMap } from '../job_map'; +import { usePageUrlState } from '../../../util/url_state'; +import { ListingPageUrlState } from '../../../../../common/types/common'; +import { DataFrameAnalyticsListColumn } from './components/analytics_list/common'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; + +export const getDefaultDFAListState = (): ListingPageUrlState => ({ + pageIndex: 0, + pageSize: 10, + sortField: DataFrameAnalyticsListColumn.id, + sortDirection: 'asc', +}); export const Page: FC = () => { const [blockRefresh, setBlockRefresh] = useState(false); const [globalState] = useUrlState('_g'); + const [dfaPageState, setDfaPageState] = usePageUrlState( + ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, + getDefaultDFAListState() + ); + useRefreshInterval(setBlockRefresh); const location = useLocation(); @@ -93,7 +109,11 @@ export const Page: FC = () => { {selectedTabId === 'map' && mapJobId && } {selectedTabId === 'data_frame_analytics' && ( - + )} {selectedTabId === 'models' && } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx index f0fa62b7a3d8a..1b1bea889925f 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx @@ -22,8 +22,6 @@ import { JobGroup } from '../job_group'; import { useMlKibana } from '../../../../contexts/kibana'; interface JobFilterBarProps { - jobId: string; - groupIds: string[]; setFilters: (query: Query | null) => void; queryText?: string; } @@ -75,7 +73,7 @@ export const JobFilterBar: FC = ({ queryText, setFilters }) = useEffect(() => { setFilters(queryInstance); - }, []); + }, [queryText]); const filters: SearchFilterConfig[] = useMemo( () => [ diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index 397062248689d..338222e3ac4a2 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -6,7 +6,6 @@ import { each } from 'lodash'; import { i18n } from '@kbn/i18n'; -import rison from 'rison-node'; import { mlJobService } from '../../../services/job_service'; import { @@ -367,31 +366,3 @@ function jobProperty(job, prop) { }; return job[propMap[prop]]; } - -function getUrlVars(url) { - const vars = {}; - url.replace(/[?&]+([^=&]+)=([^&]*)/gi, function (_, key, value) { - vars[key] = value; - }); - return vars; -} - -export function getSelectedIdFromUrl(url) { - const result = {}; - if (typeof url === 'string') { - const isGroup = url.includes('groupIds'); - url = decodeURIComponent(url); - - if (url.includes('mlManagement')) { - const urlParams = getUrlVars(url); - const decodedJson = rison.decode(urlParams.mlManagement); - - if (isGroup) { - result.groupIds = decodedJson.groupIds; - } else { - result.jobId = decodedJson.jobId; - } - } - } - return result; -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts deleted file mode 100644 index 4414be0b4fdcb..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSelectedIdFromUrl } from './utils'; - -describe('ML - Jobs List utils', () => { - const jobId = 'test_job_id_1'; - const jobIdUrl = `http://localhost:5601/aql/app/ml#/jobs?mlManagement=(jobId:${jobId})`; - const groupIdOne = 'test_group_id_1'; - const groupIdTwo = 'test_group_id_2'; - const groupIdsUrl = `http://localhost:5601/aql/app/ml#/jobs?mlManagement=(groupIds:!(${groupIdOne},${groupIdTwo}))`; - const groupIdUrl = `http://localhost:5601/aql/app/ml#/jobs?mlManagement=(groupIds:!(${groupIdOne}))`; - - describe('getSelectedIdFromUrl', () => { - it('should get selected job id from the url', () => { - const actual = getSelectedIdFromUrl(jobIdUrl); - expect(actual).toStrictEqual({ jobId }); - }); - - it('should get selected group ids from the url', () => { - const expected = { groupIds: [groupIdOne, groupIdTwo] }; - const actual = getSelectedIdFromUrl(groupIdsUrl); - expect(actual).toStrictEqual(expected); - }); - - it('should get selected group id from the url', () => { - const expected = { groupIds: [groupIdOne] }; - const actual = getSelectedIdFromUrl(groupIdUrl); - expect(actual).toStrictEqual(expected); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx index 4c6469f6800a7..df50f53b811fa 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC } from 'react'; import { NavigationMenu } from '../../components/navigation_menu'; // @ts-ignore import { JobsListView } from './components/jobs_list_view/index'; -import { useUrlState } from '../../util/url_state'; +import { usePageUrlState } from '../../util/url_state'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { ListingPageUrlState } from '../../../../common/types/common'; interface JobsPageProps { blockRefresh?: boolean; @@ -17,15 +19,7 @@ interface JobsPageProps { lastRefresh?: number; } -export interface AnomalyDetectionJobsListState { - pageSize: number; - pageIndex: number; - sortField: string; - sortDirection: string; - queryText?: string; -} - -export const getDefaultAnomalyDetectionJobsListState = (): AnomalyDetectionJobsListState => ({ +export const getDefaultAnomalyDetectionJobsListState = (): ListingPageUrlState => ({ pageIndex: 0, pageSize: 10, sortField: 'id', @@ -33,33 +27,15 @@ export const getDefaultAnomalyDetectionJobsListState = (): AnomalyDetectionJobsL }); export const JobsPage: FC = (props) => { - const [appState, setAppState] = useUrlState('_a'); - - const jobListState: AnomalyDetectionJobsListState = useMemo(() => { - return { - ...getDefaultAnomalyDetectionJobsListState(), - ...(appState ?? {}), - }; - }, [appState]); - - const onJobsViewStateUpdate = useCallback( - (update: Partial) => { - setAppState({ - ...jobListState, - ...update, - }); - }, - [appState, setAppState] + const [pageState, setPageState] = usePageUrlState( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + getDefaultAnomalyDetectionJobsListState() ); return (
- +
); }; diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index ad4b9ad78902b..1089484449bab 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -35,11 +35,10 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; -import { - AnomalyDetectionJobsListState, - getDefaultAnomalyDetectionJobsListState, -} from '../../../../jobs/jobs_list/jobs'; +import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; +import { ListingPageUrlState } from '../../../../../../common/types/common'; +import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; interface Tab { 'data-test-subj': string; @@ -48,21 +47,28 @@ interface Tab { content: any; } -function useTabs(isMlEnabledInSpace: boolean): Tab[] { - const [jobsViewState, setJobsViewState] = useState( - getDefaultAnomalyDetectionJobsListState() - ); +function usePageState( + defaultState: T +): [T, (update: Partial) => void] { + const [pageState, setPageState] = useState(defaultState); const updateState = useCallback( - (update: Partial) => { - setJobsViewState({ - ...jobsViewState, + (update: Partial) => { + setPageState({ + ...pageState, ...update, }); }, - [jobsViewState] + [pageState] ); + return [pageState, updateState]; +} + +function useTabs(isMlEnabledInSpace: boolean): Tab[] { + const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); + const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); + return useMemo( () => [ { @@ -75,8 +81,8 @@ function useTabs(isMlEnabledInSpace: boolean): Tab[] { @@ -95,12 +101,14 @@ function useTabs(isMlEnabledInSpace: boolean): Tab[] { ), }, ], - [isMlEnabledInSpace, jobsViewState, updateState] + [isMlEnabledInSpace, adPageState, updateAdPageState, dfaPageState, updateDfaPageState] ); } diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index a3c70e1130904..448a888ab32c2 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -13,6 +13,7 @@ import { useHistory, useLocation } from 'react-router-dom'; import { Dictionary } from '../../../common/types/common'; import { getNestedProperty } from './object_utils'; +import { MlPages } from '../../../common/constants/ml_url_generator'; type Accessor = '_a' | '_g'; export type SetUrlState = ( @@ -150,3 +151,35 @@ export const useUrlState = (accessor: Accessor) => { ); return [urlState, setUrlState]; }; + +/** + * Hook for managing the URL state of the page. + */ +export const usePageUrlState = ( + pageKey: MlPages, + defaultState: PageUrlState +): [PageUrlState, (update: Partial) => void] => { + const [appState, setAppState] = useUrlState('_a'); + const pageState = appState?.[pageKey]; + + const resultPageState: PageUrlState = useMemo(() => { + return { + ...defaultState, + ...(pageState ?? {}), + }; + }, [pageState]); + + const onStateUpdate = useCallback( + (update: Partial, replace?: boolean) => { + setAppState(pageKey, { + ...(replace ? {} : resultPageState), + ...update, + }); + }, + [pageKey, resultPageState, setAppState] + ); + + return useMemo(() => { + return [resultPageState, onStateUpdate]; + }, [resultPageState, onStateUpdate]); +}; diff --git a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts index 1f69d70ad79f9..d53dfa8fd19c9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts @@ -19,8 +19,8 @@ import type { import { ML_PAGES } from '../../common/constants/ml_url_generator'; import { createGenericMlUrl } from './common'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; -import type { AnomalyDetectionJobsListState } from '../application/jobs/jobs_list/jobs'; import { getGroupQueryText, getJobQueryText } from '../../common/util/string_utils'; +import { AppPageState, ListingPageUrlState } from '../../common/types/common'; /** * Creates URL to the Anomaly Detection Job management page */ @@ -41,11 +41,15 @@ export function createAnomalyDetectionJobManagementUrl( if (groupIds) { queryTextArr.push(getGroupQueryText(groupIds)); } - const queryState: Partial = { + const jobsListState: Partial = { ...(queryTextArr.length > 0 ? { queryText: queryTextArr.join(' ') } : {}), }; - url = setStateToKbnUrl>( + const queryState: AppPageState = { + [ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE]: jobsListState, + }; + + url = setStateToKbnUrl>( '_a', queryState, { useHash: false, storeInHashQuery: false }, diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts index 6c58a9d28bcc2..dc9c3bd86cc63 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts @@ -10,12 +10,13 @@ import { DataFrameAnalyticsExplorationQueryState, DataFrameAnalyticsExplorationUrlState, - DataFrameAnalyticsQueryState, DataFrameAnalyticsUrlState, MlCommonGlobalState, } from '../../common/types/ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; +import { getGroupQueryText, getJobQueryText } from '../../common/util/string_utils'; +import { AppPageState, ListingPageUrlState } from '../../common/types/common'; export function createDataFrameAnalyticsJobManagementUrl( appBasePath: string, @@ -26,13 +27,23 @@ export function createDataFrameAnalyticsJobManagementUrl( if (mlUrlGeneratorState) { const { jobId, groupIds, globalState } = mlUrlGeneratorState; if (jobId || groupIds) { - const queryState: Partial = { - jobId, - groupIds, + const queryTextArr = []; + if (jobId) { + queryTextArr.push(getJobQueryText(jobId)); + } + if (groupIds) { + queryTextArr.push(getGroupQueryText(groupIds)); + } + const jobsListState: Partial = { + ...(queryTextArr.length > 0 ? { queryText: queryTextArr.join(' ') } : {}), + }; + + const queryState: AppPageState = { + [ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE]: jobsListState, }; - url = setStateToKbnUrl>( - 'mlManagement', + url = setStateToKbnUrl>( + '_a', queryState, { useHash: false, storeInHashQuery: false }, url diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index e7f12ead3ffe9..3f3d88f1a31d9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -30,7 +30,7 @@ describe('MlUrlGenerator', () => { jobId: 'fq_single_1', }, }); - expect(url).toBe('/app/ml/jobs?_a=(queryText:fq_single_1)'); + expect(url).toBe("/app/ml/jobs?_a=(jobs:(queryText:'id:fq_single_1'))"); }); it('should generate valid URL for the Anomaly Detection job management page for groupIds', async () => { @@ -40,7 +40,9 @@ describe('MlUrlGenerator', () => { groupIds: ['farequote', 'categorization'], }, }); - expect(url).toBe("/app/ml/jobs?_a=(queryText:'groups:(farequote%20or%20categorization)')"); + expect(url).toBe( + "/app/ml/jobs?_a=(jobs:(queryText:'groups:(farequote%20or%20categorization)'))" + ); }); it('should generate valid URL for the page for selecting the type of anomaly detection job to create', async () => { @@ -180,7 +182,9 @@ describe('MlUrlGenerator', () => { jobId: 'grid_regression_1', }, }); - expect(url).toBe('/app/ml/data_frame_analytics?mlManagement=(jobId:grid_regression_1)'); + expect(url).toBe( + "/app/ml/data_frame_analytics?_a=(data_frame_analytics:(queryText:'id:grid_regression_1'))" + ); }); it('should generate valid URL for the Data Frame Analytics job management page with groupIds', async () => { @@ -190,7 +194,9 @@ describe('MlUrlGenerator', () => { groupIds: ['group_1', 'group_2'], }, }); - expect(url).toBe('/app/ml/data_frame_analytics?mlManagement=(groupIds:!(group_1,group_2))'); + expect(url).toBe( + "/app/ml/data_frame_analytics?_a=(data_frame_analytics:(queryText:'groups:(group_1%20or%20group_2)'))" + ); }); }); diff --git a/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap index 08a8cc57618a5..ffb1620b60fa3 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap @@ -14,7 +14,7 @@ exports[`NoData should show a default message if reason is unknown 1`] = ` style="max-width:600px" >
cloud dashboard. + + + (opens in a new tab or window) + For more information on Monitoring in Elastic Cloud, please see the documentation. + + + (opens in a new tab or window) +

diff --git a/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap index e7b88e23c5f68..9e3b7c0e25d5d 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap @@ -185,6 +185,16 @@ Array [ target="_blank" > cloud dashboard. + + + (opens in a new tab or window) + For more information on Monitoring in Elastic Cloud, please see the documentation. + + + (opens in a new tab or window) +

diff --git a/x-pack/plugins/monitoring/public/components/page_loading/__tests__/__snapshots__/page_loading.test.js.snap b/x-pack/plugins/monitoring/public/components/page_loading/__tests__/__snapshots__/page_loading.test.js.snap index f4d8232b2e340..fa223a2fe57e1 100644 --- a/x-pack/plugins/monitoring/public/components/page_loading/__tests__/__snapshots__/page_loading.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/page_loading/__tests__/__snapshots__/page_loading.test.js.snap @@ -9,7 +9,7 @@ exports[`PageLoading should show a simple page loading component 1`] = ` class="euiPageBody" >
{ +export interface KibanaSettingsCollectorExtraOptions { getEmailValueStructure(email: string | null): EmailSettingData; } +export type KibanaSettingsCollector = Collector & + KibanaSettingsCollectorExtraOptions; + export function getSettingsCollector( usageCollection: UsageCollectionSetup, config: MonitoringConfig ) { - return usageCollection.makeStatsCollector({ + return usageCollection.makeStatsCollector< + EmailSettingData | undefined, + unknown, + false, + KibanaSettingsCollectorExtraOptions + >({ type: KIBANA_SETTINGS_TYPE, isReady: () => true, schema: { @@ -60,7 +68,7 @@ export function getSettingsCollector( default_admin_email: { type: 'text' }, }, }, - async fetch(this: KibanaSettingsCollector) { + async fetch() { let kibanaSettingsData; const defaultAdminEmail = await checkForEmailValue(config); diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts index 2f63a878b0cde..8a2283420ac95 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts @@ -6,6 +6,7 @@ import { getMonitoringUsageCollector } from './get_usage_collector'; import { fetchClusters } from '../../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; jest.mock('../../lib/alerts/fetch_clusters', () => ({ fetchClusters: jest.fn().mockImplementation(() => { @@ -57,7 +58,7 @@ jest.mock('./lib/fetch_license_type', () => ({ })); describe('getMonitoringUsageCollector', () => { - const callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createLegacyClusterClient(); const config: any = { ui: { ccs: { @@ -70,7 +71,7 @@ describe('getMonitoringUsageCollector', () => { const usageCollection: any = { makeUsageCollector: jest.fn(), }; - await getMonitoringUsageCollector(usageCollection, config, callCluster); + await getMonitoringUsageCollector(usageCollection, config, esClient); const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; @@ -120,11 +121,11 @@ describe('getMonitoringUsageCollector', () => { makeUsageCollector: jest.fn(), }; - await getMonitoringUsageCollector(usageCollection, config, callCluster); + await getMonitoringUsageCollector(usageCollection, config, esClient); const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; const args = mock.calls[0]; - const result = await args[0].fetch(); + const result = await args[0].fetch({}); expect(result).toStrictEqual({ hasMonitoringData: true, clusters: [ @@ -147,7 +148,7 @@ describe('getMonitoringUsageCollector', () => { makeUsageCollector: jest.fn(), }; - await getMonitoringUsageCollector(usageCollection, config, callCluster); + await getMonitoringUsageCollector(usageCollection, config, esClient); const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; const args = mock.calls[0]; @@ -155,7 +156,27 @@ describe('getMonitoringUsageCollector', () => { return []; }); - const result = await args[0].fetch(); + const result = await args[0].fetch({}); + expect(result).toStrictEqual({ + hasMonitoringData: false, + clusters: [], + }); + }); + + it('should handle scoped data', async () => { + const usageCollection: any = { + makeUsageCollector: jest.fn(), + }; + + await getMonitoringUsageCollector(usageCollection, config, esClient); + const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; + const args = mock.calls[0]; + + (fetchClusters as jest.Mock).mockImplementation(() => { + return []; + }); + + const result = await args[0].fetch({ kibanaRequest: {} }); expect(result).toStrictEqual({ hasMonitoringData: false, clusters: [], diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts index 278a6c163c0ad..038042f109817 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts @@ -5,7 +5,7 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { LegacyAPICaller } from 'src/core/server'; +import { ILegacyClusterClient } from 'src/core/server'; import { MonitoringConfig } from '../../config'; import { fetchAvailableCcs } from '../../lib/alerts/fetch_available_ccs'; import { getStackProductsUsage } from './lib/get_stack_products_usage'; @@ -18,9 +18,9 @@ import { fetchClusters } from '../../lib/alerts/fetch_clusters'; export function getMonitoringUsageCollector( usageCollection: UsageCollectionSetup, config: MonitoringConfig, - callCluster: LegacyAPICaller + legacyEsClient: ILegacyClusterClient ) { - return usageCollection.makeUsageCollector({ + return usageCollection.makeUsageCollector({ type: 'monitoring', isReady: () => true, schema: { @@ -97,7 +97,13 @@ export function getMonitoringUsageCollector( }, }, }, - fetch: async () => { + extendFetchContext: { + kibanaRequest: true, + }, + fetch: async ({ kibanaRequest }) => { + const callCluster = kibanaRequest + ? legacyEsClient.asScoped(kibanaRequest).callAsCurrentUser + : legacyEsClient.callAsInternalUser; const usageClusters: MonitoringClusterStackProductUsage[] = []; const availableCcs = config.ui.ccs.enabled ? await fetchAvailableCcs(callCluster) : []; const elasticsearchIndex = getCcsIndexPattern(INDEX_PATTERN_ELASTICSEARCH, availableCcs); diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts index 47ad78b29962c..25e243656898c 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'src/core/server'; +import { ILegacyClusterClient } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getSettingsCollector } from './get_settings_collector'; import { getMonitoringUsageCollector } from './get_usage_collector'; @@ -15,10 +15,10 @@ export { KibanaSettingsCollector } from './get_settings_collector'; export function registerCollectors( usageCollection: UsageCollectionSetup, config: MonitoringConfig, - callCluster: LegacyAPICaller + legacyEsClient: ILegacyClusterClient ) { usageCollection.registerCollector(getSettingsCollector(usageCollection, config)); usageCollection.registerCollector( - getMonitoringUsageCollector(usageCollection, config, callCluster) + getMonitoringUsageCollector(usageCollection, config, legacyEsClient) ); } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index d3e5028d72fcc..41b501d88af99 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -173,7 +173,7 @@ export class Plugin { }, }); - registerCollectors(plugins.usageCollection, config, cluster.callAsInternalUser); + registerCollectors(plugins.usageCollection, config, cluster); } // Always create the bulk uploader diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts index 099f6915611cb..a119686afe663 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts @@ -180,6 +180,7 @@ describe('get_all_stats', () => { esClient: esClient as any, soClient: soClient as any, usageCollection: {} as any, + kibanaRequest: undefined, timestamp, }, { @@ -206,6 +207,7 @@ describe('get_all_stats', () => { esClient: esClient as any, soClient: soClient as any, usageCollection: {} as any, + kibanaRequest: undefined, timestamp, }, { diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts index 0acdb9968bc03..b296ff090aedd 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts @@ -13,6 +13,7 @@ import { } from './get_cluster_uuids'; describe('get_cluster_uuids', () => { + const kibanaRequest = undefined; const callCluster = sinon.stub(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const soClient = savedObjectsRepositoryMock.create(); @@ -33,7 +34,7 @@ describe('get_cluster_uuids', () => { callCluster.withArgs('search').returns(Promise.resolve(response)); expect( await getClusterUuids( - { callCluster, esClient, soClient, timestamp, usageCollection: {} as any }, + { callCluster, esClient, soClient, timestamp, kibanaRequest, usageCollection: {} as any }, { maxBucketSize: 1, } as any @@ -47,7 +48,7 @@ describe('get_cluster_uuids', () => { callCluster.returns(Promise.resolve(response)); expect( await fetchClusterUuids( - { callCluster, esClient, soClient, timestamp, usageCollection: {} as any }, + { callCluster, esClient, soClient, timestamp, kibanaRequest, usageCollection: {} as any }, { maxBucketSize: 1, } as any diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index 4cecc2e24867f..fff18353c58b0 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -74,7 +74,7 @@ describe('license checks', () => { let usageStats: any; beforeAll(async () => { const plugins = getPluginsMock({ license: 'basic' }); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock('basic'), @@ -83,7 +83,7 @@ describe('license checks', () => { return Promise.resolve(true); } ); - usageStats = await fetch(getMockFetchClients(getResponseMock())); + usageStats = await collector.fetch(getMockFetchClients(getResponseMock())); }); test('sets enables to true', async () => { @@ -103,7 +103,7 @@ describe('license checks', () => { let usageStats: any; beforeAll(async () => { const plugins = getPluginsMock({ license: 'none' }); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock('none'), @@ -112,7 +112,7 @@ describe('license checks', () => { return Promise.resolve(true); } ); - usageStats = await fetch(getMockFetchClients(getResponseMock())); + usageStats = await collector.fetch(getMockFetchClients(getResponseMock())); }); test('sets enables to true', async () => { @@ -132,7 +132,7 @@ describe('license checks', () => { let usageStats: any; beforeAll(async () => { const plugins = getPluginsMock({ license: 'platinum' }); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock('platinum'), @@ -141,7 +141,7 @@ describe('license checks', () => { return Promise.resolve(true); } ); - usageStats = await fetch(getMockFetchClients(getResponseMock())); + usageStats = await collector.fetch(getMockFetchClients(getResponseMock())); }); test('sets enables to true', async () => { @@ -161,7 +161,7 @@ describe('license checks', () => { let usageStats: any; beforeAll(async () => { const plugins = getPluginsMock({ license: 'basic' }); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock('basic'), @@ -170,7 +170,7 @@ describe('license checks', () => { return Promise.resolve(true); } ); - usageStats = await fetch(getMockFetchClients({})); + usageStats = await collector.fetch(getMockFetchClients({})); }); test('sets enables to true', async () => { @@ -193,7 +193,7 @@ describe('data modeling', () => { }); test('with normal looking usage data', async () => { const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock(), @@ -237,13 +237,13 @@ describe('data modeling', () => { }, } as SearchResponse) // prettier-ignore ); - const usageStats = await fetch(collectorFetchContext); + const usageStats = await collector.fetch(collectorFetchContext); expect(usageStats).toMatchSnapshot(); }); test('with sparse data', async () => { const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock(), @@ -287,13 +287,13 @@ describe('data modeling', () => { }, } as SearchResponse) // prettier-ignore ); - const usageStats = await fetch(collectorFetchContext); + const usageStats = await collector.fetch(collectorFetchContext); expect(usageStats).toMatchSnapshot(); }); test('with empty data', async () => { const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector( + const collector = getReportingUsageCollector( mockCore, plugins.usageCollection, getLicenseMock(), @@ -337,7 +337,7 @@ describe('data modeling', () => { }, } as SearchResponse) // prettier-ignore ); - const usageStats = await fetch(collectorFetchContext); + const usageStats = await collector.fetch(collectorFetchContext); expect(usageStats).toMatchSnapshot(); }); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js deleted file mode 100644 index 8672a8b8f6849..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { registerRollupSearchStrategy } from './register_rollup_search_strategy'; - -describe('Register Rollup Search Strategy', () => { - let addSearchStrategy; - let getRollupService; - - beforeEach(() => { - addSearchStrategy = jest.fn().mockName('addSearchStrategy'); - getRollupService = jest.fn().mockName('getRollupService'); - }); - - test('should run initialization', () => { - registerRollupSearchStrategy(addSearchStrategy, getRollupService); - - expect(addSearchStrategy).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts deleted file mode 100644 index 22dafbb71d802..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts +++ /dev/null @@ -1,28 +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 { ILegacyScopedClusterClient } from 'src/core/server'; -import { - DefaultSearchCapabilities, - AbstractSearchStrategy, - ReqFacade, -} from '../../../../../../src/plugins/vis_type_timeseries/server'; -import { getRollupSearchStrategy } from './rollup_search_strategy'; -import { getRollupSearchCapabilities } from './rollup_search_capabilities'; - -export const registerRollupSearchStrategy = ( - addSearchStrategy: (searchStrategy: any) => void, - getRollupService: (reg: ReqFacade) => Promise -) => { - const RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities); - const RollupSearchStrategy = getRollupSearchStrategy( - AbstractSearchStrategy, - RollupSearchCapabilities, - getRollupService - ); - - addSearchStrategy(new RollupSearchStrategy()); -}; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.ts deleted file mode 100644 index 354bf641114c7..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.ts +++ /dev/null @@ -1,115 +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 { get, has } from 'lodash'; -import { KibanaRequest } from 'src/core/server'; -import { leastCommonInterval, isCalendarInterval } from './lib/interval_helper'; - -export const getRollupSearchCapabilities = (DefaultSearchCapabilities: any) => - class RollupSearchCapabilities extends DefaultSearchCapabilities { - constructor( - req: KibanaRequest, - fieldsCapabilities: { [key: string]: any }, - rollupIndex: string - ) { - super(req, fieldsCapabilities); - - this.rollupIndex = rollupIndex; - this.availableMetrics = get(fieldsCapabilities, `${rollupIndex}.aggs`, {}); - } - - public get dateHistogram() { - const [dateHistogram] = Object.values(this.availableMetrics.date_histogram); - - return dateHistogram; - } - - public get defaultTimeInterval() { - return ( - this.dateHistogram.fixed_interval || - this.dateHistogram.calendar_interval || - /* - Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval] in the future. - We can remove the following line only for versions > 8.x - */ - this.dateHistogram.interval || - null - ); - } - - public get searchTimezone() { - return get(this.dateHistogram, 'time_zone', null); - } - - public get whiteListedMetrics() { - const baseRestrictions = this.createUiRestriction({ - count: this.createUiRestriction(), - }); - - const getFields = (fields: { [key: string]: any }) => - Object.keys(fields).reduce( - (acc, item) => ({ - ...acc, - [item]: true, - }), - this.createUiRestriction({}) - ); - - return Object.keys(this.availableMetrics).reduce( - (acc, item) => ({ - ...acc, - [item]: getFields(this.availableMetrics[item]), - }), - baseRestrictions - ); - } - - public get whiteListedGroupByFields() { - return this.createUiRestriction({ - everything: true, - terms: has(this.availableMetrics, 'terms'), - }); - } - - public get whiteListedTimerangeModes() { - return this.createUiRestriction({ - last_value: true, - }); - } - - getValidTimeInterval(userIntervalString: string) { - const parsedRollupJobInterval = this.parseInterval(this.defaultTimeInterval); - const inRollupJobUnit = this.convertIntervalToUnit( - userIntervalString, - parsedRollupJobInterval.unit - ); - - const getValidCalendarInterval = () => { - let unit = parsedRollupJobInterval.unit; - - if (inRollupJobUnit.value > parsedRollupJobInterval.value) { - const inSeconds = this.convertIntervalToUnit(userIntervalString, 's'); - - unit = this.getSuitableUnit(inSeconds.value); - } - - return { - value: 1, - unit, - }; - }; - - const getValidFixedInterval = () => ({ - value: leastCommonInterval(inRollupJobUnit.value, parsedRollupJobInterval.value), - unit: parsedRollupJobInterval.unit, - }); - - const { value, unit } = (isCalendarInterval(parsedRollupJobInterval) - ? getValidCalendarInterval - : getValidFixedInterval)(); - - return `${value}${unit}`; - } - }; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts deleted file mode 100644 index dcf6629d35397..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts +++ /dev/null @@ -1,94 +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 { keyBy, isString } from 'lodash'; -import { ILegacyScopedClusterClient } from 'src/core/server'; -import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server'; - -import { - mergeCapabilitiesWithFields, - getCapabilitiesForRollupIndices, -} from '../../../../../../src/plugins/data/server'; - -const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); - -const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); -const isIndexPatternValid = (indexPattern: string) => - indexPattern && isString(indexPattern) && !isIndexPatternContainsWildcard(indexPattern); - -export const getRollupSearchStrategy = ( - AbstractSearchStrategy: any, - RollupSearchCapabilities: any, - getRollupService: (reg: ReqFacade) => Promise -) => - class RollupSearchStrategy extends AbstractSearchStrategy { - name = 'rollup'; - - constructor() { - super('rollup', { rest_total_hits_as_int: true }); - } - - async search(req: ReqFacade, bodies: any[], options = {}) { - const rollupService = await getRollupService(req); - const requests: any[] = []; - bodies.forEach((body) => { - requests.push( - rollupService.callAsCurrentUser('rollup.search', { - ...body, - rest_total_hits_as_int: true, - }) - ); - }); - return Promise.all(requests); - } - - async getRollupData(req: ReqFacade, indexPattern: string) { - const rollupService = await getRollupService(req); - return rollupService - .callAsCurrentUser('rollup.rollupIndexCapabilities', { - indexPattern, - }) - .catch(() => Promise.resolve({})); - } - - async checkForViability(req: ReqFacade, indexPattern: string) { - let isViable = false; - let capabilities = null; - - if (isIndexPatternValid(indexPattern)) { - const rollupData = await this.getRollupData(req, indexPattern); - const rollupIndices = getRollupIndices(rollupData); - - isViable = rollupIndices.length === 1; - - if (isViable) { - const [rollupIndex] = rollupIndices; - const fieldsCapabilities = getCapabilitiesForRollupIndices(rollupData); - - capabilities = new RollupSearchCapabilities(req, fieldsCapabilities, rollupIndex); - } - } - - return { - isViable, - capabilities, - }; - } - - async getFieldsForWildcard( - req: ReqFacade, - indexPattern: string, - { - fieldsCapabilities, - rollupIndex, - }: { fieldsCapabilities: { [key: string]: any }; rollupIndex: string } - ) { - const fields = await super.getFieldsForWildcard(req, indexPattern); - const fieldsFromFieldCapsApi = keyBy(fields, 'name'); - const rollupIndexCapabilities = fieldsCapabilities[rollupIndex].aggs; - - return mergeCapabilitiesWithFields(rollupIndexCapabilities, fieldsFromFieldCapsApi); - } - }; diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 51920af7c8cbc..3c670f56c7d8f 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -24,7 +24,6 @@ import { import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { ReqFacade } from '../../../../src/plugins/vis_type_timeseries/server'; import { PLUGIN, CONFIG_ROLLUPS } from '../common'; import { Dependencies } from './types'; import { registerApiRoutes } from './routes'; @@ -32,7 +31,6 @@ import { License } from './services'; import { registerRollupUsageCollector } from './collectors'; import { rollupDataEnricher } from './rollup_data_enricher'; import { IndexPatternsFetcher } from './shared_imports'; -import { registerRollupSearchStrategy } from './lib/search_strategies'; import { elasticsearchJsPlugin } from './client/elasticsearch_rollup'; import { isEsError } from './shared_imports'; import { formatEsError } from './lib/format_es_error'; @@ -45,6 +43,7 @@ async function getCustomEsClient(getStartServices: CoreSetup['getStartServices'] const [core] = await getStartServices(); // Extend the elasticsearchJs client with additional endpoints. const esClientConfig = { plugins: [elasticsearchJsPlugin] }; + return core.elasticsearch.legacy.createClient('rollup', esClientConfig); } @@ -128,15 +127,6 @@ export class RollupPlugin implements Plugin { }, }); - if (visTypeTimeseries) { - const getRollupService = async (request: ReqFacade) => { - this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); - return this.rollupEsClient.asScoped(request); - }; - const { addSearchStrategy } = visTypeTimeseries; - registerRollupSearchStrategy(addSearchStrategy, getRollupService); - } - if (usageCollection) { this.globalConfig$ .pipe(first()) diff --git a/x-pack/plugins/runtime_fields/README.md b/x-pack/plugins/runtime_fields/README.md new file mode 100644 index 0000000000000..d4664a3a07c61 --- /dev/null +++ b/x-pack/plugins/runtime_fields/README.md @@ -0,0 +1,197 @@ +# Runtime fields + +Welcome to the home of the runtime field editor and everything related to runtime fields! + +## The runtime field editor + +### Integration + +The recommended way to integrate the runtime fields editor is by adding a plugin dependency to the `"runtimeFields"` x-pack plugin. This way you will be able to lazy load the editor when it is required and it will not increment the bundle size of your plugin. + +```js +// 1. Add the plugin as a dependency in your kibana.json +{ + ... + "requiredBundles": [ + "runtimeFields", + ... + ] +} + +// 2. Access it in your plugin setup() +export class MyPlugin { + setup(core, { runtimeFields }) { + // logic to provide it to your app, probably through context + } +} + +// 3. Load the editor and open it anywhere in your app +const MyComponent = () => { + // Access the plugin through context + const { runtimeFields } = useAppPlugins(); + + // Ref of the handler to close the editor + const closeRuntimeFieldEditor = useRef(() => {}); + + const saveRuntimeField = (field: RuntimeField) => { + // Do something with the field + console.log(field); // { name: 'myField', type: 'boolean', script: "return 'hello'" } + }; + + const openRuntimeFieldsEditor = async() => { + // Lazy load the editor + const { openEditor } = await runtimeFields.loadEditor(); + + closeRuntimeFieldEditor.current = openEditor({ + onSave: saveRuntimeField, + /* defaultValue: optional field to edit */ + }); + }; + + useEffect(() => { + return () => { + // Make sure to remove the editor when the component unmounts + closeRuntimeFieldEditor.current(); + }; + }, []); + + return ( + + ) +} +``` + +#### Alternative + +The runtime field editor is also exported as static React component that you can import into your components. The editor is exported in 2 flavours: + +* As the content of a `` (it contains a flyout header and footer) +* As a standalone component that you can inline anywhere + +**Note:** The runtime field editor uses the `` that has a dependency on the `Provider` from the `"kibana_react"` plugin. If your app is not already wrapped by this provider you will need to add it at least around the runtime field editor. You can see an example in the ["Using the core.overlays.openFlyout()"](#using-the-coreoverlaysopenflyout) example below. + +### Content of a `` + +```js +import React, { useState } from 'react'; +import { EuiFlyoutBody, EuiButton } from '@elastic/eui'; +import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields/public'; + +const MyComponent = () => { + const { docLinksStart } = useCoreContext(); // access the core start service + const [isFlyoutVisilbe, setIsFlyoutVisible] = useState(false); + + const saveRuntimeField = useCallback((field: RuntimeField) => { + // Do something with the field + }, []); + + return ( + <> + setIsFlyoutVisible(true)}>Create field + + {isFlyoutVisible && ( + setIsFlyoutVisible(false)}> + setIsFlyoutVisible(false)} + docLinks={docLinksStart} + defaultValue={/*optional runtime field to edit*/} + /> + + )} + + ) +} +``` + +#### Using the `core.overlays.openFlyout()` + +As an alternative you can open the flyout with the `openFlyout()` helper from core. + +```js +import React, { useRef } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { OverlayRef } from 'src/core/public'; + +import { createKibanaReactContext, toMountPoint } from '../../src/plugins/kibana_react/public'; +import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields/public'; + +const MyComponent = () => { + // Access the core start service + const { docLinksStart, overlays, uiSettings } = useCoreContext(); + const flyoutEditor = useRef(null); + + const { openFlyout } = overlays; + + const saveRuntimeField = useCallback((field: RuntimeField) => { + // Do something with the field + }, []); + + const openRuntimeFieldEditor = useCallback(() => { + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings }); + + flyoutEditor.current = openFlyout( + toMountPoint( + + flyoutEditor.current?.close()} + docLinks={docLinksStart} + defaultValue={defaultRuntimeField} + /> + + ) + ); + }, [openFlyout, saveRuntimeField, uiSettings]); + + return ( + <> + Create field + + ) +} +``` + +### Standalone component + +```js +import React, { useState } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { RuntimeFieldEditor, RuntimeField, RuntimeFieldFormState } from '../runtime_fields/public'; + +const MyComponent = () => { + const { docLinksStart } = useCoreContext(); // access the core start service + const [runtimeFieldFormState, setRuntimeFieldFormState] = useState({ + isSubmitted: false, + isValid: undefined, + submit: async() => Promise.resolve({ isValid: false, data: {} as RuntimeField }) + }); + + const { submit, isValid: isFormValid, isSubmitted } = runtimeFieldFormState; + + const saveRuntimeField = useCallback(async () => { + const { isValid, data } = await submit(); + if (isValid) { + // Do something with the field (data) + } + }, [submit]); + + return ( + <> + + + + + + Save field + + + ) +} +``` \ No newline at end of file diff --git a/x-pack/plugins/runtime_fields/kibana.json b/x-pack/plugins/runtime_fields/kibana.json new file mode 100644 index 0000000000000..65932c723c474 --- /dev/null +++ b/x-pack/plugins/runtime_fields/kibana.json @@ -0,0 +1,15 @@ +{ + "id": "runtimeFields", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": [ + ], + "optionalPlugins": [ + ], + "configPath": ["xpack", "runtime_fields"], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] +} diff --git a/x-pack/plugins/runtime_fields/public/__jest__/setup_environment.tsx b/x-pack/plugins/runtime_fields/public/__jest__/setup_environment.tsx new file mode 100644 index 0000000000000..ccfe426cfdb09 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/__jest__/setup_environment.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +jest.mock('../../../../../src/plugins/kibana_react/public', () => { + const original = jest.requireActual('../../../../../src/plugins/kibana_react/public'); + + const CodeEditorMock = (props: any) => ( + ) => { + props.onChange(e.target.value); + }} + /> + ); + + return { + ...original, + CodeEditor: CodeEditorMock, + }; +}); + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + EuiComboBox: (props: any) => ( + { + props.onChange([syntheticEvent['0']]); + }} + /> + ), + }; +}); diff --git a/x-pack/plugins/runtime_fields/public/components/index.ts b/x-pack/plugins/runtime_fields/public/components/index.ts new file mode 100644 index 0000000000000..86ac968d39f21 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/index.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. + */ + +export { RuntimeFieldForm, FormState as RuntimeFieldFormState } from './runtime_field_form'; + +export { RuntimeFieldEditor } from './runtime_field_editor'; + +export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content'; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/index.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/index.ts similarity index 74% rename from x-pack/plugins/rollup/server/lib/search_strategies/index.ts rename to x-pack/plugins/runtime_fields/public/components/runtime_field_editor/index.ts index 7db0b38ea29dd..62fa0bf991542 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/index.ts +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { registerRollupSearchStrategy } from './register_rollup_search_strategy'; +export { RuntimeFieldEditor } from './runtime_field_editor'; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx new file mode 100644 index 0000000000000..c56bc16c304ad --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; +import { DocLinksStart } from 'src/core/public'; + +import '../../__jest__/setup_environment'; +import { registerTestBed, TestBed } from '../../test_utils'; +import { RuntimeField } from '../../types'; +import { RuntimeFieldForm, FormState } from '../runtime_field_form/runtime_field_form'; +import { RuntimeFieldEditor, Props } from './runtime_field_editor'; + +const setup = (props?: Props) => + registerTestBed(RuntimeFieldEditor, { + memoryRouter: { + wrapComponent: false, + }, + })(props) as TestBed; + +const docLinks: DocLinksStart = { + ELASTIC_WEBSITE_URL: 'https://jestTest.elastic.co', + DOC_LINK_VERSION: 'jest', + links: {} as any, +}; + +describe('Runtime field editor', () => { + let testBed: TestBed; + let onChange: jest.Mock = jest.fn(); + + const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1]; + + beforeEach(() => { + onChange = jest.fn(); + }); + + test('should render the ', () => { + testBed = setup({ docLinks }); + const { component } = testBed; + + expect(component.find(RuntimeFieldForm).length).toBe(1); + }); + + test('should accept a defaultValue and onChange prop to forward the form state', async () => { + const defaultValue: RuntimeField = { + name: 'foo', + type: 'date', + script: 'test=123', + }; + testBed = setup({ onChange, defaultValue, docLinks }); + + expect(onChange).toHaveBeenCalled(); + + let lastState = lastOnChangeCall()[0]; + expect(lastState.isValid).toBe(undefined); + expect(lastState.isSubmitted).toBe(false); + expect(lastState.submit).toBeDefined(); + + let data; + await act(async () => { + ({ data } = await lastState.submit()); + }); + expect(data).toEqual(defaultValue); + + // Make sure that both isValid and isSubmitted state are now "true" + lastState = lastOnChangeCall()[0]; + expect(lastState.isValid).toBe(true); + expect(lastState.isSubmitted).toBe(true); + }); +}); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx new file mode 100644 index 0000000000000..07935be171fd2 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { DocLinksStart } from 'src/core/public'; + +import { RuntimeField } from '../../types'; +import { getLinks } from '../../lib'; +import { RuntimeFieldForm, Props as FormProps } from '../runtime_field_form/runtime_field_form'; + +export interface Props { + docLinks: DocLinksStart; + defaultValue?: RuntimeField; + onChange?: FormProps['onChange']; +} + +export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks }: Props) => { + const links = getLinks(docLinks); + + return ; +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts similarity index 61% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts rename to x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts index b781199c85237..32234bfcc5600 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string }; -export function clearSelectedJobIdFromUrl(str: string): void; +export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content'; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx new file mode 100644 index 0000000000000..8e47472295f45 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.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 { act } from 'react-dom/test-utils'; +import { DocLinksStart } from 'src/core/public'; + +import '../../__jest__/setup_environment'; +import { registerTestBed, TestBed } from '../../test_utils'; +import { RuntimeField } from '../../types'; +import { RuntimeFieldEditorFlyoutContent, Props } from './runtime_field_editor_flyout_content'; + +const setup = (props?: Props) => + registerTestBed(RuntimeFieldEditorFlyoutContent, { + memoryRouter: { + wrapComponent: false, + }, + })(props) as TestBed; + +const docLinks: DocLinksStart = { + ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co', + DOC_LINK_VERSION: 'jest', + links: {} as any, +}; + +const noop = () => {}; +const defaultProps = { onSave: noop, onCancel: noop, docLinks }; + +describe('Runtime field editor flyout', () => { + test('should have a flyout title', () => { + const { exists, find } = setup(defaultProps); + + expect(exists('flyoutTitle')).toBe(true); + expect(find('flyoutTitle').text()).toBe('Create new field'); + }); + + test('should allow a runtime field to be provided', () => { + const field: RuntimeField = { + name: 'foo', + type: 'date', + script: 'test=123', + }; + + const { find } = setup({ ...defaultProps, defaultValue: field }); + + expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`); + expect(find('nameField.input').props().value).toBe(field.name); + expect(find('typeField').props().value).toBe(field.type); + expect(find('scriptField').props().value).toBe(field.script); + }); + + test('should accept an onSave prop', async () => { + const field: RuntimeField = { + name: 'foo', + type: 'date', + script: 'test=123', + }; + const onSave: jest.Mock = jest.fn(); + + const { find } = setup({ ...defaultProps, onSave, defaultValue: field }); + + await act(async () => { + find('saveFieldButton').simulate('click'); + }); + + expect(onSave).toHaveBeenCalled(); + const fieldReturned: RuntimeField = onSave.mock.calls[onSave.mock.calls.length - 1][0]; + expect(fieldReturned).toEqual(field); + }); + + test('should accept an onCancel prop', () => { + const onCancel = jest.fn(); + const { find } = setup({ ...defaultProps, onCancel }); + + find('closeFlyoutButton').simulate('click'); + + expect(onCancel).toHaveBeenCalled(); + }); + + describe('validation', () => { + test('should validate the fields and prevent saving invalid form', async () => { + const onSave: jest.Mock = jest.fn(); + + const { find, exists, form, component } = setup({ ...defaultProps, onSave }); + + expect(find('saveFieldButton').props().disabled).toBe(false); + + await act(async () => { + find('saveFieldButton').simulate('click'); + }); + component.update(); + + expect(onSave).toHaveBeenCalledTimes(0); + expect(find('saveFieldButton').props().disabled).toBe(true); + expect(form.getErrorsMessages()).toEqual([ + 'Give a name to the field.', + 'Script must emit() a value.', + ]); + expect(exists('formError')).toBe(true); + expect(find('formError').text()).toBe('Fix errors in form before continuing.'); + }); + + test('should forward values from the form', async () => { + const onSave: jest.Mock = jest.fn(); + + const { find, form } = setup({ ...defaultProps, onSave }); + + act(() => { + form.setInputValue('nameField.input', 'someName'); + form.setInputValue('scriptField', 'script=123'); + }); + + await act(async () => { + find('saveFieldButton').simulate('click'); + }); + + expect(onSave).toHaveBeenCalled(); + let fieldReturned: RuntimeField = onSave.mock.calls[onSave.mock.calls.length - 1][0]; + expect(fieldReturned).toEqual({ + name: 'someName', + type: 'keyword', // default to keyword + script: 'script=123', + }); + + // Change the type and make sure it is forwarded + act(() => { + find('typeField').simulate('change', [ + { + label: 'Other type', + value: 'other_type', + }, + ]); + }); + await act(async () => { + find('saveFieldButton').simulate('click'); + }); + fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0]; + expect(fieldReturned).toEqual({ + name: 'someName', + type: 'other_type', + script: 'script=123', + }); + }); + }); +}); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx new file mode 100644 index 0000000000000..c7454cff0eb15 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.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 React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiSpacer, + EuiCallOut, +} from '@elastic/eui'; +import { DocLinksStart } from 'src/core/public'; + +import { RuntimeField } from '../../types'; +import { FormState } from '../runtime_field_form'; +import { RuntimeFieldEditor } from '../runtime_field_editor'; + +const geti18nTexts = (field?: RuntimeField) => { + return { + flyoutTitle: field + ? i18n.translate('xpack.runtimeFields.editor.flyoutEditFieldTitle', { + defaultMessage: 'Edit {fieldName} field', + values: { + fieldName: field.name, + }, + }) + : i18n.translate('xpack.runtimeFields.editor.flyoutDefaultTitle', { + defaultMessage: 'Create new field', + }), + closeButtonLabel: i18n.translate('xpack.runtimeFields.editor.flyoutCloseButtonLabel', { + defaultMessage: 'Close', + }), + saveButtonLabel: i18n.translate('xpack.runtimeFields.editor.flyoutSaveButtonLabel', { + defaultMessage: 'Save', + }), + formErrorsCalloutTitle: i18n.translate('xpack.runtimeFields.editor.validationErrorTitle', { + defaultMessage: 'Fix errors in form before continuing.', + }), + }; +}; + +export interface Props { + /** + * Handler for the "save" footer button + */ + onSave: (field: RuntimeField) => void; + /** + * Handler for the "cancel" footer button + */ + onCancel: () => void; + /** + * The docLinks start service from core + */ + docLinks: DocLinksStart; + /** + * An optional runtime field to edit + */ + defaultValue?: RuntimeField; +} + +export const RuntimeFieldEditorFlyoutContent = ({ + onSave, + onCancel, + docLinks, + defaultValue: field, +}: Props) => { + const i18nTexts = geti18nTexts(field); + + const [formState, setFormState] = useState({ + isSubmitted: false, + isValid: field ? true : undefined, + submit: field + ? async () => ({ isValid: true, data: field }) + : async () => ({ isValid: false, data: {} as RuntimeField }), + }); + const { submit, isValid: isFormValid, isSubmitted } = formState; + + const onSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + onSave(data); + } + }, [submit, onSave]); + + return ( + <> + + +

{i18nTexts.flyoutTitle}

+
+
+ + + + + + + {isSubmitted && !isFormValid && ( + <> + + + + )} + + + + onCancel()} + data-test-subj="closeFlyoutButton" + > + {i18nTexts.closeButtonLabel} + + + + + onSaveField()} + data-test-subj="saveFieldButton" + disabled={isSubmitted && !isFormValid} + fill + > + {i18nTexts.saveButtonLabel} + + + + + + ); +}; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/index.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/index.ts new file mode 100644 index 0000000000000..4041a04aec4d1 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RuntimeFieldForm, FormState } from './runtime_field_form'; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx new file mode 100644 index 0000000000000..1829514856eed --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import '../../__jest__/setup_environment'; +import { registerTestBed, TestBed } from '../../test_utils'; +import { RuntimeField } from '../../types'; +import { RuntimeFieldForm, Props, FormState } from './runtime_field_form'; + +const setup = (props?: Props) => + registerTestBed(RuntimeFieldForm, { + memoryRouter: { + wrapComponent: false, + }, + })(props) as TestBed; + +const links = { + painlessSyntax: 'https://jestTest.elastic.co/to-be-defined.html', +}; + +describe('Runtime field form', () => { + let testBed: TestBed; + let onChange: jest.Mock = jest.fn(); + + const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1]; + + beforeEach(() => { + onChange = jest.fn(); + }); + + test('should render expected 3 fields (name, returnType, script)', () => { + testBed = setup({ links }); + const { exists } = testBed; + + expect(exists('nameField')).toBe(true); + expect(exists('typeField')).toBe(true); + expect(exists('scriptField')).toBe(true); + }); + + test('should have a link to learn more about painless syntax', () => { + testBed = setup({ links }); + const { exists, find } = testBed; + + expect(exists('painlessSyntaxLearnMoreLink')).toBe(true); + expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.painlessSyntax); + }); + + test('should accept a "defaultValue" prop', () => { + const defaultValue: RuntimeField = { + name: 'foo', + type: 'date', + script: 'test=123', + }; + testBed = setup({ defaultValue, links }); + const { find } = testBed; + + expect(find('nameField.input').props().value).toBe(defaultValue.name); + expect(find('typeField').props().value).toBe(defaultValue.type); + expect(find('scriptField').props().value).toBe(defaultValue.script); + }); + + test('should accept an "onChange" prop to forward the form state', async () => { + const defaultValue: RuntimeField = { + name: 'foo', + type: 'date', + script: 'test=123', + }; + testBed = setup({ onChange, defaultValue, links }); + + expect(onChange).toHaveBeenCalled(); + + let lastState = lastOnChangeCall()[0]; + expect(lastState.isValid).toBe(undefined); + expect(lastState.isSubmitted).toBe(false); + expect(lastState.submit).toBeDefined(); + + let data; + await act(async () => { + ({ data } = await lastState.submit()); + }); + expect(data).toEqual(defaultValue); + + // Make sure that both isValid and isSubmitted state are now "true" + lastState = lastOnChangeCall()[0]; + expect(lastState.isValid).toBe(true); + expect(lastState.isSubmitted).toBe(true); + }); +}); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx new file mode 100644 index 0000000000000..6068302f5b269 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.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, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { PainlessLang } from '@kbn/monaco'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiComboBoxOptionOption, + EuiLink, +} from '@elastic/eui'; + +import { useForm, Form, FormHook, UseField, TextField, CodeEditor } from '../../shared_imports'; +import { RuntimeField } from '../../types'; +import { RUNTIME_FIELD_OPTIONS } from '../../constants'; +import { schema } from './schema'; + +export interface FormState { + isValid: boolean | undefined; + isSubmitted: boolean; + submit: FormHook['submit']; +} + +export interface Props { + links: { + painlessSyntax: string; + }; + defaultValue?: RuntimeField; + onChange?: (state: FormState) => void; +} + +const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => { + const { form } = useForm({ defaultValue, schema }); + const { submit, isValid: isFormValid, isSubmitted } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid: isFormValid, isSubmitted, submit }); + } + }, [onChange, isFormValid, isSubmitted, submit]); + + return ( +
+ + {/* Name */} + + + + + {/* Return type */} + + path="type"> + {({ label, value, setValue }) => { + if (value === undefined) { + return null; + } + return ( + <> + + { + if (newValue.length === 0) { + // Don't allow clearing the type. One must always be selected + return; + } + setValue(newValue); + }} + isClearable={false} + data-test-subj="typeField" + fullWidth + /> + + + ); + }} + + + + + + + {/* Script */} + path="script"> + {({ value, setValue, label, isValid, getErrorsMessages }) => { + return ( + + + + {i18n.translate('xpack.runtimeFields.form.script.learnMoreLinkText', { + defaultMessage: 'Learn more about syntax.', + })} + + + + } + fullWidth + > + + + ); + }} + + + ); +}; + +export const RuntimeFieldForm = React.memo(RuntimeFieldFormComp); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts new file mode 100644 index 0000000000000..abb7cf812200f --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +import { FormSchema, fieldValidators } from '../../shared_imports'; +import { RUNTIME_FIELD_OPTIONS } from '../../constants'; +import { RuntimeField, RuntimeType, ComboBoxOption } from '../../types'; + +const { emptyField } = fieldValidators; + +export const schema: FormSchema = { + name: { + label: i18n.translate('xpack.runtimeFields.form.nameLabel', { + defaultMessage: 'Name', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.runtimeFields.form.validations.nameIsRequiredErrorMessage', { + defaultMessage: 'Give a name to the field.', + }) + ), + }, + ], + }, + type: { + label: i18n.translate('xpack.runtimeFields.form.runtimeTypeLabel', { + defaultMessage: 'Type', + }), + defaultValue: 'keyword', + deserializer: (fieldType?: RuntimeType) => { + if (!fieldType) { + return []; + } + + const label = RUNTIME_FIELD_OPTIONS.find(({ value }) => value === fieldType)?.label; + return [{ label: label ?? fieldType, value: fieldType }]; + }, + serializer: (value: Array>) => value[0].value!, + }, + script: { + label: i18n.translate('xpack.runtimeFields.form.defineFieldLabel', { + defaultMessage: 'Define field', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.runtimeFields.form.validations.scriptIsRequiredErrorMessage', { + defaultMessage: 'Script must emit() a value.', + }) + ), + }, + ], + }, +}; diff --git a/x-pack/plugins/runtime_fields/public/constants.ts b/x-pack/plugins/runtime_fields/public/constants.ts new file mode 100644 index 0000000000000..017b58c246afe --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/constants.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 { ComboBoxOption } from './types'; + +export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; + +type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; + +export const RUNTIME_FIELD_OPTIONS: Array> = [ + { + label: 'Keyword', + value: 'keyword', + }, + { + label: 'Long', + value: 'long', + }, + { + label: 'Double', + value: 'double', + }, + { + label: 'Date', + value: 'date', + }, + { + label: 'IP', + value: 'ip', + }, + { + label: 'Boolean', + value: 'boolean', + }, +]; diff --git a/x-pack/plugins/runtime_fields/public/index.ts b/x-pack/plugins/runtime_fields/public/index.ts new file mode 100644 index 0000000000000..0eab32c0b3d97 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { RuntimeFieldsPlugin } from './plugin'; + +export { + RuntimeFieldEditorFlyoutContent, + RuntimeFieldEditor, + RuntimeFieldFormState, +} from './components'; +export { RUNTIME_FIELD_OPTIONS } from './constants'; +export { RuntimeField, RuntimeType, PluginSetup as RuntimeFieldsSetup } from './types'; + +export function plugin() { + return new RuntimeFieldsPlugin(); +} diff --git a/x-pack/plugins/runtime_fields/public/lib/documentation.ts b/x-pack/plugins/runtime_fields/public/lib/documentation.ts new file mode 100644 index 0000000000000..87eab8b7ed997 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/lib/documentation.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DocLinksStart } from 'src/core/public'; + +export const getLinks = (docLinks: DocLinksStart) => { + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`; + + return { + painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`, + }; +}; diff --git a/x-pack/plugins/runtime_fields/public/lib/index.ts b/x-pack/plugins/runtime_fields/public/lib/index.ts new file mode 100644 index 0000000000000..11c9914bf2e81 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getLinks } from './documentation'; diff --git a/x-pack/plugins/runtime_fields/public/load_editor.tsx b/x-pack/plugins/runtime_fields/public/load_editor.tsx new file mode 100644 index 0000000000000..f1b9c495f0336 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/load_editor.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { CoreSetup, OverlayRef } from 'src/core/public'; + +import { toMountPoint, createKibanaReactContext } from './shared_imports'; +import { LoadEditorResponse, RuntimeField } from './types'; + +export interface OpenRuntimeFieldEditorProps { + onSave(field: RuntimeField): void; + defaultValue?: RuntimeField; +} + +export const getRuntimeFieldEditorLoader = (coreSetup: CoreSetup) => async (): Promise< + LoadEditorResponse +> => { + const { RuntimeFieldEditorFlyoutContent } = await import('./components'); + const [core] = await coreSetup.getStartServices(); + const { uiSettings, overlays, docLinks } = core; + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings }); + + let overlayRef: OverlayRef | null = null; + + const openEditor = ({ onSave, defaultValue }: OpenRuntimeFieldEditorProps) => { + const closeEditor = () => { + overlayRef?.close(); + overlayRef = null; + }; + + const onSaveField = (field: RuntimeField) => { + closeEditor(); + onSave(field); + }; + + overlayRef = overlays.openFlyout( + toMountPoint( + + overlayRef?.close()} + docLinks={docLinks} + defaultValue={defaultValue} + /> + + ) + ); + + return closeEditor; + }; + + return { + openEditor, + }; +}; diff --git a/x-pack/plugins/runtime_fields/public/plugin.test.ts b/x-pack/plugins/runtime_fields/public/plugin.test.ts new file mode 100644 index 0000000000000..07f7a3553d0d3 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/plugin.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { coreMock } from 'src/core/public/mocks'; + +jest.mock('../../../../src/plugins/kibana_react/public', () => { + const original = jest.requireActual('../../../../src/plugins/kibana_react/public'); + + return { + ...original, + toMountPoint: (node: React.ReactNode) => node, + }; +}); + +import { StartPlugins, PluginStart } from './types'; +import { RuntimeFieldEditorFlyoutContent } from './components'; +import { RuntimeFieldsPlugin } from './plugin'; + +const noop = () => {}; + +describe('RuntimeFieldsPlugin', () => { + let coreSetup: CoreSetup; + let plugin: RuntimeFieldsPlugin; + + beforeEach(() => { + plugin = new RuntimeFieldsPlugin(); + coreSetup = coreMock.createSetup(); + }); + + test('should return a handler to load the runtime field editor', async () => { + const setupApi = await plugin.setup(coreSetup, {}); + expect(setupApi.loadEditor).toBeDefined(); + }); + + test('once it is loaded it should expose a handler to open the editor', async () => { + const setupApi = await plugin.setup(coreSetup, {}); + const response = await setupApi.loadEditor(); + expect(response.openEditor).toBeDefined(); + }); + + test('should call core.overlays.openFlyout when opening the editor', async () => { + const openFlyout = jest.fn(); + const onSaveSpy = jest.fn(); + + const mockCore = { + overlays: { + openFlyout, + }, + uiSettings: {}, + }; + coreSetup.getStartServices = async () => [mockCore] as any; + const setupApi = await plugin.setup(coreSetup, {}); + const { openEditor } = await setupApi.loadEditor(); + + openEditor({ onSave: onSaveSpy }); + + expect(openFlyout).toHaveBeenCalled(); + + const [[arg]] = openFlyout.mock.calls; + expect(arg.props.children.type).toBe(RuntimeFieldEditorFlyoutContent); + + // We force call the "onSave" prop from the component + // and make sure that the the spy is being called. + // Note: we are testing implementation details, if we change or rename the "onSave" prop on + // the component, we will need to update this test accordingly. + expect(arg.props.children.props.onSave).toBeDefined(); + arg.props.children.props.onSave(); + expect(onSaveSpy).toHaveBeenCalled(); + }); + + test('should return a handler to close the flyout', async () => { + const setupApi = await plugin.setup(coreSetup, {}); + const { openEditor } = await setupApi.loadEditor(); + + const closeEditorHandler = openEditor({ onSave: noop }); + expect(typeof closeEditorHandler).toBe('function'); + }); +}); diff --git a/x-pack/plugins/runtime_fields/public/plugin.ts b/x-pack/plugins/runtime_fields/public/plugin.ts new file mode 100644 index 0000000000000..ebc8b98db66ba --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/plugin.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 { Plugin, CoreSetup, CoreStart } from 'src/core/public'; + +import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types'; +import { getRuntimeFieldEditorLoader } from './load_editor'; + +export class RuntimeFieldsPlugin + implements Plugin { + public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup { + return { + loadEditor: getRuntimeFieldEditorLoader(core), + }; + } + + public start(core: CoreStart, plugins: StartPlugins) { + return {}; + } + + public stop() { + return {}; + } +} diff --git a/x-pack/plugins/runtime_fields/public/shared_imports.ts b/x-pack/plugins/runtime_fields/public/shared_imports.ts new file mode 100644 index 0000000000000..200a68ab71031 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/shared_imports.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + useForm, + Form, + FormSchema, + UseField, + FormHook, +} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + +export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; + +export { TextField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; + +export { + CodeEditor, + toMountPoint, + createKibanaReactContext, +} from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/runtime_fields/public/test_utils.ts b/x-pack/plugins/runtime_fields/public/test_utils.ts new file mode 100644 index 0000000000000..966db01ef1532 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/test_utils.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 { registerTestBed, TestBed } from '@kbn/test/jest'; diff --git a/x-pack/plugins/runtime_fields/public/types.ts b/x-pack/plugins/runtime_fields/public/types.ts new file mode 100644 index 0000000000000..4172061540af8 --- /dev/null +++ b/x-pack/plugins/runtime_fields/public/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DataPublicPluginStart } from 'src/plugins/data/public'; + +import { RUNTIME_FIELD_TYPES } from './constants'; +import { OpenRuntimeFieldEditorProps } from './load_editor'; + +export interface LoadEditorResponse { + openEditor(props: OpenRuntimeFieldEditorProps): () => void; +} + +export interface PluginSetup { + loadEditor(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginStart {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SetupPlugins {} + +export interface StartPlugins { + data: DataPublicPluginStart; +} + +export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; + +export interface RuntimeField { + name: string; + type: RuntimeType; + script: string; +} + +export interface ComboBoxOption { + label: string; + value?: T; +} diff --git a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts index 80d2dbc0b1566..7f6e2a12d9e53 100644 --- a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts +++ b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts @@ -6,6 +6,7 @@ import { SavedObject, SavedObjectReference } from 'src/core/types'; import { Tag, TagAttributes } from '../types'; +import { TagsCapabilities } from '../capabilities'; export const createTagReference = (id: string): SavedObjectReference => ({ type: 'tag', @@ -35,3 +36,13 @@ export const createTagAttributes = (parts: Partial = {}): TagAttr color: '#FF00CC', ...parts, }); + +export const createTagCapabilities = (parts: Partial = {}): TagsCapabilities => ({ + view: true, + create: true, + edit: true, + delete: true, + assign: true, + viewConnections: true, + ...parts, +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx index 7baebdae2493e..1a80c0598f97a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx @@ -22,6 +22,7 @@ import { EuiTextArea, EuiSpacer, EuiText, + htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -52,6 +53,7 @@ export const CreateOrEditModal: FC = ({ tag, mode, }) => { + const optionalMessageId = htmlIdGenerator()(); const ifMounted = useIfMounted(); const [submitting, setSubmitting] = useState(false); @@ -139,6 +141,12 @@ export const CreateOrEditModal: FC = ({ onClick={() => setColor(getRandomColor())} size="xs" style={{ height: '18px', fontSize: '0.75rem' }} + aria-label={i18n.translate( + 'xpack.savedObjectsTagging.management.createModal.color.randomizeAriaLabel', + { + defaultMessage: 'Randomize tag color', + } + )} > = ({ defaultMessage: 'Description', })} labelAppend={ - + = ({ resize="none" fullWidth={true} compressed={true} + aria-describedby={optionalMessageId} /> diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts new file mode 100644 index 0000000000000..42a4e628bef4e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + overlayServiceMock, + notificationServiceMock, +} from '../../../../../../src/core/public/mocks'; +import { tagClientMock } from '../../tags/tags_client.mock'; +import { TagBulkAction } from '../types'; +import { getBulkDeleteAction } from './bulk_delete'; + +describe('bulkDeleteAction', () => { + let tagClient: ReturnType; + let overlays: ReturnType; + let notifications: ReturnType; + let setLoading: jest.MockedFunction<(loading: boolean) => void>; + let action: TagBulkAction; + + const tagIds = ['id-1', 'id-2', 'id-3']; + + beforeEach(() => { + tagClient = tagClientMock.create(); + overlays = overlayServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + setLoading = jest.fn(); + + action = getBulkDeleteAction({ tagClient, overlays, notifications, setLoading }); + }); + + it('performs the operation if the confirmation is accepted', async () => { + overlays.openConfirm.mockResolvedValue(true); + + await action.execute(tagIds); + + expect(overlays.openConfirm).toHaveBeenCalledTimes(1); + + expect(tagClient.bulkDelete).toHaveBeenCalledTimes(1); + expect(tagClient.bulkDelete).toHaveBeenCalledWith(tagIds); + + expect(notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not perform the operation if the confirmation is rejected', async () => { + overlays.openConfirm.mockResolvedValue(false); + + await action.execute(tagIds); + + expect(overlays.openConfirm).toHaveBeenCalledTimes(1); + + expect(tagClient.bulkDelete).not.toHaveBeenCalled(); + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + }); + + it('does not show notification if `client.bulkDelete` rejects ', async () => { + overlays.openConfirm.mockResolvedValue(true); + tagClient.bulkDelete.mockRejectedValue(new Error('error calling bulkDelete')); + + await expect(action.execute(tagIds)).rejects.toMatchInlineSnapshot( + `[Error: error calling bulkDelete]` + ); + + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts new file mode 100644 index 0000000000000..6d9c14d330007 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverlayStart, NotificationsStart } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { ITagInternalClient } from '../../tags'; +import { TagBulkAction } from '../types'; + +interface GetBulkDeleteActionOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagClient: ITagInternalClient; + setLoading: (loading: boolean) => void; +} + +export const getBulkDeleteAction = ({ + overlays, + notifications, + tagClient, + setLoading, +}: GetBulkDeleteActionOptions): TagBulkAction => { + return { + id: 'delete', + label: i18n.translate('xpack.savedObjectsTagging.management.actions.bulkDelete.label', { + defaultMessage: 'Delete', + }), + 'aria-label': i18n.translate( + 'xpack.savedObjectsTagging.management.actions.bulkDelete.ariaLabel', + { + defaultMessage: 'Delete selected tags', + } + ), + icon: 'trash', + refreshAfterExecute: true, + execute: async (tagIds) => { + const confirmed = await overlays.openConfirm( + i18n.translate('xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.text', { + defaultMessage: + 'By deleting {count, plural, one {this tag} other {these tags}}, you will no longer be able to assign {count, plural, one {it} other {them}} to saved objects. ' + + '{count, plural, one {This tag} other {These tags}} will be removed from any saved objects that currently use {count, plural, one {it} other {them}}. ' + + 'Are you sure you wish to proceed?', + values: { + count: tagIds.length, + }, + }), + { + title: i18n.translate( + 'xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.title', + { + defaultMessage: 'Delete {count, plural, one {1 tag} other {# tags}}', + values: { + count: tagIds.length, + }, + } + ), + confirmButtonText: i18n.translate( + 'xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.confirmButtonText', + { + defaultMessage: 'Delete {count, plural, one {tag} other {tags}}', + values: { + count: tagIds.length, + }, + } + ), + buttonColor: 'danger', + maxWidth: 560, + } + ); + + if (confirmed) { + setLoading(true); + await tagClient.bulkDelete(tagIds); + setLoading(false); + + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.savedObjectsTagging.management.actions.bulkDelete.notification.successTitle', + { + defaultMessage: 'Deleted {count, plural, one {1 tag} other {# tags}}', + values: { + count: tagIds.length, + }, + } + ), + }); + } + }, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/clear_selection.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/clear_selection.ts new file mode 100644 index 0000000000000..79212be98236c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/clear_selection.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { TagBulkAction } from '../types'; + +interface GetClearSelectionActionOptions { + clearSelection: () => void; +} + +export const getClearSelectionAction = ({ + clearSelection, +}: GetClearSelectionActionOptions): TagBulkAction => { + return { + id: 'clear_selection', + label: i18n.translate('xpack.savedObjectsTagging.management.actions.clearSelection.label', { + defaultMessage: 'Clear selection', + }), + icon: 'cross', + refreshAfterExecute: true, + execute: async () => { + clearSelection(); + }, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts new file mode 100644 index 0000000000000..5325d4ee97cf8 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createTagCapabilities } from '../../../common/test_utils'; +import { TagsCapabilities } from '../../../common/capabilities'; +import { tagClientMock } from '../../tags/tags_client.mock'; +import { TagBulkAction } from '../types'; + +import { getBulkActions } from './index'; + +describe('getBulkActions', () => { + let core: ReturnType; + let tagClient: ReturnType; + let clearSelection: jest.MockedFunction<() => void>; + let setLoading: jest.MockedFunction<(loading: boolean) => void>; + + beforeEach(() => { + core = coreMock.createStart(); + tagClient = tagClientMock.create(); + clearSelection = jest.fn(); + setLoading = jest.fn(); + }); + + const getActions = (caps: Partial) => + getBulkActions({ + core, + tagClient, + clearSelection, + setLoading, + capabilities: createTagCapabilities(caps), + }); + + const getIds = (actions: TagBulkAction[]) => actions.map((action) => action.id); + + it('only returns the `delete` action if user got `delete` permission', () => { + let actions = getActions({ delete: true }); + + expect(getIds(actions)).toContain('delete'); + + actions = getActions({ delete: false }); + + expect(getIds(actions)).not.toContain('delete'); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts new file mode 100644 index 0000000000000..182f0013251df --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'src/core/public'; +import { TagsCapabilities } from '../../../common'; +import { ITagInternalClient } from '../../tags'; +import { TagBulkAction } from '../types'; +import { getBulkDeleteAction } from './bulk_delete'; +import { getClearSelectionAction } from './clear_selection'; + +interface GetBulkActionOptions { + core: CoreStart; + capabilities: TagsCapabilities; + tagClient: ITagInternalClient; + clearSelection: () => void; + setLoading: (loading: boolean) => void; +} + +export const getBulkActions = ({ + core: { notifications, overlays }, + capabilities, + tagClient, + clearSelection, + setLoading, +}: GetBulkActionOptions): TagBulkAction[] => { + const actions: TagBulkAction[] = []; + + if (capabilities.delete) { + actions.push(getBulkDeleteAction({ notifications, overlays, tagClient, setLoading })); + } + + // only add clear selection if user has permission to perform any other action + // as having at least one action will show the bulk action menu, and the selection column on the table + // and we want to avoid doing that only for the 'unselect' action. + if (actions.length > 0) { + actions.push(getClearSelectionAction({ clearSelection })); + } + + return actions; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/_action_bar.scss b/x-pack/plugins/saved_objects_tagging/public/management/components/_action_bar.scss new file mode 100644 index 0000000000000..6858e70e49e8f --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/_action_bar.scss @@ -0,0 +1,17 @@ +.tagMgt__actionBar + .euiSpacer { + display: none; +} + +.tagMgt__actionBarDivider { + height: $euiSize; + border-right: $euiBorderThin; +} + +.tagMgt__actionBar { + border-bottom: $euiBorderThin; + padding-bottom: $euiSizeS; +} + +.tagMgt__actionBarIcon { + margin-left: $euiSizeXS; +} diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/action_bar.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/action_bar.tsx new file mode 100644 index 0000000000000..15d8f155f6246 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/action_bar.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, useCallback, useMemo, FC } from 'react'; +import { + EuiPopover, + EuiFlexItem, + EuiFlexGroup, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + EuiText, + EuiLink, + EuiIcon, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { TagBulkAction } from '../types'; + +import './_action_bar.scss'; + +export interface ActionBarProps { + actions: TagBulkAction[]; + totalCount: number; + selectedCount: number; + onActionSelected: (action: TagBulkAction) => void; +} + +const actionToMenuItem = ( + action: TagBulkAction, + onActionSelected: (action: TagBulkAction) => void, + closePopover: () => void +): EuiContextMenuPanelItemDescriptor => { + return { + name: action.label, + icon: action.icon, + onClick: () => { + closePopover(); + onActionSelected(action); + }, + 'data-test-subj': `actionBar-button-${action.id}`, + }; +}; + +export const ActionBar: FC = ({ + actions, + onActionSelected, + selectedCount, + totalCount, +}) => { + const [isPopoverOpened, setPopOverOpened] = useState(false); + + const closePopover = useCallback(() => { + setPopOverOpened(false); + }, [setPopOverOpened]); + + const togglePopover = useCallback(() => { + setPopOverOpened((opened) => !opened); + }, [setPopOverOpened]); + + const contextMenuPanels = useMemo(() => { + return [ + { + id: 0, + items: actions.map((action) => actionToMenuItem(action, onActionSelected, closePopover)), + }, + ]; + }, [actions, onActionSelected, closePopover]); + + return ( +
+ + + + + + + {selectedCount > 0 && ( + <> + +
+ + + + + + + + + } + > + + + + + )} + +
+ ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts index 8435aa0431c23..a28e3523d7af6 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts @@ -6,3 +6,4 @@ export { Header } from './header'; export { TagTable } from './table'; +export { ActionBar } from './action_bar'; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx index e86977c60ade1..ed1903fca2495 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useRef, useEffect, FC } from 'react'; -import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import React, { useRef, useEffect, FC, ReactNode } from 'react'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink, Query } from '@elastic/eui'; import { Action as EuiTableAction } from '@elastic/eui/src/components/basic_table/action_types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -16,12 +16,16 @@ interface TagTableProps { loading: boolean; capabilities: TagsCapabilities; tags: TagWithRelations[]; + initialQuery?: Query; + allowSelection: boolean; + onQueryChange: (query?: Query) => void; selectedTags: TagWithRelations[]; onSelectionChange: (selection: TagWithRelations[]) => void; onEdit: (tag: TagWithRelations) => void; onDelete: (tag: TagWithRelations) => void; getTagRelationUrl: (tag: TagWithRelations) => string; onShowRelations: (tag: TagWithRelations) => void; + actionBar: ReactNode; } const tablePagination = { @@ -43,11 +47,16 @@ export const TagTable: FC = ({ loading, capabilities, tags, + initialQuery, + allowSelection, + onQueryChange, selectedTags, + onSelectionChange, onEdit, onDelete, onShowRelations, getTagRelationUrl, + actionBar, }) => { const tableRef = useRef>(null); @@ -60,9 +69,11 @@ export const TagTable: FC = ({ const actions: Array> = []; if (capabilities.edit) { actions.push({ - name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', { - defaultMessage: 'Edit', - }), + name: ({ name }) => + i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', { + defaultMessage: 'Edit {name} tag', + values: { name }, + }), description: i18n.translate( 'xpack.savedObjectsTagging.management.table.actions.edit.description', { @@ -77,9 +88,11 @@ export const TagTable: FC = ({ } if (capabilities.delete) { actions.push({ - name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', { - defaultMessage: 'Delete', - }), + name: ({ name }) => + i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', { + defaultMessage: 'Delete {name} tag', + values: { name }, + }), description: i18n.translate( 'xpack.savedObjectsTagging.management.table.actions.delete.description', { @@ -171,13 +184,30 @@ export const TagTable: FC = ({ { + onQueryChange(query || undefined); + }, box: { 'data-test-subj': 'tagsManagementSearchBar', incremental: true, diff --git a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx index 4afb15bec6243..6b0e17a945c06 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx @@ -6,13 +6,15 @@ import React, { useEffect, useCallback, useState, useMemo, FC } from 'react'; import useMount from 'react-use/lib/useMount'; -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContent, Query } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ChromeBreadcrumb, CoreStart } from 'src/core/public'; import { TagWithRelations, TagsCapabilities } from '../../common'; import { getCreateModalOpener, getEditModalOpener } from '../components/edition_modal'; import { ITagInternalClient } from '../tags'; -import { Header, TagTable } from './components'; +import { TagBulkAction } from './types'; +import { Header, TagTable, ActionBar } from './components'; +import { getBulkActions } from './actions'; import { getTagConnectionsUrl } from './utils'; interface TagManagementPageParams { @@ -32,6 +34,21 @@ export const TagManagementPage: FC = ({ const [loading, setLoading] = useState(false); const [allTags, setAllTags] = useState([]); const [selectedTags, setSelectedTags] = useState([]); + const [query, setQuery] = useState(); + + const filteredTags = useMemo(() => { + return query ? Query.execute(query, allTags) : allTags; + }, [allTags, query]); + + const bulkActions = useMemo(() => { + return getBulkActions({ + core, + capabilities, + tagClient, + setLoading, + clearSelection: () => setSelectedTags([]), + }); + }, [core, capabilities, tagClient]); const createModalOpener = useMemo(() => getCreateModalOpener({ overlays, tagClient }), [ overlays, @@ -140,13 +157,12 @@ export const TagManagementPage: FC = ({ } ), buttonColor: 'danger', + maxWidth: 560, } ); if (confirmed) { await tagClient.delete(tag.id); - fetchTags(); - notifications.toasts.addSuccess({ title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', { defaultMessage: 'Deleted "{name}" tag', @@ -155,18 +171,59 @@ export const TagManagementPage: FC = ({ }, }), }); + + await fetchTags(); } }, [overlays, notifications, fetchTags, tagClient] ); + const executeBulkAction = useCallback( + async (action: TagBulkAction) => { + try { + await action.execute(selectedTags.map(({ id }) => id)); + } catch (e) { + notifications.toasts.addError(e, { + title: i18n.translate('xpack.savedObjectsTagging.notifications.bulkActionError', { + defaultMessage: 'An error occurred', + }), + }); + } finally { + setLoading(false); + } + if (action.refreshAfterExecute) { + await fetchTags(); + } + }, + [selectedTags, fetchTags, notifications] + ); + + const actionBar = useMemo( + () => ( + + ), + [selectedTags, filteredTags, bulkActions, executeBulkAction] + ); + return (
{ + setQuery(newQuery); + setSelectedTags([]); + }} + allowSelection={bulkActions.length > 0} selectedTags={selectedTags} onSelectionChange={(tags) => { setSelectedTags(tags); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/types.ts b/x-pack/plugins/saved_objects_tagging/public/management/types.ts new file mode 100644 index 0000000000000..fc15785142431 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/types.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 { EuiIconType } from '@elastic/eui/src/components/icon/icon'; + +/** + * Represents a tag `bulk action` + */ +export interface TagBulkAction { + /** + * The unique identifier for this action. + */ + id: string; + /** + * The label displayed in the bulk action context menu. + */ + label: string; + /** + * Optional aria-label if the visual label isn't descriptive enough. + */ + 'aria-label'?: string; + /** + * An optional icon to display before the label in the context menu. + */ + icon?: EuiIconType; + /** + * Handler to execute this action against the given list of selected tag ids. + */ + execute: (tagIds: string[]) => void | Promise; + /** + * If true, the list of tags will be reloaded after the action's execution. Defaults to false. + */ + refreshAfterExecute?: boolean; +} diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.mock.ts new file mode 100644 index 0000000000000..4ef0e89ae4866 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ITagInternalClient } from './tags_client'; + +const createInternalClientMock = () => { + const mock: jest.Mocked = { + create: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + find: jest.fn(), + bulkDelete: jest.fn(), + }; + + return mock; +}; + +export const tagClientMock = { + create: createInternalClientMock, +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts index ac73880e52949..576f89b796010 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts @@ -216,41 +216,83 @@ describe('TagsClient', () => { }); }); - ///// + describe('internal APIs', () => { + describe('#find', () => { + const findOptions: FindTagsOptions = { + search: 'for, you know.', + }; + let expectedTags: Tag[]; + + beforeEach(() => { + expectedTags = [ + createTag({ id: 'tag-1' }), + createTag({ id: 'tag-2' }), + createTag({ id: 'tag-3' }), + ]; + http.get.mockResolvedValue({ tags: expectedTags, total: expectedTags.length }); + }); - describe('#find', () => { - const findOptions: FindTagsOptions = { - search: 'for, you know.', - }; - let expectedTags: Tag[]; + it('calls `http.get` with the correct parameters', async () => { + await tagsClient.find(findOptions); - beforeEach(() => { - expectedTags = [ - createTag({ id: 'tag-1' }), - createTag({ id: 'tag-2' }), - createTag({ id: 'tag-3' }), - ]; - http.get.mockResolvedValue({ tags: expectedTags, total: expectedTags.length }); + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/internal/saved_objects_tagging/tags/_find`, { + query: findOptions, + }); + }); + it('returns the tag objects from the response', async () => { + const { tags, total } = await tagsClient.find(findOptions); + expect(tags).toEqual(expectedTags); + expect(total).toEqual(3); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.get.mockRejectedValue(error); + + await expect(tagsClient.find(findOptions)).rejects.toThrowError(error); + }); }); - it('calls `http.get` with the correct parameters', async () => { - await tagsClient.find(findOptions); + describe('#bulkDelete', () => { + const tagIds = ['id-to-delete-1', 'id-to-delete-2']; - expect(http.get).toHaveBeenCalledTimes(1); - expect(http.get).toHaveBeenCalledWith(`/internal/saved_objects_tagging/tags/_find`, { - query: findOptions, + beforeEach(() => { + http.post.mockResolvedValue({}); }); - }); - it('returns the tag objects from the response', async () => { - const { tags, total } = await tagsClient.find(findOptions); - expect(tags).toEqual(expectedTags); - expect(total).toEqual(3); - }); - it('forwards the error from the http call if any', async () => { - const error = new Error('something when wrong'); - http.get.mockRejectedValue(error); - await expect(tagsClient.find(findOptions)).rejects.toThrowError(error); + it('calls `http.post` with the correct parameters', async () => { + await tagsClient.bulkDelete(tagIds); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith( + `/internal/saved_objects_tagging/tags/_bulk_delete`, + { + body: JSON.stringify({ + ids: tagIds, + }), + } + ); + }); + it('forwards the error from the http call if any', async () => { + const error = new Error('something when wrong'); + http.post.mockRejectedValue(error); + + await expect(tagsClient.bulkDelete(tagIds)).rejects.toThrowError(error); + }); + it('notifies its changeListener if the http call succeed', async () => { + await tagsClient.bulkDelete(tagIds); + + expect(changeListener.onDelete).toHaveBeenCalledTimes(2); + expect(changeListener.onDelete).toHaveBeenCalledWith(tagIds[0]); + expect(changeListener.onDelete).toHaveBeenCalledWith(tagIds[1]); + }); + it('ignores potential errors when calling `changeListener.onDelete`', async () => { + changeListener.onDelete.mockImplementation(() => { + throw new Error('error in onCreate'); + }); + + await expect(tagsClient.bulkDelete(tagIds)).resolves.toBeUndefined(); + }); }); }); }); diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts index 3169babb2bae8..a866ae82f9702 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts @@ -34,6 +34,7 @@ const trapErrors = (fn: () => void) => { export interface ITagInternalClient extends ITagsClient { find(options: FindTagsOptions): Promise; + bulkDelete(ids: string[]): Promise; } export class TagsClient implements ITagInternalClient { @@ -114,4 +115,20 @@ export class TagsClient implements ITagInternalClient { }, }); } + + public async bulkDelete(tagIds: string[]) { + await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', { + body: JSON.stringify({ + ids: tagIds, + }), + }); + + trapErrors(() => { + if (this.changeListener) { + tagIds.forEach((tagId) => { + this.changeListener!.onDelete(tagId); + }); + } + }); + } } diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts index 9519f54e01693..facfb3f690a28 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts @@ -10,7 +10,7 @@ import { registerDeleteTagRoute } from './delete_tag'; import { registerGetAllTagsRoute } from './get_all_tags'; import { registerGetTagRoute } from './get_tag'; import { registerUpdateTagRoute } from './update_tag'; -import { registerInternalFindTagsRoute } from './internal'; +import { registerInternalFindTagsRoute, registerInternalBulkDeleteRoute } from './internal'; export const registerRoutes = ({ router }: { router: IRouter }) => { // public API @@ -21,4 +21,5 @@ export const registerRoutes = ({ router }: { router: IRouter }) => { registerGetTagRoute(router); // internal API registerInternalFindTagsRoute(router); + registerInternalBulkDeleteRoute(router); }; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/bulk_delete.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/bulk_delete.ts new file mode 100644 index 0000000000000..bade81678543d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/bulk_delete.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; + +export const registerInternalBulkDeleteRoute = (router: IRouter) => { + router.post( + { + path: '/internal/saved_objects_tagging/tags/_bulk_delete', + validate: { + body: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { ids: tagIds } = req.body; + const client = ctx.tags!.tagsClient; + + for (const tagId of tagIds) { + await client.delete(tagId); + } + + return res.ok({ + body: {}, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts index 9d427cfe5831c..e20403af1f59b 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts @@ -5,3 +5,4 @@ */ export { registerInternalFindTagsRoute } from './find_tags'; +export { registerInternalBulkDeleteRoute } from './bulk_delete'; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap index b316182f3b80d..8966acee95c5d 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap @@ -92,6 +92,31 @@ exports[`APIKeysGridPage renders a callout when API keys are not enabled 1`] = ` > docs + + + + + + + (opens in a new tab or window) + + + to enable API keys. @@ -119,7 +144,7 @@ exports[`APIKeysGridPage renders permission denied if user does not have require paddingSize="l" >
renders permission denied if required 1`] = ` paddingSize="l" >
MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security_solution/common/license/license.ts b/x-pack/plugins/security_solution/common/license/license.ts new file mode 100644 index 0000000000000..96c1a14ceb1f4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/license/license.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable, Subscription } from 'rxjs'; +import { ILicense } from '../../../licensing/common/types'; + +// Generic license service class that works with the license observable +// Both server and client plugins instancates a singleton version of this class +export class LicenseService { + private observable: Observable | null = null; + private subscription: Subscription | null = null; + private licenseInformation: ILicense | null = null; + + private updateInformation(licenseInformation: ILicense) { + this.licenseInformation = licenseInformation; + } + + public start(license$: Observable) { + this.observable = license$; + this.subscription = this.observable.subscribe(this.updateInformation.bind(this)); + } + + public stop() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + public getLicenseInformation() { + return this.licenseInformation; + } + + public getLicenseInformation$() { + return this.observable; + } + + public isGoldPlus() { + return ( + this.licenseInformation?.isAvailable && + this.licenseInformation?.isActive && + this.licenseInformation?.hasAtLeast('gold') + ); + } + public isPlatinumPlus() { + return ( + this.licenseInformation?.isAvailable && + this.licenseInformation?.isActive && + this.licenseInformation?.hasAtLeast('platinum') + ); + } + public isEnterprise() { + return ( + this.licenseInformation?.isAvailable && + this.licenseInformation?.isActive && + this.licenseInformation?.hasAtLeast('enterprise') + ); + } +} diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index bee2e54d0e3ea..fb457933f4b54 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -13,7 +13,7 @@ export { DefaultVersionNumberDecoded, } from './detection_engine/schemas/types/default_version_number'; export { exactCheck } from './exact_check'; -export { getPaths, foldLeftRight } from './test_utils'; +export { getPaths, foldLeftRight, removeExternalLinkText } from './test_utils'; export { validate, validateEither } from './validate'; export { formatErrors } from './format_errors'; export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11.0'; diff --git a/x-pack/plugins/security_solution/common/test_utils.ts b/x-pack/plugins/security_solution/common/test_utils.ts index b96639ad7b034..2cdce67f364dc 100644 --- a/x-pack/plugins/security_solution/common/test_utils.ts +++ b/x-pack/plugins/security_solution/common/test_utils.ts @@ -41,3 +41,9 @@ export const getPaths = (validation: t.Validation): string[] => { ) ); }; + +/** + * Convenience utility to remove text appended to links by EUI + */ +export const removeExternalLinkText = (str: string) => + str.replace(/\(opens in a new tab or window\)/g, ''); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index 783f8be840b7f..fb1f2920aaceb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -49,6 +49,7 @@ import { DEFINITION_DETAILS, FALSE_POSITIVES_DETAILS, getDetails, + removeExternalLinkText, INDEX_PATTERNS_DETAILS, INVESTIGATION_NOTES_MARKDOWN, INVESTIGATION_NOTES_TOGGLE, @@ -174,9 +175,13 @@ describe('Custom detection rules creation', () => { cy.get(ABOUT_DETAILS).within(() => { getDetails(SEVERITY_DETAILS).should('have.text', newRule.severity); getDetails(RISK_SCORE_DETAILS).should('have.text', newRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should('have.text', expectedUrls); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should('have.text', expectedMitre); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); getDetails(TAGS_DETAILS).should('have.text', expectedTags); }); cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts index 3d4aaca8bb78f..22d2a144932bf 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts @@ -32,6 +32,7 @@ import { DEFINITION_DETAILS, FALSE_POSITIVES_DETAILS, getDetails, + removeExternalLinkText, INDEX_PATTERNS_DETAILS, INVESTIGATION_NOTES_MARKDOWN, INVESTIGATION_NOTES_TOGGLE, @@ -136,9 +137,13 @@ describe('Detection rules, EQL', () => { cy.get(ABOUT_DETAILS).within(() => { getDetails(SEVERITY_DETAILS).should('have.text', eqlRule.severity); getDetails(RISK_SCORE_DETAILS).should('have.text', eqlRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should('have.text', expectedUrls); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should('have.text', expectedMitre); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); getDetails(TAGS_DETAILS).should('have.text', expectedTags); }); cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts index 153c55fae59fe..061b66faca054 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts @@ -23,6 +23,7 @@ import { DEFINITION_DETAILS, FALSE_POSITIVES_DETAILS, getDetails, + removeExternalLinkText, MACHINE_LEARNING_JOB_ID, MACHINE_LEARNING_JOB_STATUS, MITRE_ATTACK_DETAILS, @@ -122,9 +123,13 @@ describe('Detection rules, machine learning', () => { cy.get(ABOUT_DETAILS).within(() => { getDetails(SEVERITY_DETAILS).should('have.text', machineLearningRule.severity); getDetails(RISK_SCORE_DETAILS).should('have.text', machineLearningRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should('have.text', expectedUrls); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should('have.text', expectedMitre); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); getDetails(TAGS_DETAILS).should('have.text', expectedTags); }); cy.get(DEFINITION_DETAILS).within(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts index e905365d1bbb3..b1ccca5e4f13c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts @@ -34,6 +34,7 @@ import { DETAILS_TITLE, FALSE_POSITIVES_DETAILS, getDetails, + removeExternalLinkText, INDEX_PATTERNS_DETAILS, INVESTIGATION_NOTES_MARKDOWN, INVESTIGATION_NOTES_TOGGLE, @@ -141,9 +142,13 @@ describe('Detection rules, override', () => { `${newOverrideRule.riskOverride}signal.rule.risk_score` ); getDetails(RULE_NAME_OVERRIDE_DETAILS).should('have.text', newOverrideRule.nameOverride); - getDetails(REFERENCE_URLS_DETAILS).should('have.text', expectedUrls); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should('have.text', expectedMitre); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); getDetails(TAGS_DETAILS).should('have.text', expectedTags); getDetails(TIMESTAMP_OVERRIDE_DETAILS).should('have.text', newOverrideRule.timestampOverride); cy.contains(DETAILS_TITLE, 'Severity override') diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts index a9b43d82bb7fd..c3e7892d63279 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts @@ -32,6 +32,7 @@ import { FALSE_POSITIVES_DETAILS, DEFINITION_DETAILS, getDetails, + removeExternalLinkText, INDEX_PATTERNS_DETAILS, INVESTIGATION_NOTES_MARKDOWN, INVESTIGATION_NOTES_TOGGLE, @@ -135,9 +136,13 @@ describe('Detection rules, threshold', () => { cy.get(ABOUT_DETAILS).within(() => { getDetails(SEVERITY_DETAILS).should('have.text', newThresholdRule.severity); getDetails(RISK_SCORE_DETAILS).should('have.text', newThresholdRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should('have.text', expectedUrls); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should('have.text', expectedMitre); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); getDetails(TAGS_DETAILS).should('have.text', expectedTags); }); cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index d72210dd3e083..8cf0dfb5f6661 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -75,3 +75,6 @@ export const TIMESTAMP_OVERRIDE_DETAILS = 'Timestamp override'; export const getDetails = (title: string) => cy.get(DETAILS_TITLE).contains(title).next(DETAILS_DESCRIPTION); + +export const removeExternalLinkText = (str: string) => + str.replace(/\(opens in a new tab or window\)/g, ''); diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx index 2ae5b1d20c3c3..ed7ec77b4f39b 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx @@ -6,6 +6,7 @@ import { mount, shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { mountWithIntl } from '@kbn/test/jest'; import { encodeIpv6 } from '../../lib/helpers'; @@ -92,7 +93,7 @@ describe('Custom Links', () => { const wrapper = mountWithIntl( {'Example Link'} ); - expect(wrapper.text()).toEqual('Example Link'); + expect(removeExternalLinkText(wrapper.text())).toEqual('Example Link'); }); test('it renders props passed in as link', () => { @@ -448,7 +449,7 @@ describe('Custom Links', () => { describe('WhoisLink', () => { test('it renders ip passed in as domain', () => { const wrapper = mountWithIntl({'Example Link'}); - expect(wrapper.text()).toEqual('Example Link'); + expect(removeExternalLinkText(wrapper.text())).toEqual('Example Link'); }); test('it renders correct href', () => { @@ -473,7 +474,7 @@ describe('Custom Links', () => { {'Example Link'} ); - expect(wrapper.text()).toEqual('Example Link'); + expect(removeExternalLinkText(wrapper.text())).toEqual('Example Link'); }); test('it renders correct href', () => { @@ -504,7 +505,7 @@ describe('Custom Links', () => { const wrapper = mountWithIntl( {'Example Link'} ); - expect(wrapper.text()).toEqual('Example Link'); + expect(removeExternalLinkText(wrapper.text())).toEqual('Example Link'); }); test('it renders correct href', () => { @@ -533,7 +534,7 @@ describe('Custom Links', () => { const wrapper = mountWithIntl( {'Example Link'} ); - expect(wrapper.text()).toEqual('Example Link'); + expect(removeExternalLinkText(wrapper.text())).toEqual('Example Link'); }); test('it renders correct href when port is a number', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx index e6a38863d7e5f..8bb652816a975 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { MarkdownRenderer } from './renderer'; describe('Markdown', () => { @@ -16,9 +17,9 @@ describe('Markdown', () => { test('it renders the expected link text', () => { const wrapper = mount({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().text()).toEqual( - 'External Site' - ); + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text()) + ).toEqual('External Site'); }); test('it renders the expected href', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx index b0965f8708558..90ab5c2f888fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx @@ -55,7 +55,9 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual('/app/ml/jobs?_a=(queryText:linux_anomalous_network_activity_ecs)') + expect(href).toEqual( + "/app/ml/jobs?_a=(jobs:(queryText:'id:linux_anomalous_network_activity_ecs'))" + ) ); }); @@ -72,7 +74,7 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual("/app/ml/jobs?_a=(queryText:'job%20id%20with%20spaces')") + expect(href).toEqual("/app/ml/jobs?_a=(jobs:(queryText:'id:job%20id%20with%20spaces'))") ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index 6331a2e02b219..6d114258224d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -39,6 +39,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiBorderColor": "#343741", "euiBorderEditable": "2px dotted #343741", "euiBorderRadius": "4px", + "euiBorderRadiusSmall": "2px", "euiBorderThick": "2px solid #343741", "euiBorderThin": "1px solid #343741", "euiBorderWidthThick": "2px", @@ -375,6 +376,15 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, }, "euiPaletteColorBlindKeys": "'euiColorVis0', 'euiColorVis1', 'euiColorVis2', 'euiColorVis3', 'euiColorVis4', 'euiColorVis5', 'euiColorVis6', 'euiColorVis7', 'euiColorVis8', 'euiColorVis9'", + "euiPanelBackgroundColorModifiers": Object { + "plain": "#1d1e24", + "subdued": "#25262e", + "transparent": "rgba(0, 0, 0, 0)", + }, + "euiPanelBorderRadiusModifiers": Object { + "borderRadiusMedium": "4px", + "borderRadiusNone": 0, + }, "euiPanelPaddingModifiers": Object { "paddingLarge": "24px", "paddingMedium": "16px", diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_license.ts b/x-pack/plugins/security_solution/public/common/hooks/use_license.ts new file mode 100644 index 0000000000000..db4d588bf293f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_license.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LicenseService } from '../../../common/license/license'; + +export const licenseService = new LicenseService(); + +export function useLicense() { + return licenseService; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index abc9a2cbd027c..bfa592b1f9c8e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -13,8 +13,20 @@ import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_da import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { getPolicyDetailPath, getEndpointListPath } from '../../../common/routing'; import { policyListApiPathHandlers } from '../store/policy_list/test_mock_utils'; +import { licenseService } from '../../../../common/hooks/use_license'; jest.mock('../../../../common/components/link_to'); +jest.mock('../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); describe('Policy Details', () => { type FindReactWrapperResponse = ReturnType['find']>; @@ -275,5 +287,40 @@ describe('Policy Details', () => { }); }); }); + describe('when the subscription tier is platinum or higher', () => { + beforeEach(() => { + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); + policyView = render(); + }); + + it('malware popup and message customization options are shown', () => { + // use query for finding stuff, if it doesn't find it, just returns null + const userNotificationCheckbox = policyView.find( + 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' + ); + const userNotificationCustomMessageTextArea = policyView.find( + 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' + ); + expect(userNotificationCheckbox).toHaveLength(1); + expect(userNotificationCustomMessageTextArea).toHaveLength(1); + }); + }); + describe('when the subscription tier is gold or lower', () => { + beforeEach(() => { + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + policyView = render(); + }); + + it('malware popup and message customization options are hidden', () => { + const userNotificationCheckbox = policyView.find( + 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' + ); + const userNotificationCustomMessageTextArea = policyView.find( + 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' + ); + expect(userNotificationCheckbox).toHaveLength(0); + expect(userNotificationCustomMessageTextArea).toHaveLength(0); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 7259b2ec19ee2..c72093552f551 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -30,6 +30,7 @@ import { policyConfig } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; import { popupVersionsMap } from './popup_options_to_versions'; +import { useLicense } from '../../../../../../common/hooks/use_license'; const ProtectionRadioGroup = styled.div` display: flex; @@ -116,6 +117,7 @@ export const MalwareProtections = React.memo(() => { policyDetailsConfig && policyDetailsConfig.windows.popup.malware.enabled; const userNotificationMessage = policyDetailsConfig && policyDetailsConfig.windows.popup.malware.message; + const isPlatinumPlus = useLicense().isPlatinumPlus(); const radios: Immutable { ); })} - - - - - - - - {userNotificationSelected && ( + {isPlatinumPlus && ( + <> + + + + + + + + )} + {isPlatinumPlus && userNotificationSelected && ( <> @@ -256,6 +265,7 @@ export const MalwareProtections = React.memo(() => { value={userNotificationMessage} onChange={handleCustomUserNotification} fullWidth={true} + data-test-subj="malwareUserNotificationCustomMessage" /> )} @@ -263,6 +273,7 @@ export const MalwareProtections = React.memo(() => { ); }, [ radios, + isPlatinumPlus, handleUserNotificationCheckbox, userNotificationSelected, userNotificationMessage, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index 6d45059099f8d..71b103949a80a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -121,7 +121,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" >
{ ); - expect(wrapper.find('[data-test-subj="port"]').first().text()).toEqual('443'); + expect(removeExternalLinkText(wrapper.find('[data-test-subj="port"]').first().text())).toEqual( + '443' + ); }); test('it hyperlinks links destination.port to an external service that describes the purpose of the port', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx index 2ba5cd868c2b8..2b0231999e6ee 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx @@ -9,6 +9,7 @@ import { shallow } from 'enzyme'; import { get } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { asArrayIfExists } from '../../../common/lib/helpers'; import { getMockNetflowData } from '../../../common/mock'; import '../../../common/mock/match_media'; @@ -189,9 +190,11 @@ describe('SourceDestination', () => { test('it renders the destination ip and port, separated with a colon', () => { const wrapper = mount({getSourceDestinationInstance()}); - expect(wrapper.find('[data-test-subj="destination-ip-and-port"]').first().text()).toEqual( - '10.1.2.3:80' - ); + expect( + removeExternalLinkText( + wrapper.find('[data-test-subj="destination-ip-and-port"]').first().text() + ) + ).toEqual('10.1.2.3:80'); }); test('it renders destination.packets', () => { @@ -313,9 +316,9 @@ describe('SourceDestination', () => { test('it renders the source ip and port, separated with a colon', () => { const wrapper = mount({getSourceDestinationInstance()}); - expect(wrapper.find('[data-test-subj="source-ip-and-port"]').first().text()).toEqual( - '192.168.1.2:9987' - ); + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="source-ip-and-port"]').first().text()) + ).toEqual('192.168.1.2:9987'); }); test('it renders source.packets', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx index 890add4222503..78f71a84d0b19 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx @@ -7,6 +7,7 @@ import { get } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { asArrayIfExists } from '../../../common/lib/helpers'; import { getMockNetflowData } from '../../../common/mock'; import '../../../common/mock/match_media'; @@ -976,9 +977,11 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.port"]').first().text()).toEqual( - '9987' - ); + expect( + removeExternalLinkText( + wrapper.find('[data-test-subj="draggable-content-source.port"]').first().text() + ) + ).toEqual('9987'); }); test('it renders the expected destination port when type is `destination`, and both destinationIp and destinationPort are populated', () => { @@ -1028,7 +1031,9 @@ describe('SourceDestinationIp', () => { ); expect( - wrapper.find('[data-test-subj="draggable-content-destination.port"]').first().text() + removeExternalLinkText( + wrapper.find('[data-test-subj="draggable-content-destination.port"]').first().text() + ) ).toEqual('80'); }); @@ -1078,9 +1083,11 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.port"]').first().text()).toEqual( - '9987' - ); + expect( + removeExternalLinkText( + wrapper.find('[data-test-subj="draggable-content-source.port"]').first().text() + ) + ).toEqual('9987'); }); test('it renders the expected destination port when type is `destination`, and only destinationPort is populated', () => { @@ -1131,7 +1138,9 @@ describe('SourceDestinationIp', () => { ); expect( - wrapper.find('[data-test-subj="draggable-content-destination.port"]').first().text() + removeExternalLinkText( + wrapper.find('[data-test-subj="draggable-content-destination.port"]').first().text() + ) ).toEqual('80'); }); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 5895880adb26a..5cc0d79a3f9a3 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -60,6 +60,7 @@ import { } from '../common/search_strategy/index_fields'; import { SecurityAppStore } from './common/store/store'; import { getCaseConnectorUI } from './common/lib/connectors'; +import { licenseService } from './common/hooks/use_license'; import { LazyEndpointPolicyEditExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_edit_extension'; import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension'; @@ -345,6 +346,7 @@ export class Plugin implements IPlugin { /> ); - expect(wrapper.find('[data-test-subj="certificate-fingerprint-link"]').first().text()).toEqual( - '3f4c57934e089f02ae7511200aee2d7e7aabd272' - ); + expect( + removeExternalLinkText( + wrapper.find('[data-test-subj="certificate-fingerprint-link"]').first().text() + ) + ).toEqual('3f4c57934e089f02ae7511200aee2d7e7aabd272'); }); test('it renders a hyperlink to an external site to compare the fingerprint against a known set of signatures', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx index 899a6d7486f94..c57546d5cd9aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { TestProviders } from '../../../common/mock'; import '../../../common/mock/match_media'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -42,9 +43,9 @@ describe('Ja3Fingerprint', () => { ); - expect(wrapper.find('[data-test-subj="ja3-fingerprint-link"]').first().text()).toEqual( - 'fff799d91b7c01ae3fe6787cfc895552' - ); + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="ja3-fingerprint-link"]').first().text()) + ).toEqual('fff799d91b7c01ae3fe6787cfc895552'); }); test('it renders a hyperlink to an external site to compare the fingerprint against a known set of signatures', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx index c2026a71ac6ff..9aa462ee23a8b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx @@ -8,6 +8,7 @@ import { get } from 'lodash/fp'; import React from 'react'; import { shallow } from 'enzyme'; +import { removeExternalLinkText } from '../../../../common/test_utils'; import { asArrayIfExists } from '../../../common/lib/helpers'; import { getMockNetflowData } from '../../../common/mock'; import '../../../common/mock/match_media'; @@ -188,9 +189,11 @@ describe('Netflow', () => { test('it renders the destination ip and port, separated with a colon', () => { const wrapper = mount({getNetflowInstance()}); - expect(wrapper.find('[data-test-subj="destination-ip-and-port"]').first().text()).toEqual( - '10.1.2.3:80' - ); + expect( + removeExternalLinkText( + wrapper.find('[data-test-subj="destination-ip-and-port"]').first().text() + ) + ).toEqual('10.1.2.3:80'); }); test('it renders destination.packets', () => { @@ -324,9 +327,9 @@ describe('Netflow', () => { test('it renders the source ip and port, separated with a colon', () => { const wrapper = mount({getNetflowInstance()}); - expect(wrapper.find('[data-test-subj="source-ip-and-port"]').first().text()).toEqual( - '192.168.1.2:9987' - ); + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="source-ip-and-port"]').first().text()) + ).toEqual('192.168.1.2:9987'); }); test('it renders source.packets', () => { @@ -353,11 +356,13 @@ describe('Netflow', () => { const wrapper = mount({getNetflowInstance()}); expect( - wrapper - .find('[data-test-subj="client-certificate-fingerprint"]') - .find('[data-test-subj="certificate-fingerprint-link"]') - .first() - .text() + removeExternalLinkText( + wrapper + .find('[data-test-subj="client-certificate-fingerprint"]') + .find('[data-test-subj="certificate-fingerprint-link"]') + .first() + .text() + ) ).toEqual('tls.client_certificate.fingerprint.sha1-value'); }); @@ -372,9 +377,9 @@ describe('Netflow', () => { test('renders tls.fingerprints.ja3.hash text', () => { const wrapper = mount({getNetflowInstance()}); - expect(wrapper.find('[data-test-subj="ja3-fingerprint-link"]').first().text()).toEqual( - 'tls.fingerprints.ja3.hash-value' - ); + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="ja3-fingerprint-link"]').first().text()) + ).toEqual('tls.fingerprints.ja3.hash-value'); }); test('it hyperlinks tls.server_certificate.fingerprint.sha1 site to compare the fingerprint against a known set of signatures', () => { @@ -395,11 +400,13 @@ describe('Netflow', () => { const wrapper = mount({getNetflowInstance()}); expect( - wrapper - .find('[data-test-subj="server-certificate-fingerprint"]') - .find('[data-test-subj="certificate-fingerprint-link"]') - .first() - .text() + removeExternalLinkText( + wrapper + .find('[data-test-subj="server-certificate-fingerprint"]') + .find('[data-test-subj="certificate-fingerprint-link"]') + .first() + .text() + ) ).toEqual('tls.server_certificate.fingerprint.sha1-value'); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 17c614bd2c83c..03dc2afc625cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -39,6 +39,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiBorderColor": "#343741", "euiBorderEditable": "2px dotted #343741", "euiBorderRadius": "4px", + "euiBorderRadiusSmall": "2px", "euiBorderThick": "2px solid #343741", "euiBorderThin": "1px solid #343741", "euiBorderWidthThick": "2px", @@ -375,6 +376,15 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` }, }, "euiPaletteColorBlindKeys": "'euiColorVis0', 'euiColorVis1', 'euiColorVis2', 'euiColorVis3', 'euiColorVis4', 'euiColorVis5', 'euiColorVis6', 'euiColorVis7', 'euiColorVis8', 'euiColorVis9'", + "euiPanelBackgroundColorModifiers": Object { + "plain": "#1d1e24", + "subdued": "#25262e", + "transparent": "rgba(0, 0, 0, 0)", + }, + "euiPanelBorderRadiusModifiers": Object { + "borderRadiusMedium": "4px", + "borderRadiusNone": 0, + }, "euiPanelPaddingModifiers": Object { "paddingLarge": "24px", "paddingMedium": "16px", diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx index 52b9b9a31fc99..bb61821d31315 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../common/test_utils'; import '../../../../../common/mock/match_media'; import { mockBrowserFields } from '../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../common/ecs'; @@ -75,7 +76,7 @@ describe('get_column_renderer', () => { {row} ); - expect(wrapper.text()).toContain( + expect(removeExternalLinkText(wrapper.text())).toContain( '4ETEXPLOITNETGEARWNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)Source192.168.0.3:53Destination192.168.0.3:6343' ); }); @@ -93,7 +94,7 @@ describe('get_column_renderer', () => { {row} ); - expect(wrapper.text()).toContain( + expect(removeExternalLinkText(wrapper.text())).toContain( '4ETEXPLOITNETGEARWNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)Source192.168.0.3:53Destination192.168.0.3:6343' ); }); @@ -111,7 +112,7 @@ describe('get_column_renderer', () => { {row} ); - expect(wrapper.text()).toContain( + expect(removeExternalLinkText(wrapper.text())).toContain( 'C8DRTq362Fios6hw16connectionREJSrConnection attempt rejectedtcpSource185.176.26.101:44059Destination207.154.238.205:11568' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx index 3b9752224e2c1..7ef034994ce5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData } from '../../../../../../common/mock'; import '../../../../../../common/mock/match_media'; @@ -41,7 +42,7 @@ describe('SuricataDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( '4ETEXPLOITNETGEARWNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)Source192.168.0.3:53Destination192.168.0.3:6343' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx index bed2171715380..674f922bf54e6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../../common/ecs'; import { mockTimelineData } from '../../../../../../common/mock'; @@ -58,7 +59,7 @@ describe('suricata_row_renderer', () => { {children} ); - expect(wrapper.text()).toContain( + expect(removeExternalLinkText(wrapper.text())).toContain( '4ETEXPLOITNETGEARWNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)Source192.168.0.3:53Destination192.168.0.3:6343' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx index 6c2e9ad7535a1..45381bbc99935 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../../common/ecs'; @@ -541,7 +542,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'SYSTEM\\NT AUTHORITY@HD-gqf-0af7b4feaccepted a connection viaAmSvc.exe(1084)tcp1:network-community_idSource127.0.0.1:49306Destination127.0.0.1:49305' ); }); @@ -569,7 +570,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'SYSTEM\\NT AUTHORITY@HD-55b-3ec87f66accepted a connection via(4)tcp1:network-community_idSource::1:51324Destination::1:5357' ); }); @@ -597,7 +598,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'Arun\\Anvi-Acer@HD-obe-8bf77f54disconnected viachrome.exe(11620)8.1KBtcp1:LxYHJJv98b2O0fNccXu6HheXmwk=Source192.168.0.6:59356(25.78%)2.1KB(74.22%)6KBDestination10.156.162.53:443' ); }); @@ -625,7 +626,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'SYSTEM\\NT AUTHORITY@HD-55b-3ec87f66disconnected via(4)7.9KBtcp1:ZylzQhsB1dcptA2t4DY8S6l9o8E=Source::1:51338(96.92%)7.7KB(3.08%)249BDestination::1:2869' ); }); @@ -653,7 +654,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'root@foohostopened a socket withgoogle_accounts(2166)Outbound socket (10.4.20.1:59554 -> 10.1.2.3:80) Ooutboundtcp1:network-community_idSource10.4.20.1:59554Destination10.1.2.3:80' ); }); @@ -681,7 +682,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'root@foohostclosed a socket withgoogle_accounts(2166)Outbound socket (10.4.20.1:59508 -> 10.1.2.3:80) Coutboundtcp1:network-community_idSource10.4.20.1:59508Destination10.1.2.3:80' ); }); @@ -875,7 +876,7 @@ describe('GenericRowRenderer', () => { ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'iot.example.comasked forlookup.example.comwith question typeA, which resolved to10.1.2.3(response code:NOERROR)viaan unknown process6.937500msOct 8, 2019 @ 10:05:23.241Oct 8, 2019 @ 10:05:23.248outbounddns177Budp1:network-community_idSource10.9.9.9:58732(22.60%)40B(77.40%)137BDestination10.1.1.1:53OceaniaAustralia🇦🇺AU' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx index 434be7b23aeee..b980f723b5c0c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import '../../../../../../common/mock/match_media'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; @@ -41,7 +42,7 @@ describe('ZeekDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'C8DRTq362Fios6hw16connectionREJSrConnection attempt rejectedtcpSource185.176.26.101:44059Destination207.154.238.205:11568' ); }); @@ -56,7 +57,7 @@ describe('ZeekDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'CyIrMA1L1JtLqdIuoldnsudpSource206.189.35.240:57475Destination67.207.67.3:53' ); }); @@ -71,7 +72,7 @@ describe('ZeekDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'CZLkpC22NquQJOpkwehttp302Source206.189.35.240:36220Destination192.241.164.26:80' ); }); @@ -86,7 +87,7 @@ describe('ZeekDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'noticeDropped:falseScan::Port_Scan8.42.77.171 scanned at least 15 unique ports of host 207.154.238.205 in 0m0sSource8.42.77.171' ); }); @@ -101,7 +102,7 @@ describe('ZeekDetails', () => { /> ); - expect(wrapper.text()).toEqual( + expect(removeExternalLinkText(wrapper.text())).toEqual( 'CmTxzt2OVXZLkGDaResslTLSv12TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256Source188.166.66.184:34514Destination91.189.95.15:443' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx index 9c08fbacafcdc..043bde1c10698 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../../common/ecs'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; @@ -57,7 +58,7 @@ describe('zeek_row_renderer', () => { {children} ); - expect(wrapper.text()).toContain( + expect(removeExternalLinkText(wrapper.text())).toContain( 'C8DRTq362Fios6hw16connectionREJSrConnection attempt rejectedtcpSource185.176.26.101:44059Destination207.154.238.205:11568' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx index efa22cb2c5617..f148ac5420b06 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { removeExternalLinkText } from '../../../../../../../common/test_utils'; import '../../../../../../common/mock/match_media'; import { Ecs } from '../../../../../../../common/ecs'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; @@ -89,7 +90,7 @@ describe('ZeekSignature', () => { test('should render value', () => { const wrapper = mount(); - expect(wrapper.text()).toEqual('abc'); + expect(removeExternalLinkText(wrapper.text())).toEqual('abc'); }); test('should render value and link', () => { diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 5e8400c574235..09f5765fefd4c 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -33,6 +33,7 @@ import { Network } from './network'; import { Overview } from './overview'; import { Timelines } from './timelines'; import { Management } from './management'; +import { LicensingPluginStart } from '../../licensing/public'; export interface SetupPlugins { home?: HomePublicPluginSetup; @@ -49,6 +50,7 @@ export interface StartPlugins { inspector: InspectorStart; ingestManager?: IngestManagerStart; lists?: ListsPluginStart; + licensing: LicensingPluginStart; newsfeed?: NewsfeedPublicPluginStart; triggersActionsUi: TriggersActionsStart; uiActions: UiActionsStart; diff --git a/x-pack/plugins/security_solution/server/lib/license/license.ts b/x-pack/plugins/security_solution/server/lib/license/license.ts new file mode 100644 index 0000000000000..5ae8ca48e8357 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/license/license.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LicenseService } from '../../../common/license/license'; + +export const licenseService = new LicenseService(); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index d8faa2436bf55..036c94cf50050 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -33,7 +33,7 @@ import { MlPluginSetup as MlSetup } from '../../ml/server'; import { ListPluginSetup } from '../../lists/server'; import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../encrypted_saved_objects/server'; import { SpacesPluginSetup as SpacesSetup } from '../../spaces/server'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { ILicense, LicensingPluginStart } from '../../licensing/server'; import { IngestManagerStartContract, ExternalCallback } from '../../fleet/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { initServer } from './init_server'; @@ -74,13 +74,13 @@ import { TelemetryPluginStart, TelemetryPluginSetup, } from '../../../../src/plugins/telemetry/server'; +import { licenseService } from './lib/license/license'; export interface SetupPlugins { alerts: AlertingSetup; data: DataPluginSetup; encryptedSavedObjects?: EncryptedSavedObjectsSetup; features: FeaturesSetup; - licensing: LicensingPluginSetup; lists?: ListPluginSetup; ml?: MlSetup; security?: SecuritySetup; @@ -94,6 +94,7 @@ export interface StartPlugins { alerts: AlertPluginStartContract; data: DataPluginStart; ingestManager?: IngestManagerStartContract; + licensing: LicensingPluginStart; taskManager?: TaskManagerStartContract; telemetry?: TelemetryPluginStart; } @@ -125,6 +126,7 @@ export class Plugin implements IPlugin; private manifestTask: ManifestTask | undefined; private exceptionsCache: LRU; @@ -364,6 +366,8 @@ export class Plugin implements IPlugin new MockUsageCollector(options), }, }; @@ -77,23 +77,23 @@ const getMockFetchContext = (mockedCallCluster: jest.Mock) => { describe('error handling', () => { it('handles a 404 when searching for space usage', async () => { - const { features, licensing, usageCollecion } = setup({ + const { features, licensing, usageCollection } = setup({ license: { isAvailable: true, type: 'basic' }, }); - const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, { + const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }), features, licensing, }); - await getSpacesUsage(getMockFetchContext(jest.fn().mockRejectedValue({ status: 404 }))); + await collector.fetch(getMockFetchContext(jest.fn().mockRejectedValue({ status: 404 }))); }); it('throws error for a non-404', async () => { - const { features, licensing, usageCollecion } = setup({ + const { features, licensing, usageCollection } = setup({ license: { isAvailable: true, type: 'basic' }, }); - const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, { + const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }), features, licensing, @@ -103,7 +103,7 @@ describe('error handling', () => { for (const statusCode of statusCodes) { const error = { status: statusCode }; await expect( - getSpacesUsage(getMockFetchContext(jest.fn().mockRejectedValue(error))) + collector.fetch(getMockFetchContext(jest.fn().mockRejectedValue(error))) ).rejects.toBe(error); } }); @@ -112,15 +112,15 @@ describe('error handling', () => { describe('with a basic license', () => { let usageStats: UsageStats; beforeAll(async () => { - const { features, licensing, usageCollecion } = setup({ + const { features, licensing, usageCollection } = setup({ license: { isAvailable: true, type: 'basic' }, }); - const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, { + const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, }); - usageStats = await getSpacesUsage(getMockFetchContext(defaultCallClusterMock)); + usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); expect(defaultCallClusterMock).toHaveBeenCalledWith('search', { body: { @@ -162,13 +162,13 @@ describe('with a basic license', () => { describe('with no license', () => { let usageStats: UsageStats; beforeAll(async () => { - const { features, licensing, usageCollecion } = setup({ license: { isAvailable: false } }); - const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, { + const { features, licensing, usageCollection } = setup({ license: { isAvailable: false } }); + const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, }); - usageStats = await getSpacesUsage(getMockFetchContext(defaultCallClusterMock)); + usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); }); test('sets enabled to false', () => { @@ -191,15 +191,15 @@ describe('with no license', () => { describe('with platinum license', () => { let usageStats: UsageStats; beforeAll(async () => { - const { features, licensing, usageCollecion } = setup({ + const { features, licensing, usageCollection } = setup({ license: { isAvailable: true, type: 'platinum' }, }); - const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, { + const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, }); - usageStats = await getSpacesUsage(getMockFetchContext(defaultCallClusterMock)); + usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); }); test('sets enabled to true', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts index 394ee8d606abe..e223cdb7ea545 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts @@ -38,7 +38,7 @@ export function transformResults( return _.map(subBuckets, (subBucket) => { const locationFieldResult = _.get( subBucket, - `entityHits.hits.hits[0].fields.${geoField}[0]`, + `entityHits.hits.hits[0].fields["${geoField}"][0]`, '' ); const location = locationFieldResult @@ -50,7 +50,7 @@ export function transformResults( : null; const dateInShape = _.get( subBucket, - `entityHits.hits.hits[0].fields.${dateField}[0]`, + `entityHits.hits.hits[0].fields["${dateField}"][0]`, null ); const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json index 1281777c03761..70edbd09aa5a1 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json @@ -13,48 +13,7 @@ "relation" : "gte" }, "max_score" : 0.0, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "XOng1XQB6yyY-xQxbwWM", - "_score" : 0.0, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:29.580Z" - ] - } - }, - { - "_index" : "flight_tracks", - "_id" : "Xeng1XQB6yyY-xQxbwWM", - "_score" : 0.0, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:29.580Z" - ] - } - }, - { - "_index" : "flight_tracks", - "_id" : "Xung1XQB6yyY-xQxbwWM", - "_score" : 0.0, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:29.580Z" - ] - } - }, - { - "_index" : "flight_tracks", - "_id" : "UOjg1XQB6yyY-xQxZvMz", - "_score" : 0.0, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:27.266Z" - ] - } - } - ] + "hits" : [] }, "aggregations" : { "shapes" : { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json new file mode 100644 index 0000000000000..a4b7b6872b341 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json @@ -0,0 +1,170 @@ +{ + "took" : 2760, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 10000, + "relation" : "gte" + }, + "max_score" : 0.0, + "hits" : [] + }, + "aggregations" : { + "shapes" : { + "meta" : { }, + "buckets" : { + "0DrJu3QB6yyY-xQxv6Ip" : { + "doc_count" : 1047, + "entitySplit" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 957, + "buckets" : [ + { + "key" : "936", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "N-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.190Z" + ], + "geo.coords.location" : [ + "40.62806099653244, -82.8814151789993" + ], + "entity_id" : [ + "936" + ] + }, + "sort" : [ + 1601316101190 + ] + } + ] + } + } + }, + { + "key" : "AAL2019", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "iOng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "geo.coords.location" : [ + "39.006176185794175, -82.22068064846098" + ], + "entity_id" : [ + "AAL2019" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "AAL2323", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "n-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "geo.coords.location" : [ + "41.6677269525826, -84.71324851736426" + ], + "entity_id" : [ + "AAL2323" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "ABD5250", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "GOng1XQB6yyY-xQxnGWM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.192Z" + ], + "geo.coords.location" : [ + "39.07997465226799, 6.073727197945118" + ], + "entity_id" : [ + "ABD5250" + ] + }, + "sort" : [ + 1601316101192 + ] + } + ] + } + } + } + ] + } + } + } + } + } +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts index 0aaf30ab2f3fb..e4cee9c677713 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts @@ -5,6 +5,7 @@ */ import sampleJsonResponse from './es_sample_response.json'; +import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; import { getMovedEntities, transformResults } from '../geo_threshold'; import { OTHER_CATEGORY } from '../es_query_builder'; import { SearchResponse } from 'elasticsearch'; @@ -51,6 +52,46 @@ describe('geo_threshold', () => { ]); }); + const nestedDateField = 'time_data.@timestamp'; + const nestedGeoField = 'geo.coords.location'; + it('should correctly transform expected results if fields are nested', async () => { + const transformedResults = transformResults( + (sampleJsonResponseWithNesting as unknown) as SearchResponse, + nestedDateField, + nestedGeoField + ); + expect(transformedResults).toEqual([ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + entityName: 'AAL2019', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + entityName: 'AAL2323', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + entityName: 'ABD5250', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ]); + }); + it('should return an empty array if no results', async () => { const transformedResults = transformResults(undefined, dateField, geoField); expect(transformedResults).toEqual([]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx index a7de73c9aab29..a51765fc3a720 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx @@ -89,10 +89,12 @@ describe('health check', () => { const [description, action] = queryAllByText(/TLS/i); expect(description.textContent).toMatchInlineSnapshot( - `"Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. Learn how to enable TLS."` + `"Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. Learn how to enable TLS.(opens in a new tab or window)"` ); - expect(action.textContent).toMatchInlineSnapshot(`"Learn how to enable TLS."`); + expect(action.textContent).toMatchInlineSnapshot( + `"Learn how to enable TLS.(opens in a new tab or window)"` + ); expect(action.getAttribute('href')).toMatchInlineSnapshot( `"elastic.co/guide/en/kibana/current/configuring-tls.html"` @@ -118,11 +120,11 @@ describe('health check', () => { const description = queryByRole(/banner/i); expect(description!.textContent).toMatchInlineSnapshot( - `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file. Learn how."` + `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file. Learn how.(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); - expect(action!.textContent).toMatchInlineSnapshot(`"Learn how."`); + expect(action!.textContent).toMatchInlineSnapshot(`"Learn how.(opens in a new tab or window)"`); expect(action!.getAttribute('href')).toMatchInlineSnapshot( `"elastic.co/guide/en/kibana/current/alert-action-settings-kb.html#general-alert-action-settings"` ); @@ -148,11 +150,11 @@ describe('health check', () => { const description = queryByText(/Transport Layer Security/i); expect(description!.textContent).toMatchInlineSnapshot( - `"You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. Learn how"` + `"You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. Learn how(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); - expect(action!.textContent).toMatchInlineSnapshot(`"Learn how"`); + expect(action!.textContent).toMatchInlineSnapshot(`"Learn how(opens in a new tab or window)"`); expect(action!.getAttribute('href')).toMatchInlineSnapshot( `"elastic.co/guide/en/kibana/current/alerting-getting-started.html#alerting-setup-prerequisites"` ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/expanded_row.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/expanded_row.test.tsx.snap index 137d11d3f3b09..8ace0445d0eb7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/expanded_row.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/expanded_row.test.tsx.snap @@ -178,6 +178,16 @@ exports[`PingListExpandedRow renders link to docs if body is not recorded but it + + + (opens in a new tab or window) + for more information on recording response bodies.
diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap index 69c6bf3fadb1d..7cc96a42411d2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap @@ -55,6 +55,16 @@ Array [ + + + (opens in a new tab or window) +
+ new VisTypeTimeseriesEnhanced(initializerContext); diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts new file mode 100644 index 0000000000000..0598a691ab7c5 --- /dev/null +++ b/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, PluginInitializerContext, Logger, CoreSetup } from 'src/core/server'; +import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; +import { RollupSearchStrategy } from './search_strategies/rollup_search_strategy'; + +interface VisTypeTimeseriesEnhancedSetupDependencies { + visTypeTimeseries: VisTypeTimeseriesSetup; +} + +export class VisTypeTimeseriesEnhanced + implements Plugin { + private logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('vis_type_timeseries_enhanced'); + } + + public async setup( + core: CoreSetup, + { visTypeTimeseries }: VisTypeTimeseriesEnhancedSetupDependencies + ) { + this.logger.debug('Starting plugin'); + + visTypeTimeseries.addSearchStrategy(new RollupSearchStrategy()); + } + + public start() {} +} diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/lib/interval_helper.test.js b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts similarity index 100% rename from x-pack/plugins/rollup/server/lib/search_strategies/lib/interval_helper.test.js rename to x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/lib/interval_helper.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts similarity index 100% rename from x-pack/plugins/rollup/server/lib/search_strategies/lib/interval_helper.ts rename to x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.test.js b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts similarity index 77% rename from x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.test.js rename to x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts index 977601247594f..6c30895635fe5 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_capabilities.test.js +++ b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts @@ -3,27 +3,21 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getRollupSearchCapabilities } from './rollup_search_capabilities'; +import { Unit } from '@elastic/datemath'; +import { RollupSearchCapabilities } from './rollup_search_capabilities'; -class DefaultSearchCapabilities { - constructor(request, fieldsCapabilities = {}) { - this.fieldsCapabilities = fieldsCapabilities; - this.parseInterval = jest.fn((interval) => interval); - } -} +import { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; describe('Rollup Search Capabilities', () => { const testTimeZone = 'time_zone'; const testInterval = '10s'; const rollupIndex = 'rollupIndex'; - const request = {}; + const request = ({} as unknown) as ReqFacade; - let RollupSearchCapabilities; - let fieldsCapabilities; - let rollupSearchCaps; + let fieldsCapabilities: Record; + let rollupSearchCaps: RollupSearchCapabilities; beforeEach(() => { - RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities); fieldsCapabilities = { [rollupIndex]: { aggs: { @@ -41,7 +35,6 @@ describe('Rollup Search Capabilities', () => { }); test('should create instance of RollupSearchRequest', () => { - expect(rollupSearchCaps).toBeInstanceOf(DefaultSearchCapabilities); expect(rollupSearchCaps.fieldsCapabilities).toBe(fieldsCapabilities); expect(rollupSearchCaps.rollupIndex).toBe(rollupIndex); }); @@ -55,9 +48,9 @@ describe('Rollup Search Capabilities', () => { }); describe('getValidTimeInterval', () => { - let rollupJobInterval; - let userInterval; - let getSuitableUnit; + let rollupJobInterval: { value: number; unit: Unit }; + let userInterval: { value: number; unit: Unit }; + let getSuitableUnit: Unit; beforeEach(() => { rollupSearchCaps.parseInterval = jest @@ -81,7 +74,7 @@ describe('Rollup Search Capabilities', () => { getSuitableUnit = 'd'; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('1d'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('1d'); }); test('should return 1w as common interval for 7d(user interval) and 1d(rollup interval) - calendar intervals', () => { @@ -96,7 +89,7 @@ describe('Rollup Search Capabilities', () => { getSuitableUnit = 'w'; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('1w'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('1w'); }); test('should return 1w as common interval for 1d(user interval) and 1w(rollup interval) - calendar intervals', () => { @@ -111,7 +104,7 @@ describe('Rollup Search Capabilities', () => { getSuitableUnit = 'w'; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('1w'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('1w'); }); test('should return 2y as common interval for 0.1y(user interval) and 2y(rollup interval) - fixed intervals', () => { @@ -124,7 +117,7 @@ describe('Rollup Search Capabilities', () => { unit: 'y', }; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('2y'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('2y'); }); test('should return 3h as common interval for 2h(user interval) and 3h(rollup interval) - fixed intervals', () => { @@ -137,7 +130,7 @@ describe('Rollup Search Capabilities', () => { unit: 'h', }; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('3h'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('3h'); }); test('should return 6m as common interval for 4m(user interval) and 3m(rollup interval) - fixed intervals', () => { @@ -150,7 +143,7 @@ describe('Rollup Search Capabilities', () => { unit: 'm', }; - expect(rollupSearchCaps.getValidTimeInterval()).toBe('6m'); + expect(rollupSearchCaps.getValidTimeInterval('')).toBe('6m'); }); }); }); diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts new file mode 100644 index 0000000000000..015a371bd2a35 --- /dev/null +++ b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get, has } from 'lodash'; +import { leastCommonInterval, isCalendarInterval } from './lib/interval_helper'; + +import { + ReqFacade, + DefaultSearchCapabilities, + VisPayload, +} from '../../../../../src/plugins/vis_type_timeseries/server'; + +export class RollupSearchCapabilities extends DefaultSearchCapabilities { + rollupIndex: string; + availableMetrics: Record; + + constructor( + req: ReqFacade, + fieldsCapabilities: Record, + rollupIndex: string + ) { + super(req, fieldsCapabilities); + + this.rollupIndex = rollupIndex; + this.availableMetrics = get(fieldsCapabilities, `${rollupIndex}.aggs`, {}); + } + + public get dateHistogram() { + const [dateHistogram] = Object.values(this.availableMetrics.date_histogram); + + return dateHistogram; + } + + public get defaultTimeInterval() { + return ( + this.dateHistogram.fixed_interval || + this.dateHistogram.calendar_interval || + /* + Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval] in the future. + We can remove the following line only for versions > 8.x + */ + this.dateHistogram.interval || + null + ); + } + + public get searchTimezone() { + return get(this.dateHistogram, 'time_zone', null); + } + + public get whiteListedMetrics() { + const baseRestrictions = this.createUiRestriction({ + count: this.createUiRestriction(), + }); + + const getFields = (fields: { [key: string]: any }) => + Object.keys(fields).reduce( + (acc, item) => ({ + ...acc, + [item]: true, + }), + this.createUiRestriction({}) + ); + + return Object.keys(this.availableMetrics).reduce( + (acc, item) => ({ + ...acc, + [item]: getFields(this.availableMetrics[item]), + }), + baseRestrictions + ); + } + + public get whiteListedGroupByFields() { + return this.createUiRestriction({ + everything: true, + terms: has(this.availableMetrics, 'terms'), + }); + } + + public get whiteListedTimerangeModes() { + return this.createUiRestriction({ + last_value: true, + }); + } + + getValidTimeInterval(userIntervalString: string) { + const parsedRollupJobInterval = this.parseInterval(this.defaultTimeInterval); + const inRollupJobUnit = this.convertIntervalToUnit( + userIntervalString, + parsedRollupJobInterval!.unit + ); + + const getValidCalendarInterval = () => { + let unit = parsedRollupJobInterval!.unit; + + if (inRollupJobUnit!.value > parsedRollupJobInterval!.value) { + const inSeconds = this.convertIntervalToUnit(userIntervalString, 's'); + if (inSeconds?.value) { + unit = this.getSuitableUnit(inSeconds.value); + } + } + + return { + value: 1, + unit, + }; + }; + + const getValidFixedInterval = () => ({ + value: leastCommonInterval(inRollupJobUnit?.value, parsedRollupJobInterval?.value), + unit: parsedRollupJobInterval!.unit, + }); + + const { value, unit } = (isCalendarInterval(parsedRollupJobInterval!) + ? getValidCalendarInterval + : getValidFixedInterval)(); + + return `${value}${unit}`; + } +} diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts similarity index 56% rename from x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js rename to x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts index f3da7ed3fdd17..ec6c91b616f5b 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js +++ b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts @@ -3,15 +3,35 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getRollupSearchStrategy } from './rollup_search_strategy'; +import { RollupSearchStrategy } from './rollup_search_strategy'; +import type { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; + +jest.mock('../../../../../src/plugins/vis_type_timeseries/server', () => { + const actual = jest.requireActual('../../../../../src/plugins/vis_type_timeseries/server'); + class AbstractSearchStrategyMock { + getFieldsForWildcard() { + return [ + { + name: 'day_of_week.terms.value', + type: 'object', + esTypes: ['object'], + searchable: false, + aggregatable: false, + }, + ]; + } + } + + return { + ...actual, + AbstractSearchStrategy: AbstractSearchStrategyMock, + }; +}); describe('Rollup Search Strategy', () => { - let RollupSearchStrategy; - let RollupSearchCapabilities; - let callWithRequest; - let rollupResolvedData; + let rollupResolvedData: Promise; - const request = { + const request = ({ requestContext: { core: { elasticsearch: { @@ -25,41 +45,9 @@ describe('Rollup Search Strategy', () => { }, }, }, - }; - const getRollupService = jest.fn().mockImplementation(() => { - return { - callAsCurrentUser: async () => { - return rollupResolvedData; - }, - }; - }); - const indexPattern = 'indexPattern'; + } as unknown) as ReqFacade; - beforeEach(() => { - class AbstractSearchStrategy { - getCallWithRequestInstance = jest.fn(() => callWithRequest); - - getFieldsForWildcard() { - return [ - { - name: 'day_of_week.terms.value', - type: 'object', - esTypes: ['object'], - searchable: false, - aggregatable: false, - }, - ]; - } - } - - RollupSearchCapabilities = jest.fn(() => 'capabilities'); - - RollupSearchStrategy = getRollupSearchStrategy( - AbstractSearchStrategy, - RollupSearchCapabilities, - getRollupService - ); - }); + const indexPattern = 'indexPattern'; test('should create instance of RollupSearchRequest', () => { const rollupSearchStrategy = new RollupSearchStrategy(); @@ -68,68 +56,66 @@ describe('Rollup Search Strategy', () => { }); describe('checkForViability', () => { - let rollupSearchStrategy; + let rollupSearchStrategy: RollupSearchStrategy; const rollupIndex = 'rollupIndex'; beforeEach(() => { rollupSearchStrategy = new RollupSearchStrategy(); - rollupSearchStrategy.getRollupData = jest.fn(() => ({ - [rollupIndex]: { - rollup_jobs: [ - { - job_id: 'test', - rollup_index: rollupIndex, - index_pattern: 'kibana*', - fields: { - order_date: [ - { - agg: 'date_histogram', - delay: '1m', - interval: '1m', - time_zone: 'UTC', - }, - ], - day_of_week: [ - { - agg: 'terms', - }, - ], + rollupSearchStrategy.getRollupData = jest.fn(() => + Promise.resolve({ + [rollupIndex]: { + rollup_jobs: [ + { + job_id: 'test', + rollup_index: rollupIndex, + index_pattern: 'kibana*', + fields: { + order_date: [ + { + agg: 'date_histogram', + delay: '1m', + interval: '1m', + time_zone: 'UTC', + }, + ], + day_of_week: [ + { + agg: 'terms', + }, + ], + }, }, - }, - ], - }, - })); + ], + }, + }) + ); }); test('isViable should be false for invalid index', async () => { - const result = await rollupSearchStrategy.checkForViability(request, null); + const result = await rollupSearchStrategy.checkForViability( + request, + (null as unknown) as string + ); expect(result).toEqual({ isViable: false, capabilities: null, }); }); - - test('should get RollupSearchCapabilities for valid rollup index ', async () => { - await rollupSearchStrategy.checkForViability(request, rollupIndex); - - expect(RollupSearchCapabilities).toHaveBeenCalled(); - }); }); describe('getRollupData', () => { - let rollupSearchStrategy; + let rollupSearchStrategy: RollupSearchStrategy; beforeEach(() => { rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { - rollupResolvedData = Promise.resolve('data'); + rollupResolvedData = Promise.resolve({ body: 'data' }); const rollupData = await rollupSearchStrategy.getRollupData(request, indexPattern); - expect(getRollupService).toHaveBeenCalled(); expect(rollupData).toBe('data'); }); @@ -143,8 +129,8 @@ describe('Rollup Search Strategy', () => { }); describe('getFieldsForWildcard', () => { - let rollupSearchStrategy; - let fieldsCapabilities; + let rollupSearchStrategy: RollupSearchStrategy; + let fieldsCapabilities: Record; const rollupIndex = 'rollupIndex'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts new file mode 100644 index 0000000000000..f1c20c318d109 --- /dev/null +++ b/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { keyBy, isString } from 'lodash'; +import { + AbstractSearchStrategy, + ReqFacade, + VisPayload, +} from '../../../../../src/plugins/vis_type_timeseries/server'; + +import { + mergeCapabilitiesWithFields, + getCapabilitiesForRollupIndices, +} from '../../../../../src/plugins/data/server'; + +import { RollupSearchCapabilities } from './rollup_search_capabilities'; + +const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); +const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); +const isIndexPatternValid = (indexPattern: string) => + indexPattern && isString(indexPattern) && !isIndexPatternContainsWildcard(indexPattern); + +export class RollupSearchStrategy extends AbstractSearchStrategy { + name = 'rollup'; + + async search(req: ReqFacade, bodies: any[]) { + return super.search(req, bodies, 'rollup'); + } + + async getRollupData(req: ReqFacade, indexPattern: string) { + return req.requestContext.core.elasticsearch.client.asCurrentUser.rollup + .getRollupIndexCaps({ + index: indexPattern, + }) + .then((data) => data.body) + .catch(() => Promise.resolve({})); + } + + async checkForViability(req: ReqFacade, indexPattern: string) { + let isViable = false; + let capabilities = null; + + if (isIndexPatternValid(indexPattern)) { + const rollupData = await this.getRollupData(req, indexPattern); + const rollupIndices = getRollupIndices(rollupData); + + isViable = rollupIndices.length === 1; + + if (isViable) { + const [rollupIndex] = rollupIndices; + const fieldsCapabilities = getCapabilitiesForRollupIndices(rollupData); + + capabilities = new RollupSearchCapabilities(req, fieldsCapabilities, rollupIndex); + } + } + + return { + isViable, + capabilities, + }; + } + + async getFieldsForWildcard( + req: ReqFacade, + indexPattern: string, + { + fieldsCapabilities, + rollupIndex, + }: { fieldsCapabilities: { [key: string]: any }; rollupIndex: string } + ) { + const fields = await super.getFieldsForWildcard(req, indexPattern); + const fieldsFromFieldCapsApi = keyBy(fields, 'name'); + const rollupIndexCapabilities = fieldsCapabilities[rollupIndex].aggs; + + return mergeCapabilitiesWithFields(rollupIndexCapabilities, fieldsFromFieldCapsApi); + } +} diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 193d0eea1590e..b0462143a8ad4 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -6,15 +6,17 @@ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; - -interface FLSFieldMappingResponse { +interface FLSMappingResponse { flstest: { mappings: { - [fieldName: string]: { - mapping: { - [fieldName: string]: { - type: string; - }; + runtime?: { + [fieldName: string]: { + type: string; + }; + }; + properties: { + [fieldName: string]: { + type: string; }; }; }; @@ -56,16 +58,10 @@ export default function ({ getService }: FtrProviderContext) { it('should not include runtime fields', async () => { // First, make sure the mapping actually includes a runtime field - const fieldMapping = (await es.indices.getFieldMapping({ - index: 'flstest', - fields: '*', - includeDefaults: true, - })) as FLSFieldMappingResponse; + const mapping = (await es.indices.getMapping({ index: 'flstest' })) as FLSMappingResponse; - expect(Object.keys(fieldMapping.flstest.mappings)).to.contain('runtime_customer_ssn'); - expect( - fieldMapping.flstest.mappings.runtime_customer_ssn.mapping.runtime_customer_ssn.type - ).to.eql('runtime'); + expect(Object.keys(mapping.flstest.mappings)).to.contain('runtime'); + expect(Object.keys(mapping.flstest.mappings.runtime!)).to.contain('runtime_customer_ssn'); // Now, make sure it's not returned here const { body: actualFields } = (await supertest diff --git a/x-pack/test/functional/es_archives/logstash_functional/mappings.json b/x-pack/test/functional/es_archives/logstash_functional/mappings.json index ee7feedd77530..12853523615bd 100644 --- a/x-pack/test/functional/es_archives/logstash_functional/mappings.json +++ b/x-pack/test/functional/es_archives/logstash_functional/mappings.json @@ -19,6 +19,12 @@ } } ], + "runtime": { + "runtime_number": { + "type": "long", + "script" : { "source" : "emit(doc['bytes'].value)" } + } + }, "properties": { "@message": { "fields": { @@ -342,11 +348,6 @@ } }, "type": "text" - }, - "runtime_number": { - "type": "runtime", - "runtime_type" : "long", - "script" : { "source" : "emit(doc['bytes'].value)" } } } }, @@ -389,6 +390,12 @@ } } ], + "runtime": { + "runtime_number": { + "type": "long", + "script" : { "source" : "emit(doc['bytes'].value)" } + } + }, "properties": { "@message": { "fields": { @@ -712,11 +719,6 @@ } }, "type": "text" - }, - "runtime_number": { - "type": "runtime", - "runtime_type" : "long", - "script" : { "source" : "emit(doc['bytes'].value)" } } } }, @@ -759,6 +761,12 @@ } } ], + "runtime": { + "runtime_number": { + "type": "long", + "script" : { "source" : "emit(doc['bytes'].value)" } + } + }, "properties": { "@message": { "fields": { @@ -1082,11 +1090,6 @@ } }, "type": "text" - }, - "runtime_number": { - "type": "runtime", - "runtime_type" : "long", - "script" : { "source" : "emit(doc['bytes'].value)" } } } }, @@ -1106,4 +1109,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/security/flstest/data/mappings.json b/x-pack/test/functional/es_archives/security/flstest/data/mappings.json index 3605533618a93..4f419e4b6ade4 100644 --- a/x-pack/test/functional/es_archives/security/flstest/data/mappings.json +++ b/x-pack/test/functional/es_archives/security/flstest/data/mappings.json @@ -3,6 +3,14 @@ "value": { "index": "flstest", "mappings": { + "runtime": { + "runtime_customer_ssn": { + "type": "keyword", + "script": { + "source": "emit(doc['customer_ssn'].value + ' calculated at runtime')" + } + } + }, "properties": { "customer_name": { "fields": { @@ -30,13 +38,6 @@ } }, "type": "text" - }, - "runtime_customer_ssn": { - "type": "runtime", - "runtime_type": "keyword", - "script": { - "source": "emit(doc['customer_ssn'].value + ' calculated at runtime')" - } } } }, @@ -47,4 +48,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/tag_management_page.ts b/x-pack/test/functional/page_objects/tag_management_page.ts index 8b354e9d0e1c4..7d40bf5600da4 100644 --- a/x-pack/test/functional/page_objects/tag_management_page.ts +++ b/x-pack/test/functional/page_objects/tag_management_page.ts @@ -156,6 +156,13 @@ export function TagManagementPageProvider({ getService, getPageObjects }: FtrPro } } + /** + * Tag management page object. + * + * @remarks All the table manipulation helpers makes the assumption + * that all tags are displayed on a single page. Pagination + * and finding / interacting with a tag on another page is not supported. + */ class TagManagementPage { public readonly tagModal = new TagModal(this); @@ -272,6 +279,90 @@ export function TagManagementPageProvider({ getService, getPageObjects }: FtrPro await connectionLink.click(); } + /** + * Return true if the selection column is displayed on the table, false otherwise. + */ + async isSelectionColumnDisplayed() { + const firstRow = await testSubjects.find('tagsTableRow'); + const checkbox = await firstRow.findAllByCssSelector( + '.euiTableRowCellCheckbox .euiCheckbox__input' + ); + return Boolean(checkbox.length); + } + + /** + * Click on the selection checkbox of the tag matching given tag name. + */ + async selectTagByName(tagName: string) { + const tagRow = await this.getRowByName(tagName); + const checkbox = await tagRow.findByCssSelector( + '.euiTableRowCellCheckbox .euiCheckbox__input' + ); + await checkbox.click(); + } + + /** + * Returns true if the tag bulk action menu is displayed, false otherwise. + */ + async isActionMenuButtonDisplayed() { + return testSubjects.exists('actionBar-contextMenuButton'); + } + + /** + * Open the bulk action menu if not already opened. + */ + async openActionMenu() { + if (!(await this.isActionMenuOpened())) { + await this.toggleActionMenu(); + } + } + + /** + * Check if the action for given `actionId` is present in the bulk action menu. + * + * The menu will automatically be opened if not already, but the test must still + * select tags to make the action menu button appear. + */ + async isActionPresent(actionId: string) { + if (!(await this.isActionMenuButtonDisplayed())) { + return false; + } + const menuWasOpened = await this.isActionMenuOpened(); + if (!menuWasOpened) { + await this.openActionMenu(); + } + + const actionExists = await testSubjects.exists(`actionBar-button-${actionId}`); + + if (!menuWasOpened) { + await this.toggleActionMenu(); + } + + return actionExists; + } + + /** + * Click on given bulk action button + */ + async clickOnAction(actionId: string) { + await this.openActionMenu(); + await testSubjects.click(`actionBar-button-${actionId}`); + } + + /** + * Toggle (close if opened, open if closed) the bulk action menu. + */ + async toggleActionMenu() { + await testSubjects.click('actionBar-contextMenuButton'); + } + + /** + * Return true if the bulk action menu is opened, false otherwise. + */ + async isActionMenuOpened() { + return testSubjects.exists('actionBar-contextMenuPopover'); + } + /** * Return the info of all the tags currently displayed in the table (in table's order) */ diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_bulk_delete.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_bulk_delete.ts new file mode 100644 index 0000000000000..0c9a06bd58828 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_bulk_delete.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { USERS, User, ExpectedResponse } from '../../../common/lib'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /internal/saved_objects_tagging/tags/_bulk_delete', () => { + beforeEach(async () => { + await esArchiver.load('rbac_tags'); + }); + + afterEach(async () => { + await esArchiver.unload('rbac_tags'); + }); + + const responses: Record = { + authorized: { + httpCode: 200, + expectResponse: ({ body }) => { + expect(body).to.eql({}); + }, + }, + unauthorized: { + httpCode: 403, + expectResponse: ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unable to delete tag', + }); + }, + }, + }; + + const expectedResults: Record = { + authorized: [ + USERS.SUPERUSER, + USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER, + ], + unauthorized: [ + USERS.DEFAULT_SPACE_READ_USER, + USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER, + USERS.DEFAULT_SPACE_DASHBOARD_READ_USER, + USERS.DEFAULT_SPACE_VISUALIZE_READ_USER, + USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER, + USERS.NOT_A_KIBANA_USER, + ], + }; + + const createUserTest = ( + { username, password, description }: User, + { httpCode, expectResponse }: ExpectedResponse + ) => { + it(`returns expected ${httpCode} response for ${description ?? username}`, async () => { + await supertest + .post(`/internal/saved_objects_tagging/tags/_bulk_delete`) + .send({ + ids: ['default-space-tag-1', 'default-space-tag-2'], + }) + .auth(username, password) + .expect(httpCode) + .then(expectResponse); + }); + }; + + const createTestSuite = () => { + Object.entries(expectedResults).forEach(([responseId, users]) => { + const response: ExpectedResponse = responses[responseId]; + users.forEach((user) => { + createUserTest(user, response); + }); + }); + }; + + createTestSuite(); + }); +} diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts index 5f3d1cf854f82..727479546431c 100644 --- a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/index.ts @@ -22,5 +22,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./_find')); + loadTestFile(require.resolve('./_bulk_delete')); }); } diff --git a/x-pack/test/saved_object_tagging/functional/tests/bulk_actions.ts b/x-pack/test/saved_object_tagging/functional/tests/bulk_actions.ts new file mode 100644 index 0000000000000..556130bed7931 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/bulk_actions.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); + const tagManagementPage = PageObjects.tagManagement; + + describe('table bulk actions', () => { + beforeEach(async () => { + await esArchiver.load('functional_base'); + await tagManagementPage.navigateTo(); + }); + afterEach(async () => { + await esArchiver.unload('functional_base'); + }); + + describe('bulk delete', () => { + it('deletes multiple tags', async () => { + await tagManagementPage.selectTagByName('tag-1'); + await tagManagementPage.selectTagByName('tag-3'); + + await tagManagementPage.clickOnAction('delete'); + + await PageObjects.common.clickConfirmOnModal(); + await tagManagementPage.waitUntilTableIsLoaded(); + + const displayedTags = await tagManagementPage.getDisplayedTagNames(); + expect(displayedTags.length).to.be(3); + expect(displayedTags).to.eql(['my-favorite-tag', 'tag with whitespace', 'tag-2']); + }); + }); + + describe('clear selection', () => { + it('clears the current selection', async () => { + await tagManagementPage.selectTagByName('tag-1'); + await tagManagementPage.selectTagByName('tag-3'); + + await tagManagementPage.clickOnAction('clear_selection'); + + await tagManagementPage.waitUntilTableIsLoaded(); + + expect(await tagManagementPage.isActionMenuButtonDisplayed()).to.be(false); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts b/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts index 72beabca59f5c..65443fb517edf 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/feature_control.ts @@ -35,6 +35,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }; + const selectSomeTags = async () => { + if (await tagManagementPage.isSelectionColumnDisplayed()) { + await tagManagementPage.selectTagByName('tag-1'); + await tagManagementPage.selectTagByName('tag-3'); + } + }; + const addFeatureControlSuite = ({ user, description, privileges }: FeatureControlUserSuite) => { const testPrefix = (allowed: boolean) => (allowed ? `can` : `can't`); @@ -57,6 +64,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(await tagManagementPage.isDeleteButtonVisible()).to.be(privileges.delete); }); + it(`${testPrefix(privileges.delete)} bulk delete tags`, async () => { + await selectSomeTags(); + expect(await tagManagementPage.isActionPresent('delete')).to.be(privileges.delete); + }); + it(`${testPrefix(privileges.create)} create tag`, async () => { expect(await tagManagementPage.isCreateButtonVisible()).to.be(privileges.create); }); diff --git a/x-pack/test/saved_object_tagging/functional/tests/index.ts b/x-pack/test/saved_object_tagging/functional/tests/index.ts index 0ddfa64d682a8..7fd0605c34d76 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/index.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/index.ts @@ -17,6 +17,7 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./listing')); + loadTestFile(require.resolve('./bulk_actions')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./edit')); loadTestFile(require.resolve('./som_integration')); diff --git a/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts index d8bd39ac7dc53..b938591543196 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts @@ -102,7 +102,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(itemNames).to.contain('My new markdown viz'); }); - it('allows to assign tags to the new visualization', async () => { + it('allows to create a tag from the tag selector', async () => { const { tagModal } = PageObjects.tagManagement; await PageObjects.visualize.navigateToNewVisualization(); diff --git a/yarn.lock b/yarn.lock index 91ae4b236adf3..3bfa72cc50aeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1433,10 +1433,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@30.1.1": - version "30.1.1" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-30.1.1.tgz#7d3a4e8241cca2e27078f9fa6a0138b26acd3db3" - integrity sha512-nsqrOFJfPIGS+TTd4W4viesWdPEDCxazfrPc+DZQ4kosRXLKoJ4lLKDuJKbg90HshCeamKSFgEiqWlqKAlyV+A== +"@elastic/eui@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-30.2.0.tgz#17bc630eb1a03e05d453b9934568cfbf3a82b736" + integrity sha512-VUmy7Qz49kN8a3f58bilfMwBo9zCJvHyBOUdDA1O77jFYv3+Hg7e/+uGb7Q1k6kSmCfv4r99eZWrcxVroYU4nQ== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -9367,10 +9367,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^86.0.0: - version "86.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-86.0.0.tgz#4b9504d5bbbcd4c6bd6d6fd1dd8247ab8cdeca67" - integrity sha512-byLJWhAfuYOmzRYPDf4asJgGDbI4gJGHa+i8dnQZGuv+6WW1nW1Fg+8zbBMOfLvGn7sKL41kVdmCEVpQHn9oyg== +chromedriver@^87.0.0: + version "87.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-87.0.0.tgz#e8390deed8ada791719a67ad6bf1116614f1ba2d" + integrity sha512-PY7FnHOQKfH0oPfSdhpLx5nEy5g4dGYySf2C/WZGkAaCaldYH8/3lPPucZ8MlOCi4bCSGoKoCUTeG6+wYWavvw== dependencies: "@testim/chrome-version" "^1.0.7" axios "^0.19.2"