diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md new file mode 100644 index 0000000000000..8ebc9068aa612 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [callback](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) + +## AppLeaveConfirmAction.callback property + +Signature: + +```typescript +callback?: () => void; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md index 969d5ddd44c3e..8650cd9868940 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md @@ -18,6 +18,7 @@ export interface AppLeaveConfirmAction | Property | Type | Description | | --- | --- | --- | +| [callback](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) | () => void | | | [text](./kibana-plugin-core-public.appleaveconfirmaction.text.md) | string | | | [title](./kibana-plugin-core-public.appleaveconfirmaction.title.md) | string | | | [type](./kibana-plugin-core-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | | diff --git a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md index a5f8336f6424a..d86f7b7a1a5f9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md @@ -11,5 +11,5 @@ See [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) for Signature: ```typescript -export declare type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export declare type AppLeaveHandler = (factory: AppLeaveActionFactory, nextAppId?: string) => AppLeaveAction; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md new file mode 100644 index 0000000000000..1faef45c0b2b7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionLinkBase](./kibana-plugin-core-public.chromehelpextensionlinkbase.md) + +## ChromeHelpExtensionLinkBase type + + +Signature: + +```typescript +export declare type ChromeHelpExtensionLinkBase = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md new file mode 100644 index 0000000000000..dc455ca43d24a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [content](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md) + +## ChromeHelpExtensionMenuCustomLink.content property + +Content of the button (in lieu of `children`) + +Signature: + +```typescript +content: React.ReactNode; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md new file mode 100644 index 0000000000000..feb91acd6d915 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md) + +## ChromeHelpExtensionMenuCustomLink.href property + +URL of the link + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md new file mode 100644 index 0000000000000..a02b219754042 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md) + +## ChromeHelpExtensionMenuCustomLink.linkType property + +Extend EuiButtonEmpty to provide extra functionality + +Signature: + +```typescript +linkType: 'custom'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md index 29be9b9539ee0..ff4978e69df62 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md @@ -2,14 +2,20 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) -## ChromeHelpExtensionMenuCustomLink type +## ChromeHelpExtensionMenuCustomLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { - linkType: 'custom'; - content: React.ReactNode; -}; +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [content](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md) | React.ReactNode | Content of the button (in lieu of children) | +| [href](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md) | string | URL of the link | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md) | 'custom' | Extend EuiButtonEmpty to provide extra functionality | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md new file mode 100644 index 0000000000000..b6714c39a4699 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md) + +## ChromeHelpExtensionMenuDiscussLink.href property + +URL to discuss page. i.e. `https://discuss.elastic.co/c/${appName}` + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md new file mode 100644 index 0000000000000..0141677b26a40 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md) + +## ChromeHelpExtensionMenuDiscussLink.linkType property + +Creates a generic give feedback link with comment icon + +Signature: + +```typescript +linkType: 'discuss'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md index 63d0596bd9847..a73f6daad28c2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md @@ -2,14 +2,19 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) -## ChromeHelpExtensionMenuDiscussLink type +## ChromeHelpExtensionMenuDiscussLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { - linkType: 'discuss'; - href: string; -}; +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [href](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md) | string | URL to discuss page. i.e. https://discuss.elastic.co/c/${appName} | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md) | 'discuss' | Creates a generic give feedback link with comment icon | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md new file mode 100644 index 0000000000000..9897bc6fcd2f7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md) + +## ChromeHelpExtensionMenuDocumentationLink.href property + +URL to documentation page. i.e. `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html`, + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md new file mode 100644 index 0000000000000..b75a70f9518b3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md) + +## ChromeHelpExtensionMenuDocumentationLink.linkType property + +Creates a deep-link to app-specific documentation + +Signature: + +```typescript +linkType: 'documentation'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md index c7c1c4153edf8..fab49d06d4774 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md @@ -2,14 +2,19 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) -## ChromeHelpExtensionMenuDocumentationLink type +## ChromeHelpExtensionMenuDocumentationLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { - linkType: 'documentation'; - href: string; -}; +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [href](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md) | string | URL to documentation page. i.e. ${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html, | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md) | 'documentation' | Creates a deep-link to app-specific documentation | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md new file mode 100644 index 0000000000000..1976215e7243c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [labels](./kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md) + +## ChromeHelpExtensionMenuGitHubLink.labels property + +Include at least one app-specific label to be applied to the new github issue + +Signature: + +```typescript +labels: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md new file mode 100644 index 0000000000000..b3df27213e5b7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md) + +## ChromeHelpExtensionMenuGitHubLink.linkType property + +Creates a link to a new github issue in the Kibana repo + +Signature: + +```typescript +linkType: 'github'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md index 5cb3a79086e11..ca9ceecffa6f1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md @@ -2,15 +2,20 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) -## ChromeHelpExtensionMenuGitHubLink type +## ChromeHelpExtensionMenuGitHubLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { - linkType: 'github'; - labels: string[]; - title?: string; -}; +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [labels](./kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md) | string[] | Include at least one app-specific label to be applied to the new github issue | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md) | 'github' | Creates a link to a new github issue in the Kibana repo | +| [title](./kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md) | string | Provides initial text for the title of the issue | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md new file mode 100644 index 0000000000000..af6091f9e7252 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [title](./kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md) + +## ChromeHelpExtensionMenuGitHubLink.title property + +Provides initial text for the title of the issue + +Signature: + +```typescript +title?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md index 7a219d5bfd2f8..cb7d795e3eb8e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; +export declare type ChromeHelpExtensionMenuLink = ChromeHelpExtensionMenuGitHubLink | ChromeHelpExtensionMenuDiscussLink | ChromeHelpExtensionMenuDocumentationLink | ChromeHelpExtensionMenuCustomLink; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md index 5551d52cc1226..842d86db45d73 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md @@ -4,7 +4,7 @@ ## IExternalUrlPolicy.host property -Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. +Optional host describing the external destination. May be combined with `protocol`. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md index a87dc69d79e23..3a1e571460974 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -17,6 +17,6 @@ export interface IExternalUrlPolicy | Property | Type | Description | | --- | --- | --- | | [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates if this policy allows or denies access to the described destination. | -| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | -| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | +| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. | +| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md index 67b9b439a54f6..ac73412b6e143 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md @@ -4,7 +4,7 @@ ## IExternalUrlPolicy.protocol property -Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. +Optional protocol describing the external destination. May be combined with `host`. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index a3df5d30137df..da19377054499 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -44,6 +44,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) | | | [ChromeDocTitle](./kibana-plugin-core-public.chromedoctitle.md) | APIs for accessing and updating the document title. | | [ChromeHelpExtension](./kibana-plugin-core-public.chromehelpextension.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) | | | [ChromeNavControl](./kibana-plugin-core-public.chromenavcontrol.md) | | | [ChromeNavControls](./kibana-plugin-core-public.chromenavcontrols.md) | [APIs](./kibana-plugin-core-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | | [ChromeNavLink](./kibana-plugin-core-public.chromenavlink.md) | | @@ -145,10 +149,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-core-public.appupdater.md). | | [AppUpdater](./kibana-plugin-core-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [ChromeBreadcrumb](./kibana-plugin-core-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionLinkBase](./kibana-plugin-core-public.chromehelpextensionlinkbase.md) | | | [ChromeHelpExtensionMenuLink](./kibana-plugin-core-public.chromehelpextensionmenulink.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-core-public.chromenavlinkupdateablefields.md) | | | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md index 1c9fc27d53f19..9447c8a4e50a7 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type ChartActionContext = ValueClickContext | RangeSelectContext; +export declare type ChartActionContext = ValueClickContext | RangeSelectContext | RowClickContext; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md index 474962e614aa7..5201444e69867 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md @@ -4,7 +4,7 @@ ## Embeddable.getUpdated$() method -Merges input$ and output$ streams and denounces emit till next macro-task Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously In case corresponding state change triggered `reload` this stream is guarantied to emit later which allows to skip any state handling in case `reload` already handled it +Merges input$ and output$ streams and debounces emit till next macro-task. Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously. In case corresponding state change triggered `reload` this stream is guarantied to emit later, which allows to skip any state handling in case `reload` already handled it. Signature: diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md index 4541afec29fa5..fe64bcf7c1177 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md @@ -44,7 +44,7 @@ export declare abstract class Embeddablereload this stream is guarantied to emit later which allows to skip any state handling in case reload already handled it | +| [getUpdated$()](./kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md) | | Merges input$ and output$ streams and debounces emit till next macro-task. Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously. In case corresponding state change triggered reload this stream is guarantied to emit later, which allows to skip any state handling in case reload already handled it. | | [onFatalError(e)](./kibana-plugin-plugins-embeddable-public.embeddable.onfatalerror.md) | | | | [reload()](./kibana-plugin-plugins-embeddable-public.embeddable.reload.md) | | Reload will be called when there is a request to refresh the data or view, even if the input data did not change.In case if input data did change and reload is requested input$ and output$ would still emit before reload is calledThe order would be as follows: input$ output$ reload() \-\-\-- updated$ | | [render(el)](./kibana-plugin-plugins-embeddable-public.embeddable.render.md) | | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md new file mode 100644 index 0000000000000..ea8d3870dc055 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [isEmbeddable](./kibana-plugin-plugins-embeddable-public.isembeddable.md) + +## isEmbeddable variable + +Signature: + +```typescript +isEmbeddable: (x: unknown) => x is IEmbeddable +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md new file mode 100644 index 0000000000000..91e0f988db69c --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [isRowClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md) + +## isRowClickTriggerContext variable + +Signature: + +```typescript +isRowClickTriggerContext: (context: ChartActionContext) => context is RowClickContext +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md index 06f792837e4fe..f1ea605703e59 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md @@ -78,7 +78,9 @@ | [defaultEmbeddableFactoryProvider](./kibana-plugin-plugins-embeddable-public.defaultembeddablefactoryprovider.md) | | | [EmbeddableRenderer](./kibana-plugin-plugins-embeddable-public.embeddablerenderer.md) | Helper react component to render an embeddable Can be used if you have an embeddable object or an embeddable factory Supports updating input by passing input prop | | [isContextMenuTriggerContext](./kibana-plugin-plugins-embeddable-public.iscontextmenutriggercontext.md) | | +| [isEmbeddable](./kibana-plugin-plugins-embeddable-public.isembeddable.md) | | | [isRangeSelectTriggerContext](./kibana-plugin-plugins-embeddable-public.israngeselecttriggercontext.md) | | +| [isRowClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md) | | | [isValueClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isvalueclicktriggercontext.md) | | | [PANEL\_BADGE\_TRIGGER](./kibana-plugin-plugins-embeddable-public.panel_badge_trigger.md) | | | [PANEL\_NOTIFICATION\_TRIGGER](./kibana-plugin-plugins-embeddable-public.panel_notification_trigger.md) | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md index fcccd3f6b9618..1565202e84674 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `ExpressionRenderHandler` class Signature: ```typescript -constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); +constructor(element: HTMLElement, { onRenderError, renderMode, hasCompatibleActions, }?: ExpressionRenderHandlerParams); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(element: HTMLElement, { onRenderError, renderMode }?: PartialHTMLElement | | -| { onRenderError, renderMode } | Partial<ExpressionRenderHandlerParams> | | +| { onRenderError, renderMode, hasCompatibleActions, } | ExpressionRenderHandlerParams | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md index 12c663273bd8c..d65c06bdaed83 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md @@ -14,7 +14,7 @@ export declare class ExpressionRenderHandler | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(element, { onRenderError, renderMode })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | +| [(constructor)(element, { onRenderError, renderMode, hasCompatibleActions, })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | ## Properties @@ -24,7 +24,7 @@ export declare class ExpressionRenderHandler | [events$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.events_.md) | | Observable<ExpressionRendererEvent> | | | [getElement](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.getelement.md) | | () => HTMLElement | | | [handleRenderError](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.handlerendererror.md) | | (error: ExpressionRenderError) => void | | -| [render](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md) | | (data: any, uiState?: any) => Promise<void> | | +| [render](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md) | | (value: any, uiState?: any) => Promise<void> | | | [render$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render_.md) | | Observable<number> | | | [update$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.update_.md) | | Observable<UpdateValue | null> | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md index dec17d60ffd14..87f378fd58344 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md @@ -7,5 +7,5 @@ Signature: ```typescript -render: (data: any, uiState?: any) => Promise; +render: (value: any, uiState?: any) => Promise; ``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md new file mode 100644 index 0000000000000..4d2b76cb323fb --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IExpressionLoaderParams](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md) + +## IExpressionLoaderParams.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md index 54eecad0deb50..22a73fff039e6 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md @@ -19,6 +19,7 @@ export interface IExpressionLoaderParams | [customRenderers](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.customrenderers.md) | [] | | | [debug](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md) | boolean | | | [disableCaching](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.disablecaching.md) | boolean | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md) | ExpressionRenderHandlerParams['hasCompatibleActions'] | | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.inspectoradapters.md) | Adapters | | | [onRenderError](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.onrendererror.md) | RenderErrorHandlerFnType | | | [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) | RenderMode | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md new file mode 100644 index 0000000000000..d178af55ae2d9 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md) + +## IInterpreterRenderHandlers.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: (event: any) => Promise; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md index a65e025451636..931e474a41006 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md @@ -17,6 +17,7 @@ export interface IInterpreterRenderHandlers | [done](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.event.md) | (event: any) => void | | | [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md) | (event: any) => Promise<boolean> | | | [onDestroy](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md new file mode 100644 index 0000000000000..55419279f5d21 --- /dev/null +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md) + +## IInterpreterRenderHandlers.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: (event: any) => Promise; +``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md index b1496386944fa..273703cacca06 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md @@ -17,6 +17,7 @@ export interface IInterpreterRenderHandlers | [done](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.event.md) | (event: any) => void | | | [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md) | (event: any) => Promise<boolean> | | | [onDestroy](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md index 5e10de4e0f2a5..fd1ea7df4fb74 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md @@ -26,6 +26,7 @@ | [Action](./kibana-plugin-plugins-ui_actions-public.action.md) | | | [ActionContextMapping](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md) | | | [ActionExecutionMeta](./kibana-plugin-plugins-ui_actions-public.actionexecutionmeta.md) | During action execution we can provide additional information, for example, trigger, that caused the action execution | +| [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) | | | [Trigger](./kibana-plugin-plugins-ui_actions-public.trigger.md) | This is a convenience interface used to register a \*trigger\*.Trigger specifies a named anchor to which Action can be attached. When Trigger is being \*called\* it creates a Context object and passes it to the execute method of an Action.More than one action can be attached to a single trigger, in which case when trigger is \*called\* it first displays a context menu for user to pick a single action to execute. | | [TriggerContextMapping](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md) | | | [UiActionsActionDefinition](./kibana-plugin-plugins-ui_actions-public.uiactionsactiondefinition.md) | A convenience interface used to register an action. | @@ -42,6 +43,8 @@ | [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md) | | | [APPLY\_FILTER\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.apply_filter_trigger.md) | | | [applyFilterTrigger](./kibana-plugin-plugins-ui_actions-public.applyfiltertrigger.md) | | +| [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.row_click_trigger.md) | | +| [rowClickTrigger](./kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md) | | | [SELECT\_RANGE\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.select_range_trigger.md) | | | [selectRangeTrigger](./kibana-plugin-plugins-ui_actions-public.selectrangetrigger.md) | | | [VALUE\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.value_click_trigger.md) | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md new file mode 100644 index 0000000000000..3541b53ab1d61 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.row_click_trigger.md) + +## ROW\_CLICK\_TRIGGER variable + +Signature: + +```typescript +ROW_CLICK_TRIGGER = "ROW_CLICK_TRIGGER" +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md new file mode 100644 index 0000000000000..1068cc9146893 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) > [data](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md) + +## RowClickContext.data property + +Signature: + +```typescript +data: { + rowIndex: number; + table: Datatable; + columns?: string[]; + }; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md new file mode 100644 index 0000000000000..e8baf44ff9cbc --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) > [embeddable](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md) + +## RowClickContext.embeddable property + +Signature: + +```typescript +embeddable?: IEmbeddable; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md new file mode 100644 index 0000000000000..74b55d85f10e3 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) + +## RowClickContext interface + +Signature: + +```typescript +export interface RowClickContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [data](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md) | {
rowIndex: number;
table: Datatable;
columns?: string[];
} | | +| [embeddable](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md) | IEmbeddable | | + diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md new file mode 100644 index 0000000000000..aa1097d8c0864 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [rowClickTrigger](./kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md) + +## rowClickTrigger variable + +Signature: + +```typescript +rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'> +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md index 9db44d4dc7b05..2f0d22cf6dd74 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md @@ -16,6 +16,7 @@ export interface TriggerContextMapping | --- | --- | --- | | [""](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.__.md) | TriggerContext | | | [FILTER\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.filter_trigger.md) | ApplyGlobalFilterActionContext | | +| [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md) | RowClickContext | | | [SELECT\_RANGE\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.select_range_trigger.md) | RangeSelectContext | | | [VALUE\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.value_click_trigger.md) | ValueClickContext | | | [VISUALIZE\_FIELD\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.visualize_field_trigger.md) | VisualizeFieldContext | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md new file mode 100644 index 0000000000000..cf253df337378 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [TriggerContextMapping](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md) > [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md) + +## TriggerContextMapping.ROW\_CLICK\_TRIGGER property + +Signature: + +```typescript +[ROW_CLICK_TRIGGER]: RowClickContext; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md index fd6ade88479af..ca999322b7a56 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md @@ -11,5 +11,5 @@ Signature: ```typescript -readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; +readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md index 19f215a96b23b..e95e7e1eb38b6 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly attachAction: (triggerId: T, actionId: string) => void; +readonly attachAction: (triggerId: T, actionId: string) => void; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md index 1bb6ca1115248..8e7fb8b8bbf29 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md @@ -12,5 +12,5 @@ Signature: ```typescript -readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; +readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md index d44dc4e43a52e..b996620686a28 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTrigger: (triggerId: T) => TriggerContract; +readonly getTrigger: (triggerId: T) => TriggerContract; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md index 0a9b674a45de2..f94b34ecc2d90 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerActions: (triggerId: T) => Action[]; +readonly getTriggerActions: (triggerId: T) => Action[]; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md index faed81236342d..dff958608ef9e 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; +readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md index e3c5dbb92ae90..e35eb503ab62b 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md @@ -21,17 +21,17 @@ export declare class UiActionsService | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [actions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.actions.md) | | ActionRegistry | | -| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | -| [attachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, actionId: string) => void | | +| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | +| [attachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, actionId: string) => void | | | [clear](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.clear.md) | | () => void | Removes all registered triggers and actions. | | [detachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.detachaction.md) | | (triggerId: TriggerId, actionId: string) => void | | -| [executeTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContext<T>) => Promise<void> | | +| [executeTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContext<T>) => Promise<void> | | | [executionService](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executionservice.md) | | UiActionsExecutionService | | | [fork](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.fork.md) | | () => UiActionsService | "Fork" a separate instance of UiActionsService that inherits all existing triggers and actions, but going forward all new triggers and actions added to this instance of UiActionsService are only available within this instance. | | [getAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md) | | <T extends ActionDefinition<{}>>(id: string) => Action<ActionContext<T>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV"> | | -| [getTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => TriggerContract<T> | | -| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[] | | -| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[]> | | +| [getTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => TriggerContract<T> | | +| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[] | | +| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[]> | | | [hasAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.hasaction.md) | | (actionId: string) => boolean | | | [registerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md) | | <A extends ActionDefinition<{}>>(definition: A) => Action<ActionContext<A>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV"> | | | [registerTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registertrigger.md) | | (trigger: Trigger) => void | | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 6cd848e963431..8b50fc38167d3 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -453,11 +453,11 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). | `server.cors.enabled:` | experimental[] Set to `true` to allow cross-origin API calls. *Default:* `false` -| `server.cors.credentials:` +| `server.cors.allowCredentials:` | experimental[] Set to `true` to allow browser code to access response body whenever request performed with user credentials. *Default:* `false` -| `server.cors.origin:` - | experimental[] List of origins permitted to access resources. You must specify explicit hostnames and not use `*` for `server.cors.origin` when `server.cors.credentials: true`. *Default:* "*" +| `server.cors.allowOrigin:` + | experimental[] List of origins permitted to access resources. You must specify explicit hostnames and not use `server.cors.allowOrigin: ["*"]` when `server.cors.allowCredentials: true`. *Default:* ["*"] | `server.compression.referrerWhitelist:` | Specifies an array of trusted hostnames, such as the {kib} host, or a reverse diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index ff2c321f667c8..3db5bd6d97ff0 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -233,7 +233,7 @@ image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigate https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ---- + -The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. In *URL preview*, `{{event.value}}` is substituted with a <> value. +The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. + [role="screenshot"] image:images/url_drilldown_url_template.png[URL template input] diff --git a/docs/user/dashboard/images/url_drilldown_url_template.png b/docs/user/dashboard/images/url_drilldown_url_template.png index d8515afe66a80..746ce62733618 100644 Binary files a/docs/user/dashboard/images/url_drilldown_url_template.png and b/docs/user/dashboard/images/url_drilldown_url_template.png differ diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 617fae938f8f5..df9fa2dca81fd 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -146,17 +146,7 @@ The URL drilldown template has three sources for variables: * *Context* variables that change depending on where the drilldown is created and used. These variables are extracted from a context of a panel on a dashboard. For example, `{{context.panel.filters}}` gives access to filters that applied to the current panel. * *Event* variables that depend on the trigger context. These variables are dynamically extracted from the interaction context when the drilldown is executed. -[[values-in-preview]] -A subtle but important difference between *context* and *event* variables is that *context* variables use real values in previews when creating a URL drilldown. -For example, `{{context.panel.filters}}` are previewed with the current filters that applied to a panel. -*Event* variables are extracted during drilldown execution from a user interaction with a panel (for example, from a pie slice that the user clicked on). - -Because there is no user interaction with a panel in preview, there is no interaction context to use in a preview. -To work around this, {kib} provides a sample interaction that relies on a trigger. -So in a preview, you might notice that `{{event.value}}` is replaced with `{{event.value}}` instead of with a sample from your data. -Such previews can help you make sure that the structure of your URL template is valid. -However, to ensure that the configured URL drilldown works as expected with your data, you have to save the dashboard and test in the panel. - +To ensure that the configured URL drilldown works as expected with your data, you have to save the dashboard and test in the panel. You can access the full list of variables available for the current panel and selected trigger by clicking *Add variable* in the top-right corner of a URL template input. [float] @@ -241,6 +231,22 @@ Note: `{{event.value}}` is a shorthand for `{{event.points.[0].value}}` + `{{event.key}}` is a shorthand for `{{event.points.[0].key}}` +| *Row click* +| event.rowIndex +| Number, representing the row that was clicked, starting from 0. + +| +| event.values +| An array of all cell values for the raw on which the action will execute. + +| +| event.keys +| An array of field names for each column. + +| +| event.columnNames +| An array of column names. + | *Range selection* | event.from + event.to diff --git a/packages/kbn-es-archiver/src/lib/__tests__/stats.ts b/packages/kbn-es-archiver/src/lib/stats.test.ts similarity index 84% rename from packages/kbn-es-archiver/src/lib/__tests__/stats.ts rename to packages/kbn-es-archiver/src/lib/stats.test.ts index 0ab7d161feb6e..13f04451ff7e5 100644 --- a/packages/kbn-es-archiver/src/lib/__tests__/stats.ts +++ b/packages/kbn-es-archiver/src/lib/stats.test.ts @@ -19,10 +19,9 @@ import { uniq } from 'lodash'; import sinon from 'sinon'; -import expect from '@kbn/expect'; import { ToolingLog } from '@kbn/dev-utils'; -import { createStats } from '../'; +import { createStats } from './stats'; function createBufferedLog(): ToolingLog & { buffer: string } { const log: ToolingLog = new ToolingLog({ @@ -40,12 +39,12 @@ function assertDeepClones(a: any, b: any) { try { (function recurse(one, two) { if (typeof one !== 'object' || typeof two !== 'object') { - expect(one).to.be(two); + expect(one).toBe(two); return; } - expect(one).to.eql(two); - expect(one).to.not.be(two); + expect(one).toEqual(two); + expect(one).not.toBe(two); const keys = uniq(Object.keys(one).concat(Object.keys(two))); keys.forEach((k) => { path.push(k); @@ -68,14 +67,14 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.skippedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('skipped', true); + expect(indexStats).toHaveProperty('skipped', true); }); it('logs that the index was skipped', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.skippedIndex('index-name'); - expect(log.buffer).to.contain('Skipped'); + expect(log.buffer).toContain('Skipped'); }); }); @@ -84,13 +83,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.deletedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('deleted', true); + expect(indexStats).toHaveProperty('deleted', true); }); it('logs that the index was deleted', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.deletedIndex('index-name'); - expect(log.buffer).to.contain('Deleted'); + expect(log.buffer).toContain('Deleted'); }); }); @@ -99,13 +98,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.createdIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('created', true); + expect(indexStats).toHaveProperty('created', true); }); it('logs that the index was created', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.createdIndex('index-name'); - expect(log.buffer).to.contain('Created'); + expect(log.buffer).toContain('Created'); }); describe('with metadata', () => { it('debug-logs each key from the metadata', async () => { @@ -114,8 +113,8 @@ describe('esArchiver: Stats', () => { stats.createdIndex('index-name', { foo: 'bar', }); - expect(log.buffer).to.contain('debg'); - expect(log.buffer).to.contain('foo "bar"'); + expect(log.buffer).toContain('debg'); + expect(log.buffer).toContain('foo "bar"'); }); }); describe('without metadata', () => { @@ -123,7 +122,7 @@ describe('esArchiver: Stats', () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.createdIndex('index-name'); - expect(log.buffer).to.not.contain('debg'); + expect(log.buffer).not.toContain('debg'); }); }); }); @@ -133,13 +132,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.archivedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('archived', true); + expect(indexStats).toHaveProperty('archived', true); }); it('logs that the index was archived', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.archivedIndex('index-name'); - expect(log.buffer).to.contain('Archived'); + expect(log.buffer).toContain('Archived'); }); describe('with metadata', () => { it('debug-logs each key from the metadata', async () => { @@ -148,8 +147,8 @@ describe('esArchiver: Stats', () => { stats.archivedIndex('index-name', { foo: 'bar', }); - expect(log.buffer).to.contain('debg'); - expect(log.buffer).to.contain('foo "bar"'); + expect(log.buffer).toContain('debg'); + expect(log.buffer).toContain('foo "bar"'); }); }); describe('without metadata', () => { @@ -157,7 +156,7 @@ describe('esArchiver: Stats', () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.archivedIndex('index-name'); - expect(log.buffer).to.not.contain('debg'); + expect(log.buffer).not.toContain('debg'); }); }); }); @@ -166,10 +165,10 @@ describe('esArchiver: Stats', () => { it('increases the docs.indexed count for the index', () => { const stats = createStats('name', new ToolingLog()); stats.indexedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.indexed).to.be(1); + expect(stats.toJSON()['index-name'].docs.indexed).toBe(1); stats.indexedDoc('index-name'); stats.indexedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.indexed).to.be(3); + expect(stats.toJSON()['index-name'].docs.indexed).toBe(3); }); }); @@ -177,10 +176,10 @@ describe('esArchiver: Stats', () => { it('increases the docs.archived count for the index', () => { const stats = createStats('name', new ToolingLog()); stats.archivedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.archived).to.be(1); + expect(stats.toJSON()['index-name'].docs.archived).toBe(1); stats.archivedDoc('index-name'); stats.archivedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.archived).to.be(3); + expect(stats.toJSON()['index-name'].docs.archived).toBe(3); }); }); @@ -189,7 +188,7 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.archivedIndex('index1'); stats.archivedIndex('index2'); - expect(Object.keys(stats.toJSON())).to.eql(['index1', 'index2']); + expect(Object.keys(stats.toJSON())).toEqual(['index1', 'index2']); }); it('returns a deep clone of the stats', () => { const stats = createStats('name', new ToolingLog()); diff --git a/packages/kbn-eslint-plugin-eslint/jest.config.js b/packages/kbn-eslint-plugin-eslint/jest.config.js new file mode 100644 index 0000000000000..f2dbf1268f1c8 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-eslint-plugin-eslint'], +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/client/a.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/client/a.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/client/a.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/client/a.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/b.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/b.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/b.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/b.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/c.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/c.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/c.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/c.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/deep/d.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/deep/d.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/deep/d.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/deep/d.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/index_patterns/index.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/index_patterns/index.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/index_patterns/index.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/index_patterns/index.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js b/packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js similarity index 98% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js rename to packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js index 0bdd4e328b396..8ba42c7b70f40 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js +++ b/packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js @@ -18,7 +18,7 @@ */ const { RuleTester } = require('eslint'); -const rule = require('../disallow_license_headers'); +const rule = require('./disallow_license_headers'); const dedent = require('dedent'); const ruleTester = new RuleTester({ diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js b/packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js similarity index 70% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js rename to packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js index e16ba0d16bb87..516ffc2b17bf7 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js +++ b/packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js @@ -29,7 +29,7 @@ const path = require('path'); const { RuleTester } = require('eslint'); -const rule = require('../no_restricted_paths'); +const rule = require('./no_restricted_paths'); const ruleTester = new RuleTester({ parser: require.resolve('babel-eslint'), @@ -43,14 +43,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { valid: [ { code: 'import a from "../client/a.js"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/other/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/other/**/*', }, ], }, @@ -58,14 +58,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const a = require("../client/a.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/other/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/other/**/*', }, ], }, @@ -73,7 +73,7 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import b from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, @@ -98,14 +98,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'notrequire("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -142,15 +142,15 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { allowSameFolder: true, - target: 'files/no_restricted_paths/**/*', - from: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', + from: '__fixtures__/no_restricted_paths/**/*', }, ], }, @@ -158,15 +158,18 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { allowSameFolder: true, - target: 'files/no_restricted_paths/**/*', - from: ['files/no_restricted_paths/**/*', '!files/no_restricted_paths/server/b*'], + target: '__fixtures__/no_restricted_paths/**/*', + from: [ + '__fixtures__/no_restricted_paths/**/*', + '!__fixtures__/no_restricted_paths/server/b*', + ], }, ], }, @@ -176,16 +179,16 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Check if dirs that start with 'index' work correctly. code: 'import { X } from "./index_patterns"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: ['files/no_restricted_paths/(public|server)/**/*'], + target: ['__fixtures__/no_restricted_paths/(public|server)/**/*'], from: [ - 'files/no_restricted_paths/server/**/*', - '!files/no_restricted_paths/server/index.{ts,tsx}', + '__fixtures__/no_restricted_paths/server/**/*', + '!__fixtures__/no_restricted_paths/server/index.{ts,tsx}', ], allowSameFolder: true, }, @@ -198,14 +201,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { invalid: [ { code: 'export { b } from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -220,14 +223,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import b from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -242,18 +245,18 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import a from "../client/a"\nimport c from "./c"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/client/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/client/**/*', }, { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/server/c.js', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/server/c.js', }, ], }, @@ -273,7 +276,7 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const b = require("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, @@ -295,10 +298,10 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const b = require("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { - basePath: path.join(__dirname, 'files', 'no_restricted_paths'), + basePath: path.join(__dirname, '__fixtures__', 'no_restricted_paths'), zones: [ { target: 'client/**/*', @@ -318,14 +321,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/**/*', - from: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', + from: '__fixtures__/no_restricted_paths/**/*', }, ], }, @@ -342,13 +345,13 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import deeply within Core, using "src/core/..." Webpack alias. code: 'const d = require("src/core/server/saved_objects")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', from: 'src/core/server/**/*', }, ], @@ -366,14 +369,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import "ui/kfetch". code: 'const d = require("ui/kfetch")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { from: ['src/legacy/ui/**/*', 'ui/**/*'], - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', allowSameFolder: true, }, ], @@ -391,14 +394,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import deeply "ui/kfetch/public/index". code: 'const d = require("ui/kfetch/public/index")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { from: ['src/legacy/ui/**/*', 'ui/**/*'], - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', allowSameFolder: true, }, ], @@ -417,16 +420,16 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { // Don't use index*. // It won't work with dirs that start with 'index'. code: 'import { X } from "./index_patterns"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: ['files/no_restricted_paths/(public|server)/**/*'], + target: ['__fixtures__/no_restricted_paths/(public|server)/**/*'], from: [ - 'files/no_restricted_paths/server/**/*', - '!files/no_restricted_paths/server/index*', + '__fixtures__/no_restricted_paths/server/**/*', + '!__fixtures__/no_restricted_paths/server/index*', ], allowSameFolder: true, }, diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js b/packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js similarity index 98% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js rename to packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js index f5d3d6b61c558..f839cc4bad6fa 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js +++ b/packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js @@ -18,7 +18,7 @@ */ const { RuleTester } = require('eslint'); -const rule = require('../require_license_header'); +const rule = require('./require_license_header'); const dedent = require('dedent'); const ruleTester = new RuleTester({ diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 27d7f1af89275..95c425a81c5c9 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -83,10 +83,10 @@ pageLoadAssetSize: transform: 41007 triggersActionsUi: 170001 uiActions: 97717 - uiActionsEnhanced: 349511 + uiActionsEnhanced: 313011 upgradeAssistant: 81241 uptime: 40825 - urlDrilldown: 34174 + urlDrilldown: 70674 urlForwarding: 32579 usageCollection: 39762 visDefaultEditor: 50178 diff --git a/packages/kbn-test-subj-selector/__tests__/index.js b/packages/kbn-test-subj-selector/index.test.js similarity index 66% rename from packages/kbn-test-subj-selector/__tests__/index.js rename to packages/kbn-test-subj-selector/index.test.js index 23165cefec94a..e6a5d0c731205 100755 --- a/packages/kbn-test-subj-selector/__tests__/index.js +++ b/packages/kbn-test-subj-selector/index.test.js @@ -17,19 +17,18 @@ * under the License. */ -const testSubjSelector = require('../'); -const expect = require('@kbn/expect'); +const testSubjSelector = require('./'); describe('testSubjSelector()', function () { it('converts subjectSelectors to cssSelectors', function () { - expect(testSubjSelector('foo bar')).to.eql('[data-test-subj="foo bar"]'); - expect(testSubjSelector('foo > bar')).to.eql('[data-test-subj="foo"] [data-test-subj="bar"]'); - expect(testSubjSelector('foo > bar baz')).to.eql( + expect(testSubjSelector('foo bar')).toEqual('[data-test-subj="foo bar"]'); + expect(testSubjSelector('foo > bar')).toEqual('[data-test-subj="foo"] [data-test-subj="bar"]'); + expect(testSubjSelector('foo > bar baz')).toEqual( '[data-test-subj="foo"] [data-test-subj="bar baz"]' ); - expect(testSubjSelector('foo> ~bar')).to.eql('[data-test-subj="foo"] [data-test-subj~="bar"]'); - expect(testSubjSelector('~ foo')).to.eql('[data-test-subj~="foo"]'); - expect(testSubjSelector('~foo & ~ bar')).to.eql( + expect(testSubjSelector('foo> ~bar')).toEqual('[data-test-subj="foo"] [data-test-subj~="bar"]'); + expect(testSubjSelector('~ foo')).toEqual('[data-test-subj~="foo"]'); + expect(testSubjSelector('~foo & ~ bar')).toEqual( '[data-test-subj~="foo"][data-test-subj~="bar"]' ); }); diff --git a/src/test_utils/jest.config.js b/packages/kbn-test-subj-selector/jest.config.js similarity index 93% rename from src/test_utils/jest.config.js rename to packages/kbn-test-subj-selector/jest.config.js index b7e77413598c0..78ee88aa13c30 100644 --- a/src/test_utils/jest.config.js +++ b/packages/kbn-test-subj-selector/jest.config.js @@ -20,5 +20,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../..', - roots: ['/src/test_utils'], + roots: ['/packages/kbn-test-subj-selector'], }; diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js similarity index 91% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js index 37ea49172d2c4..236e299a48c0c 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js @@ -24,12 +24,12 @@ export default function () { testFiles: [ require.resolve('./tests/before_hook'), require.resolve('./tests/it'), - require.resolve('./tests/after_hook') + require.resolve('./tests/after_hook'), ], services: { hookIntoLIfecycle({ getService }) { const log = getService('log'); - const lifecycle = getService('lifecycle') + const lifecycle = getService('lifecycle'); lifecycle.testFailure.add(async (err, test) => { log.info('testFailure %s %s', err.message, test.fullTitle()); @@ -42,10 +42,10 @@ export default function () { await delay(10); log.info('testHookFailureAfterDelay %s %s', err.message, test.fullTitle()); }); - } + }, }, mochaReporter: { - captureLogOutput: false - } + captureLogOutput: false, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/after_hook.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/after_hook.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/after_hook.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/after_hook.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/before_hook.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/before_hook.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/before_hook.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/before_hook.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/it.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/it.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/it.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/it.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js similarity index 94% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js index 60f0835b25abe..5e9669861656f 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js @@ -20,7 +20,5 @@ import { resolve } from 'path'; export default () => ({ - testFiles: [ - resolve(__dirname, 'tests.js') - ] + testFiles: [resolve(__dirname, 'tests.js')], }); diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/tests.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/tests.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/tests.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/tests.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js b/packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js similarity index 79% rename from packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js index a010d9f0b038e..f36faed361692 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js @@ -20,21 +20,18 @@ import { spawnSync } from 'child_process'; import { resolve } from 'path'; -import expect from '@kbn/expect'; import { REPO_ROOT } from '@kbn/utils'; const SCRIPT = resolve(REPO_ROOT, 'scripts/functional_test_runner.js'); -const BASIC_CONFIG = require.resolve('../fixtures/simple_project/config.js'); +const BASIC_CONFIG = require.resolve('./__fixtures__/simple_project/config.js'); describe('basic config file with a single app and test', function () { - this.timeout(60 * 1000); - it('runs and prints expected output', () => { const proc = spawnSync(process.execPath, [SCRIPT, '--config', BASIC_CONFIG]); const stdout = proc.stdout.toString('utf8'); - expect(stdout).to.contain('$BEFORE$'); - expect(stdout).to.contain('$TESTNAME$'); - expect(stdout).to.contain('$INTEST$'); - expect(stdout).to.contain('$AFTER$'); + expect(stdout).toContain('$BEFORE$'); + expect(stdout).toContain('$TESTNAME$'); + expect(stdout).toContain('$INTEST$'); + expect(stdout).toContain('$AFTER$'); }); }); diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js similarity index 73% rename from packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js index fa4ef88fd3e70..304365694d0a7 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js @@ -21,15 +21,12 @@ import { spawnSync } from 'child_process'; import { resolve } from 'path'; import stripAnsi from 'strip-ansi'; -import expect from '@kbn/expect'; import { REPO_ROOT } from '@kbn/utils'; const SCRIPT = resolve(REPO_ROOT, 'scripts/functional_test_runner.js'); -const FAILURE_HOOKS_CONFIG = require.resolve('../fixtures/failure_hooks/config.js'); +const FAILURE_HOOKS_CONFIG = require.resolve('./__fixtures__/failure_hooks/config.js'); describe('failure hooks', function () { - this.timeout(60 * 1000); - it('runs and prints expected output', () => { const proc = spawnSync(process.execPath, [SCRIPT, '--config', FAILURE_HOOKS_CONFIG]); const lines = stripAnsi(proc.stdout.toString('utf8')).split(/\r?\n/); @@ -37,8 +34,8 @@ describe('failure hooks', function () { { flag: '$FAILING_BEFORE_HOOK$', assert(lines) { - expect(lines.shift()).to.match(/info\s+testHookFailure\s+\$FAILING_BEFORE_ERROR\$/); - expect(lines.shift()).to.match( + expect(lines.shift()).toMatch(/info\s+testHookFailure\s+\$FAILING_BEFORE_ERROR\$/); + expect(lines.shift()).toMatch( /info\s+testHookFailureAfterDelay\s+\$FAILING_BEFORE_ERROR\$/ ); }, @@ -46,16 +43,16 @@ describe('failure hooks', function () { { flag: '$FAILING_TEST$', assert(lines) { - expect(lines.shift()).to.match(/global before each/); - expect(lines.shift()).to.match(/info\s+testFailure\s+\$FAILING_TEST_ERROR\$/); - expect(lines.shift()).to.match(/info\s+testFailureAfterDelay\s+\$FAILING_TEST_ERROR\$/); + expect(lines.shift()).toMatch(/global before each/); + expect(lines.shift()).toMatch(/info\s+testFailure\s+\$FAILING_TEST_ERROR\$/); + expect(lines.shift()).toMatch(/info\s+testFailureAfterDelay\s+\$FAILING_TEST_ERROR\$/); }, }, { flag: '$FAILING_AFTER_HOOK$', assert(lines) { - expect(lines.shift()).to.match(/info\s+testHookFailure\s+\$FAILING_AFTER_ERROR\$/); - expect(lines.shift()).to.match( + expect(lines.shift()).toMatch(/info\s+testHookFailure\s+\$FAILING_AFTER_ERROR\$/); + expect(lines.shift()).toMatch( /info\s+testHookFailureAfterDelay\s+\$FAILING_AFTER_ERROR\$/ ); }, @@ -70,6 +67,6 @@ describe('failure hooks', function () { } } - expect(tests).to.have.length(0); + expect(tests).toHaveLength(0); }); }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js similarity index 95% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js index 3bce2f2250b04..91462dab3b563 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js @@ -19,8 +19,6 @@ export default function () { return { - testFiles: [ - 'config.1' - ] + testFiles: ['config.1'], }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js similarity index 92% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js index 6906779f97ef2..27c5ec44a96f4 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js @@ -21,9 +21,6 @@ export default async function ({ readConfigFile }) { const config1 = await readConfigFile(require.resolve('./config.1.js')); return { - testFiles: [ - ...config1.get('testFiles'), - 'config.2' - ] + testFiles: [...config1.get('testFiles'), 'config.2'], }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js similarity index 92% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js index 94ac54ee81b74..9b9606cba0f59 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js @@ -20,11 +20,9 @@ export default async function ({ readConfigFile }) { const config4 = await readConfigFile(require.resolve('./config.4')); return { - testFiles: [ - 'baz' - ], + testFiles: ['baz'], screenshots: { - ...config4.get('screenshots') - } + ...config4.get('screenshots'), + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js similarity index 96% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js index 60239502602e2..e13347f86b360 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js @@ -20,7 +20,7 @@ export default function () { return { screenshots: { - directory: 'bar' - } + directory: 'bar', + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js similarity index 98% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js index 8da9021a440e5..19b7c2c410bea 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js @@ -19,6 +19,6 @@ export default async function () { return { - foo: 'bar' + foo: 'bar', }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js similarity index 74% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js rename to packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js index 8d02e7262409f..bbe518a3ac355 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js @@ -17,42 +17,40 @@ * under the License. */ -import expect from '@kbn/expect'; - import { ToolingLog } from '@kbn/dev-utils'; -import { readConfigFile } from '../read_config_file'; -import { Config } from '../config'; +import { readConfigFile } from './read_config_file'; +import { Config } from './config'; const log = new ToolingLog(); describe('readConfigFile()', () => { it('reads config from a file, returns an instance of Config class', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.1')); - expect(config).to.be.a(Config); - expect(config.get('testFiles')).to.eql(['config.1']); + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1')); + expect(config instanceof Config).toBeTruthy(); + expect(config.get('testFiles')).toEqual(['config.1']); }); it('merges setting overrides into log', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.1'), { + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1'), { screenshots: { directory: 'foo.bar', }, }); - expect(config.get('screenshots.directory')).to.be('foo.bar'); + expect(config.get('screenshots.directory')).toBe('foo.bar'); }); it('supports loading config files from within config files', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.2')); - expect(config.get('testFiles')).to.eql(['config.1', 'config.2']); + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.2')); + expect(config.get('testFiles')).toEqual(['config.1', 'config.2']); }); it('throws if settings are invalid', async () => { try { - await readConfigFile(log, require.resolve('./fixtures/config.invalid')); + await readConfigFile(log, require.resolve('./__fixtures__/config.invalid')); throw new Error('expected readConfigFile() to fail'); } catch (err) { - expect(err.message).to.match(/"foo"/); + expect(err.message).toMatch(/"foo"/); } }); }); diff --git a/packages/kbn-test/src/mocha/__tests__/fixtures/project/test.js b/packages/kbn-test/src/mocha/__fixtures__/project/test.js similarity index 100% rename from packages/kbn-test/src/mocha/__tests__/fixtures/project/test.js rename to packages/kbn-test/src/mocha/__fixtures__/project/test.js diff --git a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.test.js similarity index 76% rename from packages/kbn-test/src/mocha/__tests__/junit_report_generation.js rename to packages/kbn-test/src/mocha/junit_report_generation.test.js index 605ad38efbc96..03fceca0df32c 100644 --- a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.test.js @@ -24,12 +24,11 @@ import { fromNode as fcb } from 'bluebird'; import { parseString } from 'xml2js'; import del from 'del'; import Mocha from 'mocha'; -import expect from '@kbn/expect'; -import { getUniqueJunitReportPath } from '../../report_path'; +import { getUniqueJunitReportPath } from '../report_path'; -import { setupJUnitReportGeneration } from '../junit_report_generation'; +import { setupJUnitReportGeneration } from './junit_report_generation'; -const PROJECT_DIR = resolve(__dirname, 'fixtures/project'); +const PROJECT_DIR = resolve(__dirname, '__fixtures__/project'); const DURATION_REGEX = /^\d+\.\d{3}$/; const ISO_DATE_SEC_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; const XML_PATH = getUniqueJunitReportPath(PROJECT_DIR, 'test'); @@ -54,7 +53,7 @@ describe('dev/mocha/junit report generation', () => { const report = await fcb((cb) => parseString(readFileSync(XML_PATH), cb)); // test case results are wrapped in - expect(report).to.eql({ + expect(report).toEqual({ testsuites: { testsuite: [report.testsuites.testsuite[0]], }, @@ -62,9 +61,9 @@ describe('dev/mocha/junit report generation', () => { // the single element at the root contains summary data for all tests results const [testsuite] = report.testsuites.testsuite; - expect(testsuite.$.time).to.match(DURATION_REGEX); - expect(testsuite.$.timestamp).to.match(ISO_DATE_SEC_REGEX); - expect(testsuite).to.eql({ + expect(testsuite.$.time).toMatch(DURATION_REGEX); + expect(testsuite.$.timestamp).toMatch(ISO_DATE_SEC_REGEX); + expect(testsuite).toEqual({ $: { failures: '2', name: 'test', @@ -78,13 +77,13 @@ describe('dev/mocha/junit report generation', () => { // there are actually only three tests, but since the hook failed // it is reported as a test failure - expect(testsuite.testcase).to.have.length(4); + expect(testsuite.testcase).toHaveLength(4); const [testPass, testFail, beforeEachFail, testSkipped] = testsuite.testcase; const sharedClassname = testPass.$.classname; - expect(sharedClassname).to.match(/^test\.test[^\.]js$/); - expect(testPass.$.time).to.match(DURATION_REGEX); - expect(testPass).to.eql({ + expect(sharedClassname).toMatch(/^test\.test[^\.]js$/); + expect(testPass.$.time).toMatch(DURATION_REGEX); + expect(testPass).toEqual({ $: { classname: sharedClassname, name: 'SUITE works', @@ -94,9 +93,10 @@ describe('dev/mocha/junit report generation', () => { 'system-out': testPass['system-out'], }); - expect(testFail.$.time).to.match(DURATION_REGEX); - expect(testFail.failure[0]).to.match(/Error: FORCE_TEST_FAIL\n.+fixtures.project.test.js/); - expect(testFail).to.eql({ + expect(testFail.$.time).toMatch(DURATION_REGEX); + + expect(testFail.failure[0]).toMatch(/Error: FORCE_TEST_FAIL/); + expect(testFail).toEqual({ $: { classname: sharedClassname, name: 'SUITE fails', @@ -107,12 +107,10 @@ describe('dev/mocha/junit report generation', () => { failure: [testFail.failure[0]], }); - expect(beforeEachFail.$.time).to.match(DURATION_REGEX); - expect(beforeEachFail.failure).to.have.length(1); - expect(beforeEachFail.failure[0]).to.match( - /Error: FORCE_HOOK_FAIL\n.+fixtures.project.test.js/ - ); - expect(beforeEachFail).to.eql({ + expect(beforeEachFail.$.time).toMatch(DURATION_REGEX); + expect(beforeEachFail.failure).toHaveLength(1); + expect(beforeEachFail.failure[0]).toMatch(/Error: FORCE_HOOK_FAIL/); + expect(beforeEachFail).toEqual({ $: { classname: sharedClassname, name: 'SUITE SUB_SUITE "before each" hook: fail hook for "never runs"', @@ -123,7 +121,7 @@ describe('dev/mocha/junit report generation', () => { failure: [beforeEachFail.failure[0]], }); - expect(testSkipped).to.eql({ + expect(testSkipped).toEqual({ $: { classname: sharedClassname, name: 'SUITE SUB_SUITE never runs', diff --git a/src/core/public/application/application_leave.test.ts b/src/core/public/application/application_leave.test.ts index b560bbc0cbc25..9d0da6fe0096d 100644 --- a/src/core/public/application/application_leave.test.ts +++ b/src/core/public/application/application_leave.test.ts @@ -35,7 +35,18 @@ describe('getLeaveAction', () => { type: AppLeaveActionType.default, }); }); + + it('returns the default action provided by the handle and nextAppId', () => { + expect(getLeaveAction((actions) => actions.default(), 'futureAppId')).toEqual({ + type: AppLeaveActionType.default, + }); + }); + it('returns the confirm action provided by the handler', () => { + expect(getLeaveAction((actions) => actions.confirm('some message'), 'futureAppId')).toEqual({ + type: AppLeaveActionType.confirm, + text: 'some message', + }); expect(getLeaveAction((actions) => actions.confirm('some message'))).toEqual({ type: AppLeaveActionType.confirm, text: 'some message', @@ -45,5 +56,14 @@ describe('getLeaveAction', () => { text: 'another message', title: 'a title', }); + const callback = jest.fn(); + expect( + getLeaveAction((actions) => actions.confirm('another message', 'a title', callback)) + ).toEqual({ + type: AppLeaveActionType.confirm, + text: 'another message', + title: 'a title', + callback, + }); }); }); diff --git a/src/core/public/application/application_leave.tsx b/src/core/public/application/application_leave.tsx index 7b69d70d3f6f6..e6170daaff0a0 100644 --- a/src/core/public/application/application_leave.tsx +++ b/src/core/public/application/application_leave.tsx @@ -26,8 +26,8 @@ import { } from './types'; const appLeaveActionFactory: AppLeaveActionFactory = { - confirm(text: string, title?: string) { - return { type: AppLeaveActionType.confirm, text, title }; + confirm(text: string, title?: string, callback?: () => void) { + return { type: AppLeaveActionType.confirm, text, title, callback }; }, default() { return { type: AppLeaveActionType.default }; @@ -38,9 +38,9 @@ export function isConfirmAction(action: AppLeaveAction): action is AppLeaveConfi return action.type === AppLeaveActionType.confirm; } -export function getLeaveAction(handler?: AppLeaveHandler): AppLeaveAction { +export function getLeaveAction(handler?: AppLeaveHandler, nextAppId?: string): AppLeaveAction { if (!handler) { return appLeaveActionFactory.default(); } - return handler(appLeaveActionFactory); + return handler(appLeaveActionFactory, nextAppId); } diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index cd186f87b3a87..912ab40cbe1db 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -755,6 +755,19 @@ describe('#start()', () => { `); }); + it('should call private function shouldNavigate with overlays and the nextAppId', async () => { + service.setup(setupDeps); + const shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate'); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('myTestApp'); + expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myTestApp'); + + await navigateToApp('myOtherApp'); + expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myOtherApp'); + }); + describe('when `replace` option is true', () => { it('use `history.replace` instead of `history.push`', async () => { service.setup(setupDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 4d54d4831698b..67281170957c6 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -244,7 +244,9 @@ export class ApplicationService { ) => { const currentAppId = this.currentAppId$.value; const navigatingToSameApp = currentAppId === appId; - const shouldNavigate = navigatingToSameApp ? true : await this.shouldNavigate(overlays); + const shouldNavigate = navigatingToSameApp + ? true + : await this.shouldNavigate(overlays, appId); if (shouldNavigate) { if (path === undefined) { @@ -332,18 +334,24 @@ export class ApplicationService { this.currentActionMenu$.next(currentActionMenu); }; - private async shouldNavigate(overlays: OverlayStart): Promise { + private async shouldNavigate(overlays: OverlayStart, nextAppId: string): Promise { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { return true; } - const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); + const action = getLeaveAction( + this.appInternalStates.get(currentAppId)?.leaveHandler, + nextAppId + ); if (isConfirmAction(action)) { const confirmed = await overlays.openConfirm(action.text, { title: action.title, 'data-test-subj': 'appLeaveConfirmModal', }); if (!confirmed) { + if (action.callback) { + setTimeout(action.callback, 0); + } return false; } } diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index d9f326c7a59ab..c161a7f166541 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -578,7 +578,10 @@ export interface AppMountParameters { * * @public */ -export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export type AppLeaveHandler = ( + factory: AppLeaveActionFactory, + nextAppId?: string +) => AppLeaveAction; /** * Possible type of actions on application leave. @@ -614,6 +617,7 @@ export interface AppLeaveConfirmAction { type: AppLeaveActionType.confirm; text: string; title?: string; + callback?: () => void; } /** @@ -636,8 +640,10 @@ export interface AppLeaveActionFactory { * * @param text The text to display in the confirmation message * @param title (optional) title to display in the confirmation message + * @param callback (optional) to know that the user want to stay on the page + * so we can show to the user the right UX for him to saved his/her/their changes */ - confirm(text: string, title?: string): AppLeaveConfirmAction; + confirm(text: string, title?: string, callback?: () => void): AppLeaveConfirmAction; /** * Returns a default action, resulting on executing the default behavior when * the user tries to leave an application diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index cc1e0851f5944..6483ede0564c1 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -27,6 +27,7 @@ export { ChromeHelpExtension, } from './chrome_service'; export { + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 5ce5a5f635d64..46ed76e72db02 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -2041,7 +2041,7 @@ exports[`Header renders 1`] = ` } /> , - , - - + + } - kibanaDocLink="/docs" - kibanaVersion="1.0.0" - useDefaultContent={true} + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + repositionOnScroll={true} > - - - - } - closePopover={[Function]} - data-test-subj="helpMenuButton" - display="inlineBlock" - hasArrow={true} - id="headerHelpMenu" - isOpen={false} - ownFocus={false} - panelPaddingSize="m" - repositionOnScroll={true} + -
-
- - - -
+ /> + + +
-
-
-
-
+ + + + , , ], diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index c7dd8c7e8bb1c..ad8767cefaba3 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -17,10 +17,10 @@ * under the License. */ -import * as Rx from 'rxjs'; import React, { Component, Fragment } from 'react'; +import { combineLatest, Observable, Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { InjectedIntl, injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiButtonEmptyProps, @@ -35,14 +35,20 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { ExclusiveUnion } from '@elastic/eui'; -import { combineLatest } from 'rxjs'; -import { HeaderExtension } from './header_extension'; -import { ChromeHelpExtension } from '../../chrome_service'; +import { InternalApplicationStart } from '../../../application'; import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants'; +import { ChromeHelpExtension } from '../../chrome_service'; +import { HeaderExtension } from './header_extension'; +import { isModifiedOrPrevented } from './nav_link'; + +/** @public */ +export type ChromeHelpExtensionLinkBase = Pick< + EuiButtonEmptyProps, + 'iconType' | 'target' | 'rel' | 'data-test-subj' +>; /** @public */ -export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase { /** * Creates a link to a new github issue in the Kibana repo */ @@ -55,10 +61,10 @@ export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { * Provides initial text for the title of the issue */ title?: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase { /** * Creates a generic give feedback link with comment icon */ @@ -68,10 +74,10 @@ export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { * i.e. `https://discuss.elastic.co/c/${appName}` */ href: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase { /** * Creates a deep-link to app-specific documentation */ @@ -81,35 +87,36 @@ export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { * i.e. `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html`, */ href: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase { /** * Extend EuiButtonEmpty to provide extra functionality */ linkType: 'custom'; + /** + * URL of the link + */ + href: string; /** * Content of the button (in lieu of `children`) */ content: React.ReactNode; -}; +} /** @public */ -export type ChromeHelpExtensionMenuLink = ExclusiveUnion< - ChromeHelpExtensionMenuGitHubLink, - ExclusiveUnion< - ChromeHelpExtensionMenuDiscussLink, - ExclusiveUnion - > ->; +export type ChromeHelpExtensionMenuLink = + | ChromeHelpExtensionMenuGitHubLink + | ChromeHelpExtensionMenuDiscussLink + | ChromeHelpExtensionMenuDocumentationLink + | ChromeHelpExtensionMenuCustomLink; interface Props { - helpExtension$: Rx.Observable; - helpSupportUrl$: Rx.Observable; - intl: InjectedIntl; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + helpExtension$: Observable; + helpSupportUrl$: Observable; kibanaVersion: string; - useDefaultContent?: boolean; kibanaDocLink: string; } @@ -119,8 +126,8 @@ interface State { helpSupportUrl: string; } -class HeaderHelpMenuUI extends Component { - private subscription?: Rx.Subscription; +export class HeaderHelpMenu extends Component { + private subscription?: Subscription; constructor(props: Props) { super(props); @@ -151,41 +158,69 @@ class HeaderHelpMenuUI extends Component { } } - createGithubUrl = (labels: string[], title?: string) => { - const url = new URL('https://github.com/elastic/kibana/issues/new?'); - - if (labels.length) { - url.searchParams.set('labels', labels.join(',')); - } + public render() { + const { kibanaVersion } = this.props; - if (title) { - url.searchParams.set('title', title); - } + const defaultContent = this.renderDefaultContent(); + const customContent = this.renderCustomContent(); - return url.toString(); - }; + const button = ( + + + + ); - createCustomLink = ( - index: number, - text: React.ReactNode, - addSpacer?: boolean, - buttonProps?: EuiButtonEmptyProps - ) => { return ( - - - {text} - - {addSpacer && } - + + + + +

+ +

+
+ + + +
+
+ +
+ {defaultContent} + {defaultContent && customContent && } + {customContent} +
+
); - }; + } - public render() { - const { intl, kibanaVersion, useDefaultContent, kibanaDocLink } = this.props; - const { helpExtension, helpSupportUrl } = this.state; + private renderDefaultContent() { + const { kibanaDocLink } = this.props; + const { helpSupportUrl } = this.state; - const defaultContent = useDefaultContent ? ( + return ( { /> - ) : null; - - let customContent; - if (helpExtension) { - const { appName, links, content } = helpExtension; - - const getFeedbackText = () => - i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackOnApp', { - defaultMessage: 'Give feedback on {appName}', - values: { appName: helpExtension.appName }, - }); - - const customLinks = - links && - links.map((link, index) => { - const { linkType, title, labels = [], content: text, ...rest } = link; - switch (linkType) { - case 'documentation': - return this.createCustomLink( - index, - , - index < links.length - 1, - { - target: '_blank', - rel: 'noopener', - ...rest, - } - ); - case 'github': - return this.createCustomLink(index, getFeedbackText(), index < links.length - 1, { - iconType: 'logoGithub', - href: this.createGithubUrl(labels, title), - target: '_blank', - rel: 'noopener', - ...rest, - }); - case 'discuss': - return this.createCustomLink(index, getFeedbackText(), index < links.length - 1, { - iconType: 'editorComment', + ); + } + + private renderCustomContent() { + const { helpExtension } = this.state; + if (!helpExtension) { + return null; + } + const { navigateToUrl } = this.props; + const { appName, links, content } = helpExtension; + + const getFeedbackText = () => + i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackOnApp', { + defaultMessage: 'Give feedback on {appName}', + values: { appName: helpExtension.appName }, + }); + + const customLinks = + links && + links.map((link, index) => { + const addSpacer = index < links.length - 1; + switch (link.linkType) { + case 'documentation': { + const { linkType, ...rest } = link; + return createCustomLink( + index, + , + addSpacer, + { target: '_blank', rel: 'noopener', ...rest, - }); - case 'custom': - return this.createCustomLink(index, text, index < links.length - 1, { ...rest }); - default: - break; + } + ); } - }); - - customContent = ( - <> - -

{appName}

-
- - {customLinks} - {content && ( - <> - {customLinks && } - - - )} - - ); - } - - const button = ( - - - - ); + case 'github': { + const { linkType, labels, title, ...rest } = link; + return createCustomLink(index, getFeedbackText(), addSpacer, { + iconType: 'logoGithub', + href: createGithubUrl(labels, title), + target: '_blank', + rel: 'noopener', + ...rest, + }); + } + case 'discuss': { + const { linkType, ...rest } = link; + return createCustomLink(index, getFeedbackText(), addSpacer, { + iconType: 'editorComment', + target: '_blank', + rel: 'noopener', + ...rest, + }); + } + case 'custom': { + const { linkType, content: text, href, ...rest } = link; + return createCustomLink(index, text, addSpacer, { + href, + onClick: this.createOnClickHandler(href, navigateToUrl), + ...rest, + }); + } + default: + break; + } + }); return ( - - - - -

- -

-
- - - -
-
- -
- {defaultContent} - {defaultContent && customContent && } - {customContent} -
-
+ <> + +

{appName}

+
+ + {customLinks} + {content && ( + <> + {customLinks && } + + + )} + ); } @@ -361,10 +360,44 @@ class HeaderHelpMenuUI extends Component { isOpen: false, }); }; + + private createOnClickHandler(href: string, navigate: Props['navigateToUrl']) { + return (event: React.MouseEvent) => { + if (!isModifiedOrPrevented(event) && event.button === 0) { + event.preventDefault(); + this.closeMenu(); + navigate(href); + } + }; + } } -export const HeaderHelpMenu = injectI18n(HeaderHelpMenuUI); +const createGithubUrl = (labels: string[], title?: string) => { + const url = new URL('https://github.com/elastic/kibana/issues/new?'); + + if (labels.length) { + url.searchParams.set('labels', labels.join(',')); + } + + if (title) { + url.searchParams.set('title', title); + } + + return url.toString(); +}; -HeaderHelpMenu.defaultProps = { - useDefaultContent: true, +const createCustomLink = ( + index: number, + text: React.ReactNode, + addSpacer?: boolean, + buttonProps?: EuiButtonEmptyProps +) => { + return ( + + + {text} + + {addSpacer && } + + ); }; diff --git a/src/core/public/chrome/ui/header/index.ts b/src/core/public/chrome/ui/header/index.ts index a492273a65ba8..d75cd10af7bed 100644 --- a/src/core/public/chrome/ui/header/index.ts +++ b/src/core/public/chrome/ui/header/index.ts @@ -20,6 +20,7 @@ export { Header, HeaderProps } from './header'; export { OnIsLockedUpdate, NavType } from './types'; export { + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/chrome/ui/index.ts b/src/core/public/chrome/ui/index.ts index 4f6ad90cb96a3..4894aaac7c914 100644 --- a/src/core/public/chrome/ui/index.ts +++ b/src/core/public/chrome/ui/index.ts @@ -20,6 +20,7 @@ export { LoadingIndicator } from './loading_indicator'; export { Header, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 8e240bfe91d48..2e1238df350e0 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -43,6 +43,7 @@ import { ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, ChromeHelpExtensionMenuDocumentationLink, @@ -300,6 +301,7 @@ export { ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, ChromeHelpExtensionMenuDocumentationLink, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 65912e0954261..3852792547062 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -14,7 +14,6 @@ import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; -import { ExclusiveUnion } from '@elastic/eui'; import { History } from 'history'; import { Href } from 'history'; import { IconType } from '@elastic/eui'; @@ -92,6 +91,8 @@ export enum AppLeaveActionType { // // @public export interface AppLeaveConfirmAction { + // (undocumented) + callback?: () => void; // (undocumented) text: string; // (undocumented) @@ -111,7 +112,7 @@ export interface AppLeaveDefaultAction { // Warning: (ae-forgotten-export) The symbol "AppLeaveActionFactory" needs to be exported by the entry point index.d.ts // // @public -export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export type AppLeaveHandler = (factory: AppLeaveActionFactory, nextAppId?: string) => AppLeaveAction; // @public (undocumented) export interface ApplicationSetup { @@ -250,32 +251,36 @@ export interface ChromeHelpExtension { } // @public (undocumented) -export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { - linkType: 'custom'; +export type ChromeHelpExtensionLinkBase = Pick; + +// @public (undocumented) +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase { content: React.ReactNode; -}; + href: string; + linkType: 'custom'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { - linkType: 'discuss'; +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase { href: string; -}; + linkType: 'discuss'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { - linkType: 'documentation'; +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase { href: string; -}; + linkType: 'documentation'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { - linkType: 'github'; +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase { labels: string[]; + linkType: 'github'; title?: string; -}; +} // @public (undocumented) -export type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; +export type ChromeHelpExtensionMenuLink = ChromeHelpExtensionMenuGitHubLink | ChromeHelpExtensionMenuDiscussLink | ChromeHelpExtensionMenuDocumentationLink | ChromeHelpExtensionMenuCustomLink; // @public (undocumented) export interface ChromeNavControl { diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 22cb7275b6a23..27f3ab30ac8f3 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -120,97 +120,43 @@ describe('configureClient', () => { }); describe('Client logging', () => { - it('logs error when the client emits an @elastic/elasticsearch error', () => { - const client = configureClient(config, { logger, scoped: false }); - - const response = createApiResponse({ body: {} }); - client.emit('response', new errors.TimeoutError('message', response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[TimeoutError]: message", - ], - ] - `); - }); - - it('logs error when the client emits an ResponseError returned by elasticsearch', () => { - const client = configureClient(config, { logger, scoped: false }); - - const response = createApiResponse({ - statusCode: 400, - headers: {}, - body: { - error: { - type: 'illegal_argument_exception', - reason: 'request [/_path] contains unrecognized parameter: [name]', - }, + function createResponseWithBody(body?: RequestBody) { + return createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body, }, }); - client.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", - ], - ] - `); - }); - - it('logs default error info when the error response body is empty', () => { - const client = configureClient(config, { logger, scoped: false }); + } + describe('does not log whrn "logQueries: false"', () => { + it('response', () => { + const client = configureClient(config, { logger, scoped: false }); + const response = createResponseWithBody({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }); - let response = createApiResponse({ - statusCode: 400, - headers: {}, - body: { - error: {}, - }, + client.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toHaveLength(0); }); - client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[ResponseError]: Response Error", - ], - ] - `); + it('error', () => { + const client = configureClient(config, { logger, scoped: false }); - logger.error.mockClear(); + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); - response = createApiResponse({ - statusCode: 400, - headers: {}, - body: {} as any, + expect(loggingSystemMock.collect(logger).error).toHaveLength(0); }); - client.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[ResponseError]: Response Error", - ], - ] - `); }); describe('logs each queries if `logQueries` is true', () => { - function createResponseWithBody(body?: RequestBody) { - return createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, - body, - }, - }); - } - it('when request body is an object', () => { const client = configureClient( createFakeConfig({ @@ -374,108 +320,211 @@ describe('configureClient', () => { ] `); }); - }); - it('properly encode queries', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + it('properly encode queries', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { city: 'Münich' }, - }, + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { city: 'Münich' }, + }, + }); + + client.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?city=M%C3%BCnich", + Object { + "tags": Array [ + "query", + ], + }, + ], + ] + `); }); - client.emit('response', null, response); + it('logs queries even in case of errors if `logQueries` is true', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?city=M%C3%BCnich", - Object { - "tags": Array [ - "query", - ], + const response = createApiResponse({ + statusCode: 500, + body: { + error: { + type: 'internal server error', }, - ], - ] - `); - }); + }, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body: { + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }, + }, + }); + client.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "500 + GET /foo?hello=dolly + {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}} [internal server error]: internal server error", + ], + ] + `); + }); - it('logs queries even in case of errors if `logQueries` is true', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); - - const response = createApiResponse({ - statusCode: 500, - body: { - error: { - type: 'internal server error', + it('does not log queries if `logQueries` is false', () => { + const client = configureClient( + createFakeConfig({ + logQueries: false, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + }, + }); + + client.emit('response', null, response); + + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('logs error when the client emits an @elastic/elasticsearch error', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "[TimeoutError]: message", + ], + ] + `); + }); + + it('logs error when the client emits an ResponseError returned by elasticsearch', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, }, - }, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, body: { - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, + error: { + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', }, }, - }, - }); - client.emit('response', new errors.ResponseError(response), response); + }); + client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ - "500 - GET /foo?hello=dolly - {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - Object { - "tags": Array [ - "query", - ], - }, - ], - ] - `); - }); + Array [ + "400 + GET /_path?hello=dolly [illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", + ], + ] + `); + }); - it('does not log queries if `logQueries` is false', () => { - const client = configureClient( - createFakeConfig({ - logQueries: false, - }), - { logger, scoped: false } - ); + it('logs default error info when the error response body is empty', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - }, - }); + let response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: { + error: {}, + }, + }); + client.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: Response Error", + ], + ] + `); + + logger.error.mockClear(); - client.emit('response', null, response); + response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: {} as any, + }); + client.emit('response', new errors.ResponseError(response), response); - expect(logger.debug).not.toHaveBeenCalled(); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: Response Error", + ], + ] + `); + }); }); }); }); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index bf07ea604d228..920a713a60332 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -18,9 +18,8 @@ */ import { Buffer } from 'buffer'; import { stringify } from 'querystring'; -import { Client } from '@elastic/elasticsearch'; -import { RequestBody } from '@elastic/elasticsearch/lib/Transport'; - +import { ApiError, Client, RequestEvent, errors } from '@elastic/elasticsearch'; +import type { RequestBody } from '@elastic/elasticsearch/lib/Transport'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; @@ -36,29 +35,6 @@ export const configureClient = ( return client; }; -const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { - client.on('response', (error, event) => { - if (error) { - const errorMessage = - // error details for response errors provided by elasticsearch, defaults to error name/message - `[${event.body?.error?.type ?? error.name}]: ${event.body?.error?.reason ?? error.message}`; - - logger.error(errorMessage); - } - if (event && logQueries) { - const params = event.meta.request.params; - - // definition is wrong, `params.querystring` can be either a string or an object - const querystring = convertQueryString(params.querystring); - const url = `${params.path}${querystring ? `?${querystring}` : ''}`; - const body = params.body ? `\n${ensureString(params.body)}` : ''; - logger.debug(`${event.statusCode}\n${params.method} ${url}${body}`, { - tags: ['query'], - }); - } - }); -}; - const convertQueryString = (qs: string | Record | undefined): string => { if (qs === undefined || typeof qs === 'string') { return qs ?? ''; @@ -72,3 +48,45 @@ function ensureString(body: RequestBody): string { if ('readable' in body && body.readable && typeof body._read === 'function') return '[stream]'; return JSON.stringify(body); } + +function getErrorMessage(error: ApiError, event: RequestEvent): string { + if (error instanceof errors.ResponseError) { + return `${getResponseMessage(event)} [${event.body?.error?.type}]: ${ + event.body?.error?.reason ?? error.message + }`; + } + return `[${error.name}]: ${error.message}`; +} + +/** + * returns a string in format: + * + * status code + * URL + * request body + * + * so it could be copy-pasted into the Dev console + */ +function getResponseMessage(event: RequestEvent): string { + const params = event.meta.request.params; + + // definition is wrong, `params.querystring` can be either a string or an object + const querystring = convertQueryString(params.querystring); + const url = `${params.path}${querystring ? `?${querystring}` : ''}`; + const body = params.body ? `\n${ensureString(params.body)}` : ''; + return `${event.statusCode}\n${params.method} ${url}${body}`; +} + +const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { + client.on('response', (error, event) => { + if (event && logQueries) { + if (error) { + logger.error(getErrorMessage(error, event)); + } else { + logger.debug(getResponseMessage(event), { + tags: ['query'], + }); + } + } + }); +}; diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index a440c67944fab..9b667f888771e 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -39,9 +39,11 @@ Object { "enabled": true, }, "cors": Object { - "credentials": false, + "allowCredentials": false, + "allowOrigin": Array [ + "*", + ], "enabled": false, - "origin": "*", }, "customResponseHeaders": Object {}, "host": "localhost", diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index f893e7783ac8f..b71763e8a2e14 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -331,51 +331,67 @@ describe('with compression', () => { }); describe('cors', () => { - describe('origin', () => { + describe('allowOrigin', () => { it('list cannot be empty', () => { expect(() => config.schema.validate({ cors: { - origin: [], + allowOrigin: [], }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[cors.origin]: types that failed validation: - - [cors.origin.0]: expected value to equal [*] - - [cors.origin.1]: array size is [0], but cannot be smaller than [1]" - `); + "[cors.allowOrigin]: types that failed validation: + - [cors.allowOrigin.0]: array size is [0], but cannot be smaller than [1] + - [cors.allowOrigin.1]: array size is [0], but cannot be smaller than [1]" + `); }); it('list of valid URLs', () => { - const origin = ['http://127.0.0.1:3000', 'https://elastic.co']; + const allowOrigin = ['http://127.0.0.1:3000', 'https://elastic.co']; expect( config.schema.validate({ - cors: { origin }, - }).cors.origin - ).toStrictEqual(origin); + cors: { allowOrigin }, + }).cors.allowOrigin + ).toStrictEqual(allowOrigin); expect(() => config.schema.validate({ cors: { - origin: ['*://elastic.co/*'], + allowOrigin: ['*://elastic.co/*'], }, }) ).toThrow(); }); it('can be configured as "*" wildcard', () => { - expect(config.schema.validate({ cors: { origin: '*' } }).cors.origin).toBe('*'); + expect(config.schema.validate({ cors: { allowOrigin: ['*'] } }).cors.allowOrigin).toEqual([ + '*', + ]); + }); + + it('cannot mix wildcard "*" with valid URLs', () => { + expect( + () => + config.schema.validate({ cors: { allowOrigin: ['*', 'https://elastic.co'] } }).cors + .allowOrigin + ).toThrowErrorMatchingInlineSnapshot(` + "[cors.allowOrigin]: types that failed validation: + - [cors.allowOrigin.0.0]: expected URI with scheme [http|https]. + - [cors.allowOrigin.1.1]: expected value to equal [*]" + `); }); }); describe('credentials', () => { - it('cannot use wildcard origin if "credentials: true"', () => { + it('cannot use wildcard allowOrigin if "credentials: true"', () => { expect( - () => config.schema.validate({ cors: { credentials: true, origin: '*' } }).cors.origin + () => + config.schema.validate({ cors: { allowCredentials: true, allowOrigin: ['*'] } }).cors + .allowOrigin ).toThrowErrorMatchingInlineSnapshot( `"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."` ); expect( - () => config.schema.validate({ cors: { credentials: true } }).cors.origin + () => config.schema.validate({ cors: { allowCredentials: true } }).cors.allowOrigin ).toThrowErrorMatchingInlineSnapshot( `"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."` ); diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 74cdbfbedeea9..2bd296fe338ab 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -48,17 +48,20 @@ export const config = { cors: schema.object( { enabled: schema.boolean({ defaultValue: false }), - credentials: schema.boolean({ defaultValue: false }), - origin: schema.oneOf( - [schema.literal('*'), schema.arrayOf(hostURISchema, { minSize: 1 })], + allowCredentials: schema.boolean({ defaultValue: false }), + allowOrigin: schema.oneOf( + [ + schema.arrayOf(hostURISchema, { minSize: 1 }), + schema.arrayOf(schema.literal('*'), { minSize: 1, maxSize: 1 }), + ], { - defaultValue: '*', + defaultValue: ['*'], } ), }, { validate(value) { - if (value.credentials === true && value.origin === '*') { + if (value.allowCredentials === true && value.allowOrigin.includes('*')) { return 'Cannot specify wildcard origin "*" with "credentials: true". Please provide a list of allowed origins.'; } }, @@ -168,8 +171,8 @@ export class HttpConfig { public port: number; public cors: { enabled: boolean; - credentials: boolean; - origin: '*' | string[]; + allowCredentials: boolean; + allowOrigin: string[]; }; public customResponseHeaders: Record; public maxPayload: ByteSizeValue; diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 4098b631b19d8..962c2107513b5 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -196,8 +196,8 @@ describe('getServerOptions', () => { config.schema.validate({ cors: { enabled: true, - credentials: false, - origin: '*', + allowCredentials: false, + allowOrigin: ['*'], }, }), {} as any, @@ -206,7 +206,7 @@ describe('getServerOptions', () => { expect(getServerOptions(httpConfig).routes?.cors).toEqual({ credentials: false, - origin: '*', + origin: ['*'], headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'], }); }); diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 61688a51345b5..8bec26f31fa26 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -39,8 +39,8 @@ const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None- export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) { const cors: RouteOptionsCors | false = config.cors.enabled ? { - credentials: config.cors.credentials, - origin: config.cors.origin, + credentials: config.cors.allowCredentials, + origin: config.cors.allowOrigin, headers: corsAllowedHeaders, } : false; diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/either.test.js b/src/dev/code_coverage/ingest_coverage/either.test.js similarity index 73% rename from src/dev/code_coverage/ingest_coverage/__tests__/either.test.js rename to src/dev/code_coverage/ingest_coverage/either.test.js index 0ae55508e8434..a64d6c29feee7 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/either.test.js +++ b/src/dev/code_coverage/ingest_coverage/either.test.js @@ -17,27 +17,26 @@ * under the License. */ -import * as Either from '../either'; -import { noop } from '../utils'; -import expect from '@kbn/expect'; +import * as Either from './either'; +import { noop } from './utils'; const pluck = (x) => (obj) => obj[x]; -const expectNull = (x) => expect(x).to.equal(null); +const expectNull = (x) => expect(x).toBeNull(); const attempt = (obj) => Either.fromNullable(obj).map(pluck('detail')); describe(`either datatype functions`, () => { describe(`helpers`, () => { it(`'fromNullable' should be a fn`, () => { - expect(typeof Either.fromNullable).to.be('function'); + expect(typeof Either.fromNullable).toBe('function'); }); it(`' Either.tryCatch' should be a fn`, () => { - expect(typeof Either.tryCatch).to.be('function'); + expect(typeof Either.tryCatch).toBe('function'); }); it(`'left' should be a fn`, () => { - expect(typeof Either.left).to.be('function'); + expect(typeof Either.left).toBe('function'); }); it(`'right' should be a fn`, () => { - expect(typeof Either.right).to.be('function'); + expect(typeof Either.right).toBe('function'); }); }); describe(' Either.tryCatch', () => { @@ -46,18 +45,18 @@ describe(`either datatype functions`, () => { sut = Either.tryCatch(() => { throw new Error('blah'); }); - expect(sut.inspect()).to.be('Left(Error: blah)'); + expect(sut.inspect()).toBe('Left(Error: blah)'); }); it(`should return a 'Right' on successful execution`, () => { sut = Either.tryCatch(noop); - expect(sut.inspect()).to.be('Right(undefined)'); + expect(sut.inspect()).toBe('Right(undefined)'); }); }); describe(`fromNullable`, () => { it(`should continue processing if a truthy is calculated`, () => { attempt({ detail: 'x' }).fold( () => {}, - (x) => expect(x).to.equal('x') + (x) => expect(x).toBe('x') ); }); it(`should drop processing if a falsey is calculated`, () => { @@ -66,16 +65,16 @@ describe(`either datatype functions`, () => { }); describe(`predicate fns`, () => { it(`right.isRight() is true`, () => { - expect(Either.right('a').isRight()).to.be(true); + expect(Either.right('a').isRight()).toBe(true); }); it(`right.isLeft() is false`, () => { - expect(Either.right('a').isLeft()).to.be(false); + expect(Either.right('a').isLeft()).toBe(false); }); it(`left.isLeft() is true`, () => { - expect(Either.left().isLeft()).to.be(true); + expect(Either.left().isLeft()).toBe(true); }); it(`left.isRight() is true`, () => { - expect(Either.left().isRight()).to.be(false); + expect(Either.left().isRight()).toBe(false); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js b/src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js similarity index 87% rename from src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js rename to src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js index f668c1f86f5b0..edc6f714beb6a 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js +++ b/src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js @@ -17,9 +17,8 @@ * under the License. */ -import expect from '@kbn/expect'; -import { whichIndex } from '../ingest_helpers'; -import { TOTALS_INDEX, RESEARCH_TOTALS_INDEX, RESEARCH_COVERAGE_INDEX } from '../constants'; +import { whichIndex } from './ingest_helpers'; +import { TOTALS_INDEX, RESEARCH_TOTALS_INDEX, RESEARCH_COVERAGE_INDEX } from './constants'; describe(`Ingest Helper fns`, () => { describe(`whichIndex`, () => { @@ -29,14 +28,14 @@ describe(`Ingest Helper fns`, () => { const isTotal = true; it(`should return the Research Totals Index`, () => { const actual = whichIndexAgainstResearchJob(isTotal); - expect(actual).to.be(RESEARCH_TOTALS_INDEX); + expect(actual).toBe(RESEARCH_TOTALS_INDEX); }); }); describe(`against the coverage index`, () => { it(`should return the Research Totals Index`, () => { const isTotal = false; const actual = whichIndexAgainstResearchJob(isTotal); - expect(actual).to.be(RESEARCH_COVERAGE_INDEX); + expect(actual).toBe(RESEARCH_COVERAGE_INDEX); }); }); }); @@ -46,7 +45,7 @@ describe(`Ingest Helper fns`, () => { const isTotal = true; it(`should return the "Prod" Totals Index`, () => { const actual = whichIndexAgainstProdJob(isTotal); - expect(actual).to.be(TOTALS_INDEX); + expect(actual).toBe(TOTALS_INDEX); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js b/src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js similarity index 92% rename from src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js rename to src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js index 371695337ed56..84cedbc75be5a 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js @@ -17,8 +17,7 @@ * under the License. */ -import expect from '@kbn/expect'; -import { enumeratePatterns } from '../team_assignment/enumerate_patterns'; +import { enumeratePatterns } from './enumerate_patterns'; import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; const log = new ToolingLog({ @@ -36,14 +35,14 @@ describe(`enumeratePatterns`, () => { actual[0].includes( 'x-pack/plugins/reporting/server/browsers/extract/unzip.js kibana-reporting' ) - ).to.be(true); + ).toBe(true); }); it(`should resolve src/plugins/charts/public/static/color_maps/color_maps.ts to kibana-app`, () => { const actual = enumeratePatterns(REPO_ROOT)(log)( new Map([['src/plugins/charts/public/static/color_maps', ['kibana-app']]]) ); - expect(actual[0][0]).to.be( + expect(actual[0][0]).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app' ); }); @@ -55,6 +54,6 @@ describe(`enumeratePatterns`, () => { actual[0].includes( `${short}/public/common/components/exceptions/builder/translations.ts kibana-security` ) - ).to.be(true); + ).toBe(true); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js b/src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js similarity index 86% rename from src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js rename to src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js index f480135b45ac6..f96eb61b1e18e 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js @@ -17,8 +17,7 @@ * under the License. */ -import expect from '@kbn/expect'; -import { tryPath } from '../team_assignment/enumeration_helpers'; +import { tryPath } from './enumeration_helpers'; describe(`enumeration helper fns`, () => { describe(`tryPath`, () => { @@ -26,24 +25,24 @@ describe(`enumeration helper fns`, () => { it(`should return a right on an existing path`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/ingest.js'; const actual = tryPath(aPath); - expect(actual.isRight()).to.be(true); + expect(actual.isRight()).toBe(true); }); it(`should return a left on a non existing path`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/does_not_exist.js'; const actual = tryPath(aPath); - expect(actual.isLeft()).to.be(true); + expect(actual.isLeft()).toBe(true); }); }); describe(`with glob file paths`, () => { it(`should not error when the glob expands to nothing, but instead return a Left`, () => { const aPath = 'src/legacy/core_plugins/kibana/public/home/*.ts'; const actual = tryPath(aPath); - expect(actual.isLeft()).to.be(true); + expect(actual.isLeft()).toBe(true); }); it(`should return a right on a glob that does indeed expand`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/*.js'; const actual = tryPath(aPath); - expect(actual.isRight()).to.be(true); + expect(actual.isRight()).toBe(true); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js b/src/dev/code_coverage/ingest_coverage/transforms.test.js similarity index 90% rename from src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js rename to src/dev/code_coverage/ingest_coverage/transforms.test.js index b6d17f83e327e..ba2762585c79b 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js +++ b/src/dev/code_coverage/ingest_coverage/transforms.test.js @@ -17,7 +17,6 @@ * under the License. */ -import expect from '@kbn/expect'; import { ciRunUrl, coveredFilePath, @@ -25,18 +24,18 @@ import { prokPrevious, teamAssignment, last, -} from '../transforms'; +} from './transforms'; import { ToolingLog } from '@kbn/dev-utils'; describe(`Transform fns`, () => { describe(`ciRunUrl`, () => { it(`should add the url when present in the environment`, () => { process.env.CI_RUN_URL = 'blah'; - expect(ciRunUrl()).to.have.property('ciRunUrl', 'blah'); + expect(ciRunUrl()).toHaveProperty('ciRunUrl', 'blah'); }); it(`should not include the url if not present in the environment`, () => { process.env.CI_RUN_URL = void 0; - expect(ciRunUrl({ a: 'a' })).not.to.have.property('ciRunUrl'); + expect(ciRunUrl({ a: 'a' })).not.toHaveProperty('ciRunUrl'); }); }); describe(`coveredFilePath`, () => { @@ -48,7 +47,7 @@ describe(`Transform fns`, () => { COVERAGE_INGESTION_KIBANA_ROOT: '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana', }; - expect(coveredFilePath(obj)).to.have.property( + expect(coveredFilePath(obj)).toHaveProperty( 'coveredFilePath', 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' ); @@ -62,7 +61,7 @@ describe(`Transform fns`, () => { COVERAGE_INGESTION_KIBANA_ROOT: '/var/lib/jenkins/workspace/elastic+kibana+qa-research/kibana', }; - expect(coveredFilePath(obj)).to.have.property( + expect(coveredFilePath(obj)).toHaveProperty( 'coveredFilePath', 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' ); @@ -74,7 +73,7 @@ describe(`Transform fns`, () => { process.env.FETCHED_PREVIOUS = 'A'; it(`should return a previous compare url`, () => { const actual = prokPrevious(comparePrefixF)('B'); - expect(actual).to.be(`https://github.com/elastic/kibana/compare/A...B`); + expect(actual).toBe(`https://github.com/elastic/kibana/compare/A...B`); }); }); describe(`itemizeVcs`, () => { @@ -85,7 +84,7 @@ describe(`Transform fns`, () => { `Tre' Seymour`, `Lorem :) ipsum Tre' λ dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`, ]; - expect(itemizeVcs(vcsInfo)({}).vcs).to.have.property( + expect(itemizeVcs(vcsInfo)({}).vcs).toHaveProperty( 'vcsUrl', `https://github.com/elastic/kibana/commit/${vcsInfo[1]}` ); @@ -106,7 +105,7 @@ describe(`Transform fns`, () => { it(`should resolve to ${expected}`, async () => { const actual = await teamAssignment(teamAssignmentsPathMOCK)(log)(obj); const { team } = actual; - expect(team).to.eql(expected); + expect(team).toEqual(expected); }); }); @@ -115,7 +114,7 @@ describe(`Transform fns`, () => { it(`should resolve to ${expected}`, async () => { const actual = await teamAssignment(teamAssignmentsPathMOCK)(log)(obj); const { team } = actual; - expect(team).to.eql(expected); + expect(team).toEqual(expected); }); }); @@ -127,7 +126,7 @@ src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch`; const actual = last(nteams); - expect(actual).to.be( + expect(actual).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch' ); }); @@ -139,7 +138,7 @@ src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch`; const actual = last(nteams); - expect(actual).to.be( + expect(actual).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch' ); }); diff --git a/src/dev/__tests__/file.js b/src/dev/file.test.js similarity index 71% rename from src/dev/__tests__/file.js rename to src/dev/file.test.js index 0e8790f32c7c9..e61be6ad96f58 100644 --- a/src/dev/__tests__/file.js +++ b/src/dev/file.test.js @@ -19,74 +19,68 @@ import { resolve, sep } from 'path'; -import expect from '@kbn/expect'; - -import { File } from '../file'; +import { File } from './file'; const HERE = resolve(__dirname, __filename); describe('dev/File', () => { describe('constructor', () => { it('throws if path is not a string', () => { - expect(() => new File()).to.throwError(); - expect(() => new File(1)).to.throwError(); - expect(() => new File(false)).to.throwError(); - expect(() => new File(null)).to.throwError(); + expect(() => new File()).toThrow(); + expect(() => new File(1)).toThrow(); + expect(() => new File(false)).toThrow(); + expect(() => new File(null)).toThrow(); }); }); describe('#getRelativePath()', () => { it('returns the path relative to the repo root', () => { const file = new File(HERE); - expect(file.getRelativePath()).to.eql(['src', 'dev', '__tests__', 'file.js'].join(sep)); + expect(file.getRelativePath()).toBe(['src', 'dev', 'file.test.js'].join(sep)); }); }); describe('#isJs()', () => { it('returns true if extension is .js', () => { const file = new File('file.js'); - expect(file.isJs()).to.eql(true); + expect(file.isJs()).toBe(true); }); it('returns false if extension is .xml', () => { const file = new File('file.xml'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if extension is .css', () => { const file = new File('file.css'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if extension is .html', () => { const file = new File('file.html'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if file has no extension', () => { const file = new File('file'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); }); describe('#getRelativeParentDirs()', () => { it('returns the parents of a file, stopping at the repo root, in descending order', () => { const file = new File(HERE); - expect(file.getRelativeParentDirs()).to.eql([ - ['src', 'dev', '__tests__'].join(sep), // src/dev/__tests__ - ['src', 'dev'].join(sep), // src/dev - 'src', - ]); + expect(file.getRelativeParentDirs()).toStrictEqual([['src', 'dev'].join(sep), 'src']); }); }); describe('#toString()', () => { it('returns the relativePath', () => { const file = new File(HERE); - expect(file.toString()).to.eql(file.getRelativePath()); + expect(file.toString()).toBe(file.getRelativePath()); }); }); describe('#toJSON()', () => { it('returns the relativePath', () => { const file = new File(HERE); - expect(file.toJSON()).to.eql(file.getRelativePath()); + expect(file.toJSON()).toBe(file.getRelativePath()); }); }); }); diff --git a/src/dev/license_checker/__tests__/valid.js b/src/dev/license_checker/valid.test.js similarity index 88% rename from src/dev/license_checker/__tests__/valid.js rename to src/dev/license_checker/valid.test.js index b569cdb7a07d7..31f7fb55854be 100644 --- a/src/dev/license_checker/__tests__/valid.js +++ b/src/dev/license_checker/valid.test.js @@ -19,9 +19,7 @@ import { resolve } from 'path'; -import expect from '@kbn/expect'; - -import { assertLicensesValid } from '../valid'; +import { assertLicensesValid } from './valid'; const ROOT = resolve(__dirname, '../../../../'); const NODE_MODULES = resolve(ROOT, './node_modules'); @@ -42,7 +40,7 @@ describe('tasks/lib/licenses', () => { packages: [PACKAGE], validLicenses: [...PACKAGE.licenses], }) - ).to.be(undefined); + ).toBe(undefined); }); it('throw an error when the packages license is invalid', () => { @@ -51,7 +49,7 @@ describe('tasks/lib/licenses', () => { packages: [PACKAGE], validLicenses: [`not ${PACKAGE.licenses[0]}`], }); - }).to.throwError(PACKAGE.name); + }).toThrow(PACKAGE.name); }); it('throws an error when the package has no licenses', () => { @@ -65,7 +63,7 @@ describe('tasks/lib/licenses', () => { ], validLicenses: [...PACKAGE.licenses], }); - }).to.throwError(PACKAGE.name); + }).toThrow(PACKAGE.name); }); it('includes the relative path to packages in error message', () => { @@ -76,8 +74,8 @@ describe('tasks/lib/licenses', () => { }); throw new Error('expected assertLicensesValid() to throw'); } catch (error) { - expect(error.message).to.contain(PACKAGE.relative); - expect(error.message).to.not.contain(PACKAGE.directory); + expect(error.message).toContain(PACKAGE.relative); + expect(error.message).not.toContain(PACKAGE.directory); } }); }); diff --git a/src/dev/__tests__/node_versions_must_match.js b/src/dev/node_versions_must_match.test.js similarity index 88% rename from src/dev/__tests__/node_versions_must_match.js rename to src/dev/node_versions_must_match.test.js index 99f2e255f47ea..897769214d78e 100644 --- a/src/dev/__tests__/node_versions_must_match.js +++ b/src/dev/node_versions_must_match.test.js @@ -18,10 +18,9 @@ */ import fs from 'fs'; -import { engines } from '../../../package.json'; +import { engines } from '../../package.json'; import { promisify } from 'util'; const readFile = promisify(fs.readFile); -import expect from '@kbn/expect'; describe('All configs should use a single version of Node', () => { it('should compare .node-version and .nvmrc', async () => { @@ -30,13 +29,13 @@ describe('All configs should use a single version of Node', () => { readFile('./.nvmrc', { encoding: 'utf-8' }), ]); - expect(nodeVersion.trim()).to.be(nvmrc.trim()); + expect(nodeVersion.trim()).toBe(nvmrc.trim()); }); it('should compare .node-version and engines.node from package.json', async () => { const nodeVersion = await readFile('./.node-version', { encoding: 'utf-8', }); - expect(nodeVersion.trim()).to.be(engines.node); + expect(nodeVersion.trim()).toBe(engines.node); }); }); diff --git a/src/legacy/utils/__tests__/unset.js b/src/legacy/utils/unset.test.js similarity index 81% rename from src/legacy/utils/__tests__/unset.js rename to src/legacy/utils/unset.test.js index 69122e06ac572..3f7a2de44508d 100644 --- a/src/legacy/utils/__tests__/unset.js +++ b/src/legacy/utils/unset.test.js @@ -17,27 +17,26 @@ * under the License. */ -import { unset } from '../unset'; -import expect from '@kbn/expect'; +import { unset } from './unset'; describe('unset(obj, key)', function () { describe('invalid input', function () { it('should do nothing if not given an object', function () { const obj = 'hello'; unset(obj, 'e'); - expect(obj).to.equal('hello'); + expect(obj).toBe('hello'); }); it('should do nothing if not given a key', function () { const obj = { one: 1 }; unset(obj); - expect(obj).to.eql({ one: 1 }); + expect(obj).toEqual({ one: 1 }); }); it('should do nothing if given an empty string as a key', function () { const obj = { one: 1 }; unset(obj, ''); - expect(obj).to.eql({ one: 1 }); + expect(obj).toEqual({ one: 1 }); }); }); @@ -50,12 +49,12 @@ describe('unset(obj, key)', function () { it('should remove the param using a string key', function () { unset(obj, 'two'); - expect(obj).to.eql({ one: 1, deep: { three: 3, four: 4 } }); + expect(obj).toEqual({ one: 1, deep: { three: 3, four: 4 } }); }); it('should remove the param using an array key', function () { unset(obj, ['two']); - expect(obj).to.eql({ one: 1, deep: { three: 3, four: 4 } }); + expect(obj).toEqual({ one: 1, deep: { three: 3, four: 4 } }); }); }); @@ -68,12 +67,12 @@ describe('unset(obj, key)', function () { it('should remove the param using a string key', function () { unset(obj, 'deep.three'); - expect(obj).to.eql({ one: 1, two: 2, deep: { four: 4 } }); + expect(obj).toEqual({ one: 1, two: 2, deep: { four: 4 } }); }); it('should remove the param using an array key', function () { unset(obj, ['deep', 'three']); - expect(obj).to.eql({ one: 1, two: 2, deep: { four: 4 } }); + expect(obj).toEqual({ one: 1, two: 2, deep: { four: 4 } }); }); }); @@ -81,22 +80,22 @@ describe('unset(obj, key)', function () { it('should clear object if only value is removed', function () { const obj = { one: { two: { three: 3 } } }; unset(obj, 'one.two.three'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); it('should clear object if no props are left', function () { const obj = { one: { two: { three: 3 } } }; unset(obj, 'one.two'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); it('should remove deep property, then clear the object', function () { const obj = { one: { two: { three: 3, four: 4 } } }; unset(obj, 'one.two.three'); - expect(obj).to.eql({ one: { two: { four: 4 } } }); + expect(obj).toEqual({ one: { two: { four: 4 } } }); unset(obj, 'one.two.four'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); }); }); diff --git a/src/plugins/console/server/__tests__/elasticsearch_proxy_config.js b/src/plugins/console/server/lib/elasticsearch_proxy_config.test.js similarity index 84% rename from src/plugins/console/server/__tests__/elasticsearch_proxy_config.js rename to src/plugins/console/server/lib/elasticsearch_proxy_config.test.js index fcf385165a591..cdbede5199286 100644 --- a/src/plugins/console/server/__tests__/elasticsearch_proxy_config.js +++ b/src/plugins/console/server/lib/elasticsearch_proxy_config.test.js @@ -17,7 +17,6 @@ * under the License. */ -import expect from '@kbn/expect'; import moment from 'moment'; import { getElasticsearchProxyConfig } from '../lib/elasticsearch_proxy_config'; import https from 'https'; @@ -39,7 +38,7 @@ describe('plugins/console', function () { ...getDefaultElasticsearchConfig(), requestTimeout: moment.duration(value), }); - expect(proxyConfig.timeout).to.be(value); + expect(proxyConfig.timeout).toBe(value); }); it(`uses https.Agent when url's protocol is https`, function () { @@ -47,12 +46,12 @@ describe('plugins/console', function () { ...getDefaultElasticsearchConfig(), hosts: ['https://localhost:9200'], }); - expect(agent).to.be.a(https.Agent); + expect(agent instanceof https.Agent).toBeTruthy(); }); it(`uses http.Agent when url's protocol is http`, function () { const { agent } = getElasticsearchProxyConfig(getDefaultElasticsearchConfig()); - expect(agent).to.be.a(http.Agent); + expect(agent instanceof http.Agent).toBeTruthy(); }); describe('ssl', function () { @@ -69,7 +68,7 @@ describe('plugins/console', function () { ...config, ssl: { ...config.ssl, verificationMode: 'none' }, }); - expect(agent.options.rejectUnauthorized).to.be(false); + expect(agent.options.rejectUnauthorized).toBe(false); }); it('sets rejectUnauthorized to true when verificationMode is certificate', function () { @@ -77,7 +76,7 @@ describe('plugins/console', function () { ...config, ssl: { ...config.ssl, verificationMode: 'certificate' }, }); - expect(agent.options.rejectUnauthorized).to.be(true); + expect(agent.options.rejectUnauthorized).toBe(true); }); it('sets checkServerIdentity to not check hostname when verificationMode is certificate', function () { @@ -92,11 +91,9 @@ describe('plugins/console', function () { }, }; - expect(agent.options.checkServerIdentity) - .withArgs('right.com', cert) - .to.not.throwException(); + expect(() => agent.options.checkServerIdentity('right.com', cert)).not.toThrow(); const result = agent.options.checkServerIdentity('right.com', cert); - expect(result).to.be(undefined); + expect(result).toBe(undefined); }); it('sets rejectUnauthorized to true when verificationMode is full', function () { @@ -105,7 +102,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, verificationMode: 'full' }, }); - expect(agent.options.rejectUnauthorized).to.be(true); + expect(agent.options.rejectUnauthorized).toBe(true); }); it(`doesn't set checkServerIdentity when verificationMode is full`, function () { @@ -114,7 +111,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, verificationMode: 'full' }, }); - expect(agent.options.checkServerIdentity).to.be(undefined); + expect(agent.options.checkServerIdentity).toBe(undefined); }); it(`sets ca when certificateAuthorities are specified`, function () { @@ -123,7 +120,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, certificateAuthorities: ['content-of-some-path'] }, }); - expect(agent.options.ca).to.contain('content-of-some-path'); + expect(agent.options.ca).toContain('content-of-some-path'); }); describe('when alwaysPresentCertificate is false', () => { @@ -138,8 +135,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); it(`doesn't set passphrase when certificate, key and keyPassphrase are specified`, function () { @@ -154,7 +151,7 @@ describe('plugins/console', function () { }, }); - expect(agent.options.passphrase).to.be(undefined); + expect(agent.options.passphrase).toBe(undefined); }); }); @@ -170,8 +167,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be('content-of-some-path'); - expect(agent.options.key).to.be('content-of-another-path'); + expect(agent.options.cert).toBe('content-of-some-path'); + expect(agent.options.key).toBe('content-of-another-path'); }); it(`sets passphrase when certificate, key and keyPassphrase are specified`, function () { @@ -186,7 +183,7 @@ describe('plugins/console', function () { }, }); - expect(agent.options.passphrase).to.be('secret'); + expect(agent.options.passphrase).toBe('secret'); }); it(`doesn't set cert when only certificate path is specified`, async function () { @@ -200,8 +197,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); it(`doesn't set key when only key path is specified`, async function () { @@ -215,8 +212,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); }); }); diff --git a/src/plugins/console/server/__tests__/proxy_config.js b/src/plugins/console/server/lib/proxy_config.test.js similarity index 83% rename from src/plugins/console/server/__tests__/proxy_config.js rename to src/plugins/console/server/lib/proxy_config.test.js index 1f3a94c4fe20f..73b181250fe01 100644 --- a/src/plugins/console/server/__tests__/proxy_config.js +++ b/src/plugins/console/server/lib/proxy_config.test.js @@ -17,14 +17,11 @@ * under the License. */ -/* eslint-env mocha */ - -import expect from '@kbn/expect'; import sinon from 'sinon'; import https, { Agent as HttpsAgent } from 'https'; import { parse as parseUrl } from 'url'; -import { ProxyConfig } from '../lib/proxy_config'; +import { ProxyConfig } from './proxy_config'; const matchGoogle = { protocol: 'https', @@ -51,10 +48,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: ['content-of-some-path'], cert: undefined, key: undefined, @@ -70,10 +67,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: undefined, cert: 'content-of-some-path', key: 'content-of-another-path', @@ -91,10 +88,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: ['content-of-some-path'], cert: 'content-of-another-path', key: 'content-of-yet-another-path', @@ -111,7 +108,7 @@ describe('ProxyConfig', function () { timeout: 100, }); - expect(config.getForParsedUri(parsedLocalEs)).to.eql({}); + expect(config.getForParsedUri(parsedLocalEs)).toEqual({}); }); }); @@ -123,7 +120,7 @@ describe('ProxyConfig', function () { timeout: football, }); - expect(config.getForParsedUri(parsedGoogle).timeout).to.be(football); + expect(config.getForParsedUri(parsedGoogle).timeout).toBe(football); }); it('assigns ssl.verify to rejectUnauthorized', function () { @@ -135,7 +132,7 @@ describe('ProxyConfig', function () { }, }); - expect(config.getForParsedUri(parsedGoogle).rejectUnauthorized).to.be(football); + expect(config.getForParsedUri(parsedGoogle).rejectUnauthorized).toBe(football); }); describe('uri us http', function () { @@ -147,8 +144,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('cert is set', function () { @@ -159,8 +156,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('key is set', function () { @@ -171,8 +168,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('cert + key are set', function () { @@ -184,8 +181,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); }); @@ -199,8 +196,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('cert is set', function () { @@ -211,8 +208,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('key is set', function () { @@ -223,8 +220,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('cert + key are set', function () { @@ -236,8 +233,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); }); diff --git a/src/plugins/console/server/__tests__/proxy_config_collection.js b/src/plugins/console/server/lib/proxy_config_collection.test.js similarity index 80% rename from src/plugins/console/server/__tests__/proxy_config_collection.js rename to src/plugins/console/server/lib/proxy_config_collection.test.js index 729972399f0bb..24dc1753106b1 100644 --- a/src/plugins/console/server/__tests__/proxy_config_collection.js +++ b/src/plugins/console/server/lib/proxy_config_collection.test.js @@ -17,14 +17,11 @@ * under the License. */ -/* eslint-env mocha */ - -import expect from '@kbn/expect'; import sinon from 'sinon'; import fs from 'fs'; import { Agent as HttpsAgent } from 'https'; -import { ProxyConfigCollection } from '../lib/proxy_config_collection'; +import { ProxyConfigCollection } from './proxy_config_collection'; describe('ProxyConfigCollection', function () { beforeEach(function () { @@ -88,61 +85,61 @@ describe('ProxyConfigCollection', function () { describe('http://localhost:5601', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5601')).to.be(3); + expect(getTimeout('http://localhost:5601')).toBe(3); }); }); describe('https://localhost:5601/.kibana', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/.kibana')).to.be(1); + expect(getTimeout('https://localhost:5601/.kibana')).toBe(1); }); }); describe('http://localhost:5602', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5602')).to.be(4); + expect(getTimeout('http://localhost:5602')).toBe(4); }); }); describe('https://localhost:5602', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5602')).to.be(4); + expect(getTimeout('https://localhost:5602')).toBe(4); }); }); describe('http://localhost:5603', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5603')).to.be(4); + expect(getTimeout('http://localhost:5603')).toBe(4); }); }); describe('https://localhost:5603', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5603')).to.be(4); + expect(getTimeout('https://localhost:5603')).toBe(4); }); }); describe('https://localhost:5601/index', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/index')).to.be(2); + expect(getTimeout('https://localhost:5601/index')).toBe(2); }); }); describe('http://localhost:5601/index', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5601/index')).to.be(3); + expect(getTimeout('http://localhost:5601/index')).toBe(3); }); }); describe('https://localhost:5601/index/type', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/index/type')).to.be(2); + expect(getTimeout('https://localhost:5601/index/type')).toBe(2); }); }); describe('http://notlocalhost', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://notlocalhost')).to.be(5); + expect(getTimeout('http://notlocalhost')).toBe(5); }); }); @@ -162,14 +159,14 @@ describe('ProxyConfigCollection', function () { it('verifies for config that produces ssl agent', function () { const conf = makeCollection().configForUri('https://es.internal.org/_search'); - expect(conf.agent.options).to.have.property('rejectUnauthorized', true); - expect(conf.agent).to.be.an(HttpsAgent); + expect(conf.agent.options).toHaveProperty('rejectUnauthorized', true); + expect(conf.agent instanceof HttpsAgent).toBeTruthy(); }); it('disabled verification for * config', function () { const conf = makeCollection().configForUri('https://extenal.org/_search'); - expect(conf).to.have.property('rejectUnauthorized', false); - expect(conf.agent).to.be(undefined); + expect(conf).toHaveProperty('rejectUnauthorized', false); + expect(conf.agent).toBe(undefined); }); }); }); diff --git a/src/plugins/console/server/__tests__/set_headers.js b/src/plugins/console/server/lib/set_headers.test.js similarity index 82% rename from src/plugins/console/server/__tests__/set_headers.js rename to src/plugins/console/server/lib/set_headers.test.js index 3ddd30777bb5b..f8680de8b5f9a 100644 --- a/src/plugins/console/server/__tests__/set_headers.js +++ b/src/plugins/console/server/lib/set_headers.test.js @@ -17,39 +17,38 @@ * under the License. */ -import expect from '@kbn/expect'; -import { setHeaders } from '../lib'; +import { setHeaders } from './set_headers'; describe('#set_headers', function () { it('throws if not given an object as the first argument', function () { const fn = () => setHeaders(null, {}); - expect(fn).to.throwError(); + expect(fn).toThrow(); }); it('throws if not given an object as the second argument', function () { const fn = () => setHeaders({}, null); - expect(fn).to.throwError(); + expect(fn).toThrow(); }); it('returns a new object', function () { const originalHeaders = {}; const newHeaders = {}; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).not.to.be(originalHeaders); - expect(returnedHeaders).not.to.be(newHeaders); + expect(returnedHeaders).not.toBe(originalHeaders); + expect(returnedHeaders).not.toBe(newHeaders); }); it('returns object with newHeaders merged with originalHeaders', function () { const originalHeaders = { foo: 'bar' }; const newHeaders = { one: 'two' }; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).to.eql({ foo: 'bar', one: 'two' }); + expect(returnedHeaders).toEqual({ foo: 'bar', one: 'two' }); }); it('returns object where newHeaders takes precedence for any matching keys', function () { const originalHeaders = { foo: 'bar' }; const newHeaders = { one: 'two', foo: 'notbar' }; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).to.eql({ foo: 'notbar', one: 'two' }); + expect(returnedHeaders).toEqual({ foo: 'notbar', one: 'two' }); }); }); diff --git a/src/plugins/console/server/__tests__/wildcard_matcher.js b/src/plugins/console/server/lib/wildcard_matcher.test.js similarity index 97% rename from src/plugins/console/server/__tests__/wildcard_matcher.js rename to src/plugins/console/server/lib/wildcard_matcher.test.js index 3e0e06efad50f..fe25b6a50f8a7 100644 --- a/src/plugins/console/server/__tests__/wildcard_matcher.js +++ b/src/plugins/console/server/lib/wildcard_matcher.test.js @@ -17,8 +17,7 @@ * under the License. */ -/* eslint-env mocha */ -import { WildcardMatcher } from '../lib/wildcard_matcher'; +import { WildcardMatcher } from './wildcard_matcher'; function should(candidate, ...constructorArgs) { if (!new WildcardMatcher(...constructorArgs).match(candidate)) { diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index 94634d2c408e5..e3447b0a86c2d 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -4,6 +4,12 @@ flex: 1; } +.dashboardViewport { + flex: 1; + display: flex; + flex-direction: column; +} + .dshStartScreen { text-align: center; } diff --git a/src/plugins/dashboard/public/application/_hacks.scss b/src/plugins/dashboard/public/application/_hacks.scss deleted file mode 100644 index debcc78792de9..0000000000000 --- a/src/plugins/dashboard/public/application/_hacks.scss +++ /dev/null @@ -1,13 +0,0 @@ -// ANGULAR SELECTOR HACKS - -/** - * Needs to correspond with the react root nested inside angular. - */ - #dashboardViewport { - flex: 1; - display: flex; - flex-direction: column; - [data-reactroot] { - flex: 1; - } -} diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index 5f3945e733527..d9eb0dafe572a 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -16,26 +16,31 @@ * specific language governing permissions and limitations * under the License. */ + +import { AddToLibraryAction } from '.'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput } from '../test_helpers'; + +import { CoreStart } from 'kibana/public'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + import { - isErrorEmbeddable, + EmbeddableInput, + ErrorEmbeddable, IContainer, + isErrorEmbeddable, ReferenceOrValueEmbeddable, - EmbeddableInput, -} from '../../embeddable_plugin'; -import { DashboardContainer } from '../embeddable'; -import { getSampleDashboardInput } from '../test_helpers'; + ViewMode, +} from '../../services/embeddable'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { AddToLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { ErrorEmbeddable, ViewMode } from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -60,6 +65,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 08cd0c7a15381..880d40cc3c612 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -17,17 +17,20 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; + +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../../../embeddable/public'; -import { NotificationsStart } from '../../../../../core/public'; +} from '../../services/embeddable'; +import { NotificationsStart } from '../../services/core'; +import { dashboardAddToLibraryAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_ADD_TO_LIBRARY = 'addToFromLibrary'; @@ -47,9 +50,7 @@ export class AddToLibraryAction implements ActionByType { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 2d98d419689c1..d27e2d6dce651 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -17,23 +17,26 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import uuid from 'uuid'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; -import { SavedObject } from '../../../../saved_objects/public'; +import uuid from 'uuid'; + +import { CoreStart } from 'src/core/public'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; +import { SavedObject } from '../../services/saved_objects'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, SavedObjectEmbeddableInput, isErrorEmbeddable, -} from '../../../../embeddable/public'; +} from '../../services/embeddable'; import { placePanelBeside, IPanelPlacementBesideArgs, } from '../embeddable/panel/dashboard_panel_placement'; +import { dashboardClonePanelAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_CLONE_PANEL = 'clonePanel'; @@ -53,9 +56,7 @@ export class ClonePanelAction implements ActionByType if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { throw new IncompatibleActionError(); } - return i18n.translate('dashboard.panel.clonePanel', { - defaultMessage: 'Clone panel', - }); + return dashboardClonePanelAction.getDisplayName(); } public getIconType({ embeddable }: ClonePanelActionContext) { @@ -99,9 +100,7 @@ export class ClonePanelAction implements ActionByType } private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise { - const clonedTag = i18n.translate('dashboard.panel.title.clonedTag', { - defaultMessage: 'copy', - }); + const clonedTag = dashboardClonePanelAction.getClonedTag(); const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); @@ -152,9 +151,7 @@ export class ClonePanelAction implements ActionByType (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = clonedSavedObject.id; } this.core.notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.panel.clonedToast', { - defaultMessage: 'Cloned panel', - }), + title: dashboardClonePanelAction.getSuccessMessage(), 'data-test-subj': 'addObjectToContainerSuccess', }); return panelState; diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx index ff4e3ee5f06eb..2b86403fc74e4 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx @@ -17,18 +17,20 @@ * under the License. */ -import { isErrorEmbeddable } from '../../embeddable_plugin'; import { ExpandPanelAction } from './expand_panel_action'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { isErrorEmbeddable } from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddable, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../services/embeddable_test_samples'; +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; const { setup, doStart } = embeddablePluginMock.createInstance(); @@ -52,6 +54,8 @@ beforeEach(async () => { overlays: {} as any, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx index dcce38cdf94ce..fe14ce13d44bc 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx @@ -17,9 +17,9 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { IEmbeddable } from '../../embeddable_plugin'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { dashboardExpandPanelAction } from '../../dashboard_strings'; +import { IEmbeddable } from '../../services/embeddable'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer, @@ -59,12 +59,8 @@ export class ExpandPanelAction implements ActionByType { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx b/src/plugins/dashboard/public/application/actions/export_csv_action.tsx index 48a7877f9383e..4f78a738095d2 100644 --- a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx +++ b/src/plugins/dashboard/public/application/actions/export_csv_action.tsx @@ -17,14 +17,15 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { Datatable } from 'src/plugins/expressions/public'; -import { FormatFactory } from '../../../../data/common/field_formats/utils'; -import { DataPublicPluginStart, exporters } from '../../../../data/public'; -import { downloadMultipleAs } from '../../../../share/public'; -import { Adapters, IEmbeddable } from '../../../../embeddable/public'; -import { ActionByType } from '../../../../ui_actions/public'; import { CoreStart } from '../../../../../core/public'; +import { FormatFactory } from '../../../../data/common/field_formats/utils'; + +import { DataPublicPluginStart, exporters } from '../../services/data'; +import { downloadMultipleAs } from '../../services/share'; +import { Adapters, IEmbeddable } from '../../services/embeddable'; +import { ActionByType } from '../../services/ui_actions'; +import { dashboardExportCsvAction } from '../../dashboard_strings'; export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV'; @@ -57,9 +58,7 @@ export class ExportCSVAction implements ActionByType { } public readonly getDisplayName = (context: ExportContext): string => - i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { - defaultMessage: 'Download as CSV', - }); + dashboardExportCsvAction.getDisplayName(); public async isCompatible(context: ExportContext): Promise { return !!this.hasDatatableContent(context.embeddable?.getInspectorAdapters?.()); @@ -99,12 +98,7 @@ export class ExportCSVAction implements ActionByType { // skip empty datatables if (datatable) { const postFix = datatables.length > 1 ? `-${i + 1}` : ''; - const untitledFilename = i18n.translate( - 'dashboard.actions.downloadOptionsUnsavedFilename', - { - defaultMessage: 'unsaved', - } - ); + const untitledFilename = dashboardExportCsvAction.getUntitledFilename(); memo[`${context!.embeddable!.getTitle() || untitledFilename}${postFix}.csv`] = { content: exporters.datatableToCSV(datatable, { diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx index f45d64cdc0ab8..3157017d469ca 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx @@ -16,21 +16,28 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; + import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { LibraryNotificationAction, UnlinkFromLibraryAction } from '.'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { + ErrorEmbeddable, + IContainer, + isErrorEmbeddable, + ReferenceOrValueEmbeddable, + ViewMode, +} from '../../services/embeddable'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { LibraryNotificationAction, UnlinkFromLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { ErrorEmbeddable, IContainer, ViewMode } from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -62,6 +69,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx index d6e75a3bb132b..13ccb279df821 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx @@ -17,18 +17,20 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import React from 'react'; + +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; +import { reactToUiComponent } from '../../services/kibana_react'; import { IEmbeddable, ViewMode, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../embeddable_plugin'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { reactToUiComponent } from '../../../../kibana_react/public'; +} from '../../services/embeddable'; + import { UnlinkFromLibraryAction } from '.'; import { LibraryNotificationPopover } from './library_notification_popover'; +import { dashboardLibraryNotification } from '../../dashboard_strings'; export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION'; @@ -43,9 +45,7 @@ export class LibraryNotificationAction implements ActionByType { const { setup, doStart } = embeddablePluginMock.createInstance(); @@ -64,6 +64,8 @@ describe('LibraryNotificationPopover', () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx index e46851a85a67f..25b37c69be589 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx @@ -27,8 +27,8 @@ import { EuiPopoverTitle, EuiText, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { LibraryNotificationActionContext, UnlinkFromLibraryAction } from '.'; +import { dashboardLibraryNotification } from '../../dashboard_strings'; export interface LibraryNotificationProps { context: LibraryNotificationActionContext; @@ -52,13 +52,11 @@ export function LibraryNotificationPopover({ setIsPopoverOpen(!isPopoverOpen)} + data-test-subj={`embeddablePanelNotification-${id}`} + aria-label={dashboardLibraryNotification.getPopoverAriaLabel()} /> } isOpen={isPopoverOpen} @@ -68,12 +66,7 @@ export function LibraryNotificationPopover({ {displayName}
-

- {i18n.translate('dashboard.panel.libraryNotification.toolTip', { - defaultMessage: - 'Editing this panel might affect other dashboards. To change to this panel only, unlink it from the library.', - })} -

+

{dashboardLibraryNotification.getTooltip()}

diff --git a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx index 54a294fd2f4ac..2f81353517b14 100644 --- a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx @@ -18,15 +18,15 @@ */ import React from 'react'; import { CoreStart } from 'src/core/public'; -import { toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { toMountPoint } from '../../services/kibana_react'; import { ReplacePanelFlyout } from './replace_panel_flyout'; import { + IContainer, IEmbeddable, + EmbeddableStart, EmbeddableInput, EmbeddableOutput, - EmbeddableStart, - IContainer, -} from '../../embeddable_plugin'; +} from '../../services/embeddable'; export async function openReplacePanelFlyout(options: { embeddable: IContainer; diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx index 38afc22670709..ffc76c77a6916 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx @@ -16,20 +16,22 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable } from '../../embeddable_plugin'; + import { ReplacePanelAction } from './replace_panel_action'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { isErrorEmbeddable } from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddable, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -53,6 +55,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx index 5526af2f83850..553a0b9770d01 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx @@ -17,12 +17,12 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { CoreStart } from 'src/core/public'; -import { IEmbeddable, ViewMode, EmbeddableStart } from '../../embeddable_plugin'; +import { IEmbeddable, ViewMode, EmbeddableStart } from '../../services/embeddable'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { openReplacePanelFlyout } from './open_replace_panel_flyout'; +import { dashboardReplacePanelAction } from '../../dashboard_strings'; export const ACTION_REPLACE_PANEL = 'replacePanel'; @@ -50,9 +50,7 @@ export class ReplacePanelAction implements ActionByType { } this.lastToast = this.props.notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { - defaultMessage: '{savedObjectName} was added', - values: { - savedObjectName: name, - }, - }), + title: dashboardReplacePanelAction.getSuccessMessage(name), 'data-test-subj': 'addObjectToContainerSuccess', }); }; @@ -104,9 +99,7 @@ export class ReplacePanelFlyout extends React.Component { const SavedObjectFinder = this.props.savedObjectsFinder; const savedObjectsFinder = ( diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 6a9769b0c8d16..962d25bf0fe1a 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -16,25 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { CoreStart } from 'kibana/public'; + +import { + ViewMode, + IContainer, + ErrorEmbeddable, + isErrorEmbeddable, + ReferenceOrValueEmbeddable, + SavedObjectEmbeddableInput, +} from '../../services/embeddable'; +import { UnlinkFromLibraryAction } from '.'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { UnlinkFromLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { - ViewMode, - SavedObjectEmbeddableInput, - ErrorEmbeddable, -} from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -59,6 +63,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index b20bbc6350aaa..93ceb72624259 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -17,17 +17,19 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../../../embeddable/public'; +} from '../../services/embeddable'; import { NotificationsStart } from '../../../../../core/public'; +import { dashboardUnlinkFromLibraryAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; @@ -47,9 +49,7 @@ export class UnlinkFromLibraryAction implements ActionByType string; - savedQueryService: DataPublicPluginStart['query']['savedQueries']; - embeddable: EmbeddableStart; - localStorage: Storage; - share?: SharePluginStart; - usageCollection?: UsageCollectionSetup; - navigateToDefaultApp: UrlForwardingStart['navigateToDefaultApp']; - navigateToLegacyKibanaUrl: UrlForwardingStart['navigateToLegacyKibanaUrl']; - scopedHistory: () => ScopedHistory; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; - savedObjects: SavedObjectsStart; - savedObjectsTagging?: SavedObjectsTaggingApi; - restorePreviousUrl: () => void; -} - -let angularModuleInstance: IModule | null = null; - -export const renderApp = (element: HTMLElement, appBasePath: string, deps: RenderDeps) => { - if (!angularModuleInstance) { - angularModuleInstance = createLocalAngularModule(); - // global routing stuff - configureAppAngularModule( - angularModuleInstance, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true, - deps.scopedHistory - ); - initDashboardApp(angularModuleInstance, deps); - } - - const $injector = mountDashboardApp(appBasePath, element); - - return () => { - ($injector.get('kbnUrlStateStorage') as any).cancel(); - $injector.get('$rootScope').$destroy(); - }; -}; - -const mainTemplate = (basePath: string) => `
- -
`; - -const moduleName = 'app/dashboard'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -function mountDashboardApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'dshAppContainer'); - // eslint-disable-next-line no-unsanitized/property - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - // initialize global state handler - element.appendChild(mountpoint); - return $injector; -} - -function createLocalAngularModule() { - createLocalI18nModule(); - createLocalIconModule(); - - const dashboardAngularModule = angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'app/dashboard/I18n', - 'app/dashboard/icon', - ]); - return dashboardAngularModule; -} - -function createLocalIconModule() { - angular - .module('app/dashboard/icon', ['react']) - .directive('icon', (reactDirective) => reactDirective(EuiIcon)); -} - -function createLocalI18nModule() { - angular - .module('app/dashboard/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/src/plugins/dashboard/public/application/dashboard_app.html b/src/plugins/dashboard/public/application/dashboard_app.html deleted file mode 100644 index 87a5728ac2059..0000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-

{{screenTitle}}

-
- -
diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 6690ae318fc8f..8eff48251b371 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -17,75 +17,231 @@ * under the License. */ -import moment from 'moment'; -import { Subscription } from 'rxjs'; +import _ from 'lodash'; import { History } from 'history'; +import { merge, Subscription } from 'rxjs'; +import React, { useEffect, useCallback, useState } from 'react'; -import { ViewMode } from 'src/plugins/embeddable/public'; -import { IIndexPattern, TimeRange, Query, Filter, SavedQuery } from 'src/plugins/data/public'; -import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; - -import { DashboardAppState, SavedDashboardPanel } from '../types'; -import { DashboardAppController } from './dashboard_app_controller'; -import { RenderDeps } from './application'; -import { SavedObjectDashboard } from '../saved_dashboards'; - -export interface DashboardAppScope extends ng.IScope { - dash: SavedObjectDashboard; - appState: DashboardAppState; - model: { - query: Query; - filters: Filter[]; - timeRestore: boolean; - title: string; - description: string; - timeRange: - | TimeRange - | { to: string | moment.Moment | undefined; from: string | moment.Moment | undefined }; - refreshInterval: any; - }; - savedQuery?: SavedQuery; - refreshInterval: any; - panels: SavedDashboardPanel[]; - indexPatterns: IIndexPattern[]; - dashboardViewMode: ViewMode; - expandedPanel?: string; - getShouldShowEditHelp: () => boolean; - getShouldShowViewHelp: () => boolean; - handleRefresh: ( - { query, dateRange }: { query?: Query; dateRange: TimeRange }, - isUpdate?: boolean - ) => void; - topNavMenu: any; - showAddPanel: any; - showSaveQuery: boolean; - kbnTopNav: any; - enterEditMode: () => void; - timefilterSubscriptions$: Subscription; - isVisible: boolean; +import { useKibana } from '../../../kibana_react/public'; +import { DashboardConstants } from '../dashboard_constants'; +import { DashboardTopNav } from './top_nav/dashboard_top_nav'; +import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from './types'; +import { + getInputSubscription, + getOutputSubscription, + getFiltersSubscription, + getSearchSessionIdFromURL, + getDashboardContainerInput, + getChangesFromAppStateForContainerState, +} from './dashboard_app_functions'; +import { + useDashboardBreadcrumbs, + useDashboardContainer, + useDashboardStateManager, + useSavedDashboard, +} from './hooks'; + +import { removeQueryParam } from '../services/kibana_utils'; +import { IndexPattern } from '../services/data'; +import { EmbeddableRenderer } from '../services/embeddable'; +import { DashboardContainerInput } from '.'; + +export interface DashboardAppProps { + history: History; + savedDashboardId?: string; + redirectTo: DashboardRedirect; + embedSettings?: DashboardEmbedSettings; } -export function initDashboardAppDirective(app: any, deps: RenderDeps) { - app.directive('dashboardApp', () => ({ - restrict: 'E', - controllerAs: 'dashboardApp', - controller: ( - $scope: DashboardAppScope, - $route: any, - $routeParams: { - id?: string; - }, - kbnUrlStateStorage: IKbnUrlStateStorage, - history: History - ) => - new DashboardAppController({ - $route, - $scope, - $routeParams, - indexPatterns: deps.data.indexPatterns, - kbnUrlStateStorage, - history, - ...deps, - }), - })); +export function DashboardApp({ + savedDashboardId, + embedSettings, + redirectTo, + history, +}: DashboardAppProps) { + const { + data, + core, + onAppLeave, + uiSettings, + indexPatterns: indexPatternService, + dashboardCapabilities, + } = useKibana().services; + + const [lastReloadTime, setLastReloadTime] = useState(0); + const [indexPatterns, setIndexPatterns] = useState([]); + + const savedDashboard = useSavedDashboard(savedDashboardId, history); + const dashboardStateManager = useDashboardStateManager(savedDashboard, history); + const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false); + + const refreshDashboardContainer = useCallback( + (lastReloadRequestTime?: number) => { + if (!dashboardContainer || !dashboardStateManager) { + return; + } + + const changes = getChangesFromAppStateForContainerState({ + dashboardContainer, + appStateDashboardInput: getDashboardContainerInput({ + isEmbeddedExternally: Boolean(embedSettings), + dashboardStateManager, + lastReloadRequestTime, + dashboardCapabilities, + query: data.query, + }), + }); + + if (changes) { + // state keys change in which likely won't need a data fetch + const noRefetchKeys: Array = [ + 'viewMode', + 'title', + 'description', + 'expandedPanelId', + 'useMargins', + 'isEmbeddedExternally', + 'isFullScreenMode', + ]; + const shouldRefetch = Object.keys(changes).some( + (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) + ); + if (getSearchSessionIdFromURL(history)) { + // going away from a background search results + removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true); + } + + dashboardContainer.updateInput({ + ...changes, + // do not start a new session if this is irrelevant state change to prevent excessive searches + ...(shouldRefetch && { searchSessionId: data.search.session.start() }), + }); + } + }, + [ + history, + data.query, + embedSettings, + dashboardContainer, + data.search.session, + dashboardCapabilities, + dashboardStateManager, + ] + ); + + // Manage dashboard container subscriptions + useEffect(() => { + if (!dashboardStateManager || !dashboardContainer) { + return; + } + const timeFilter = data.query.timefilter.timefilter; + const subscriptions = new Subscription(); + + subscriptions.add( + getInputSubscription({ + dashboardContainer, + dashboardStateManager, + filterManager: data.query.filterManager, + }) + ); + subscriptions.add( + getOutputSubscription({ + dashboardContainer, + indexPatterns: indexPatternService, + onUpdateIndexPatterns: (newIndexPatterns) => setIndexPatterns(newIndexPatterns), + }) + ); + subscriptions.add( + getFiltersSubscription({ + query: data.query, + dashboardStateManager, + }) + ); + subscriptions.add( + merge( + ...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()] + ).subscribe(() => refreshDashboardContainer()) + ); + subscriptions.add( + data.search.session.onRefresh$.subscribe(() => { + setLastReloadTime(() => new Date().getTime()); + }) + ); + dashboardStateManager.registerChangeListener(() => { + // we aren't checking dirty state because there are changes the container needs to know about + // that won't make the dashboard "dirty" - like a view mode change. + refreshDashboardContainer(); + }); + + return () => { + subscriptions.unsubscribe(); + }; + }, [ + core.http, + uiSettings, + data.query, + dashboardContainer, + data.search.session, + indexPatternService, + dashboardStateManager, + refreshDashboardContainer, + ]); + + // Sync breadcrumbs when Dashboard State Manager changes + useDashboardBreadcrumbs(dashboardStateManager, redirectTo); + + // Build onAppLeave when Dashboard State Manager changes + useEffect(() => { + if (!dashboardStateManager || !dashboardContainer) { + return; + } + onAppLeave((actions) => { + if (dashboardStateManager?.getIsDirty()) { + // TODO: Finish App leave handler with overrides when redirecting to an editor. + // return actions.confirm(leaveConfirmStrings.leaveSubtitle, leaveConfirmStrings.leaveTitle); + } + return actions.default(); + }); + return () => { + // reset on app leave handler so leaving from the listing page doesn't trigger a confirmation + onAppLeave((actions) => actions.default()); + }; + }, [dashboardStateManager, dashboardContainer, onAppLeave]); + + // Refresh the dashboard container when lastReloadTime changes + useEffect(() => { + refreshDashboardContainer(lastReloadTime); + }, [lastReloadTime, refreshDashboardContainer]); + + return ( +
+ {savedDashboard && dashboardStateManager && dashboardContainer && ( + <> + { + if (isUpdate === false) { + // The user can still request a reload in the query bar, even if the + // query is the same, and in that case, we have to explicitly ask for + // a reload, since no state changes will cause it. + setLastReloadTime(() => new Date().getTime()); + } + }} + /> +
+ +
+ + )} +
+ ); } diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx deleted file mode 100644 index 4d5a3fb9a8cc9..0000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ /dev/null @@ -1,1242 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _, { uniqBy } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui'; -import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group'; -import React, { useState, ReactElement } from 'react'; -import ReactDOM from 'react-dom'; -import angular from 'angular'; -import deepEqual from 'fast-deep-equal'; - -import { Observable, pipe, Subscription, merge, EMPTY } from 'rxjs'; -import { - filter, - map, - debounceTime, - mapTo, - startWith, - switchMap, - distinctUntilChanged, - catchError, -} from 'rxjs/operators'; -import { History } from 'history'; -import { SavedObjectSaveOpts, SavedObject } from 'src/plugins/saved_objects/public'; -import type { TagDecoratedSavedObject } from 'src/plugins/saved_objects_tagging_oss/public'; -import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; -import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; - -import { - connectToQueryState, - esFilters, - IndexPattern, - IndexPatternsContract, - QueryState, - SavedQuery, - syncQueryStateWithUrl, -} from '../../../data/public'; -import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../../saved_objects/public'; - -import { - DASHBOARD_CONTAINER_TYPE, - DashboardContainer, - DashboardContainerInput, - DashboardPanelState, -} from './embeddable'; -import { - EmbeddableFactoryNotFoundError, - ErrorEmbeddable, - isErrorEmbeddable, - openAddPanelFlyout, - ViewMode, - ContainerOutput, - EmbeddableInput, -} from '../../../embeddable/public'; -import { NavAction, SavedDashboardPanel } from '../types'; - -import { showOptionsPopover } from './top_nav/show_options_popover'; -import { DashboardSaveModal, SaveOptions } from './top_nav/save_modal'; -import { showCloneModal } from './top_nav/show_clone_modal'; -import { createSessionRestorationDataProvider, saveDashboard } from './lib'; -import { DashboardStateManager } from './dashboard_state_manager'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { getTopNavConfig } from './top_nav/get_top_nav_config'; -import { TopNavIds } from './top_nav/top_nav_ids'; -import { getDashboardTitle } from './dashboard_strings'; -import { DashboardAppScope } from './dashboard_app'; -import { RenderDeps } from './application'; -import { - IKbnUrlStateStorage, - removeQueryParam, - setStateToKbnUrl, - unhashUrl, - getQueryParams, -} from '../../../kibana_utils/public'; -import { - addFatalError, - AngularHttpError, - KibanaLegacyStart, - subscribeWithScope, -} from '../../../kibana_legacy/public'; -import { migrateLegacyQuery } from './lib/migrate_legacy_query'; -import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; - -export interface DashboardAppControllerDependencies extends RenderDeps { - $scope: DashboardAppScope; - $route: any; - $routeParams: any; - indexPatterns: IndexPatternsContract; - dashboardConfig: KibanaLegacyStart['dashboardConfig']; - history: History; - kbnUrlStateStorage: IKbnUrlStateStorage; - navigation: NavigationStart; -} - -enum UrlParams { - SHOW_TOP_MENU = 'show-top-menu', - SHOW_QUERY_INPUT = 'show-query-input', - SHOW_TIME_FILTER = 'show-time-filter', - SHOW_FILTER_BAR = 'show-filter-bar', - HIDE_FILTER_BAR = 'hide-filter-bar', -} - -interface UrlParamsSelectedMap { - [UrlParams.SHOW_TOP_MENU]: boolean; - [UrlParams.SHOW_QUERY_INPUT]: boolean; - [UrlParams.SHOW_TIME_FILTER]: boolean; - [UrlParams.SHOW_FILTER_BAR]: boolean; -} - -interface UrlParamValues extends Omit { - [UrlParams.HIDE_FILTER_BAR]: boolean; -} - -const getSearchSessionIdFromURL = (history: History): string | undefined => - getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined; - -export class DashboardAppController { - // Part of the exposed plugin API - do not remove without careful consideration. - appStatus: { - dirty: boolean; - }; - - constructor({ - pluginInitializerContext, - $scope, - $route, - $routeParams, - dashboardConfig, - indexPatterns, - savedQueryService, - embeddable, - share, - dashboardCapabilities, - scopedHistory, - embeddableCapabilities: { visualizeCapabilities, mapsCapabilities }, - data, - core: { - notifications, - overlays, - chrome, - fatalErrors, - uiSettings, - savedObjects, - http, - i18n: i18nStart, - }, - history, - setHeaderActionMenu, - kbnUrlStateStorage, - usageCollection, - navigation, - savedObjectsTagging, - }: DashboardAppControllerDependencies) { - const queryService = data.query; - const searchService = data.search; - const filterManager = queryService.filterManager; - const timefilter = queryService.timefilter.timefilter; - const queryStringManager = queryService.queryString; - const isEmbeddedExternally = Boolean($routeParams.embed); - - // url param rules should only apply when embedded (e.g. url?embed=true) - const shouldForceDisplay = (param: string): boolean => - isEmbeddedExternally && Boolean($routeParams[param]); - - const forceShowTopNavMenu = shouldForceDisplay(UrlParams.SHOW_TOP_MENU); - const forceShowQueryInput = shouldForceDisplay(UrlParams.SHOW_QUERY_INPUT); - const forceShowDatePicker = shouldForceDisplay(UrlParams.SHOW_TIME_FILTER); - const forceHideFilterBar = shouldForceDisplay(UrlParams.HIDE_FILTER_BAR); - - let lastReloadRequestTime = 0; - const dash = ($scope.dash = $route.current.locals.dash); - if (dash.id) { - chrome.docTitle.change(dash.title); - } - - let incomingEmbeddable = embeddable - .getStateTransfer(scopedHistory()) - .getIncomingEmbeddablePackage(); - - // TS is picky with type guards, we can't just inline `() => false` - function defaultTaggingGuard(obj: SavedObject): obj is TagDecoratedSavedObject { - return false; - } - - const dashboardStateManager = new DashboardStateManager({ - savedDashboard: dash, - hideWriteControls: dashboardConfig.getHideWriteControls(), - kibanaVersion: pluginInitializerContext.env.packageInfo.version, - kbnUrlStateStorage, - history, - usageCollection, - hasTaggingCapabilities: savedObjectsTagging?.ui.hasTagDecoration ?? defaultTaggingGuard, - }); - - // sync initial app filters from state to filterManager - // if there is an existing similar global filter, then leave it as global - filterManager.setAppFilters(_.cloneDeep(dashboardStateManager.appState.filters)); - queryStringManager.setQuery(migrateLegacyQuery(dashboardStateManager.appState.query)); - - // setup syncing of app filters between appState and filterManager - const stopSyncingAppFilters = connectToQueryState( - queryService, - { - set: ({ filters, query }) => { - dashboardStateManager.setFilters(filters || []); - dashboardStateManager.setQuery(query || queryStringManager.getDefaultQuery()); - }, - get: () => ({ - filters: dashboardStateManager.appState.filters, - query: dashboardStateManager.getQuery(), - }), - state$: dashboardStateManager.appState$.pipe( - map((state) => ({ - filters: state.filters, - query: queryStringManager.formatQuery(state.query), - })) - ), - }, - { - filters: esFilters.FilterStateStore.APP_STATE, - query: true, - } - ); - - // The hash check is so we only update the time filter on dashboard open, not during - // normal cross app navigation. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - const initialGlobalStateInUrl = kbnUrlStateStorage.get('_g'); - if (!initialGlobalStateInUrl?.time) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - } - if (!initialGlobalStateInUrl?.refreshInterval) { - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - } - - // starts syncing `_g` portion of url with query services - // it is important to start this syncing after `dashboardStateManager.syncTimefilterWithDashboard(timefilter);` above is run, - // otherwise it will case redundant browser history records - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - queryService, - kbnUrlStateStorage - ); - - // starts syncing `_a` portion of url - dashboardStateManager.startStateSyncing(); - - $scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean; - - const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`; - - const getDashTitle = () => - getDashboardTitle( - dashboardStateManager.getTitle(), - dashboardStateManager.getViewMode(), - dashboardStateManager.getIsDirty(timefilter), - dashboardStateManager.isNew() - ); - - const getShouldShowEditHelp = () => - !dashboardStateManager.getPanels().length && - dashboardStateManager.getIsEditMode() && - !dashboardConfig.getHideWriteControls(); - - const getShouldShowViewHelp = () => - !dashboardStateManager.getPanels().length && - dashboardStateManager.getIsViewMode() && - !dashboardConfig.getHideWriteControls(); - - const shouldShowUnauthorizedEmptyState = () => { - const readonlyMode = - !dashboardStateManager.getPanels().length && - !getShouldShowEditHelp() && - !getShouldShowViewHelp() && - dashboardConfig.getHideWriteControls(); - const userHasNoPermissions = - !dashboardStateManager.getPanels().length && - !visualizeCapabilities.save && - !mapsCapabilities.save; - return readonlyMode || userHasNoPermissions; - }; - - const addVisualization = () => { - navActions[TopNavIds.VISUALIZE](); - }; - - function getDashboardIndexPatterns(container: DashboardContainer): IndexPattern[] { - let panelIndexPatterns: IndexPattern[] = []; - Object.values(container.getChildIds()).forEach((id) => { - const embeddableInstance = container.getChild(id); - if (isErrorEmbeddable(embeddableInstance)) return; - const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns; - if (!embeddableIndexPatterns) return; - panelIndexPatterns.push(...embeddableIndexPatterns); - }); - panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); - return panelIndexPatterns; - } - - const updateIndexPatternsOperator = pipe( - filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), - map(getDashboardIndexPatterns), - distinctUntilChanged((a, b) => - deepEqual( - a.map((ip) => ip.id), - b.map((ip) => ip.id) - ) - ), - // using switchMap for previous task cancellation - switchMap((panelIndexPatterns: IndexPattern[]) => { - return new Observable((observer) => { - if (panelIndexPatterns && panelIndexPatterns.length > 0) { - $scope.$evalAsync(() => { - if (observer.closed) return; - $scope.indexPatterns = panelIndexPatterns; - observer.complete(); - }); - } else { - indexPatterns.getDefault().then((defaultIndexPattern) => { - if (observer.closed) return; - $scope.$evalAsync(() => { - if (observer.closed) return; - $scope.indexPatterns = [defaultIndexPattern as IndexPattern]; - observer.complete(); - }); - }); - } - }); - }) - ); - - const getEmptyScreenProps = ( - shouldShowEditHelp: boolean, - isEmptyInReadOnlyMode: boolean - ): DashboardEmptyScreenProps => { - const emptyScreenProps: DashboardEmptyScreenProps = { - onLinkClick: shouldShowEditHelp ? $scope.showAddPanel : $scope.enterEditMode, - showLinkToVisualize: shouldShowEditHelp, - uiSettings, - http, - }; - if (shouldShowEditHelp) { - emptyScreenProps.onVisualizeClick = addVisualization; - } - if (isEmptyInReadOnlyMode) { - emptyScreenProps.isReadonlyMode = true; - } - return emptyScreenProps; - }; - - const getDashboardInput = (): DashboardContainerInput => { - const embeddablesMap: { - [key: string]: DashboardPanelState; - } = {}; - dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => { - embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); - }); - - // If the incoming embeddable state's id already exists in the embeddables map, replace the input, retaining the existing gridData for that panel. - if (incomingEmbeddable?.embeddableId && embeddablesMap[incomingEmbeddable.embeddableId]) { - const originalPanelState = embeddablesMap[incomingEmbeddable.embeddableId]; - embeddablesMap[incomingEmbeddable.embeddableId] = { - gridData: originalPanelState.gridData, - type: incomingEmbeddable.type, - explicitInput: { - ...originalPanelState.explicitInput, - ...incomingEmbeddable.input, - id: incomingEmbeddable.embeddableId, - }, - }; - incomingEmbeddable = undefined; - } - - const shouldShowEditHelp = getShouldShowEditHelp(); - const shouldShowViewHelp = getShouldShowViewHelp(); - const isEmptyInReadonlyMode = shouldShowUnauthorizedEmptyState(); - return { - id: dashboardStateManager.savedDashboard.id || '', - filters: filterManager.getFilters(), - hidePanelTitles: dashboardStateManager.getHidePanelTitles(), - query: $scope.model.query, - timeRange: { - ..._.cloneDeep(timefilter.getTime()), - }, - refreshConfig: timefilter.getRefreshInterval(), - viewMode: dashboardStateManager.getViewMode(), - panels: embeddablesMap, - isFullScreenMode: dashboardStateManager.getFullScreenMode(), - isEmbeddedExternally, - isEmptyState: shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadonlyMode, - useMargins: dashboardStateManager.getUseMargins(), - lastReloadRequestTime, - title: dashboardStateManager.getTitle(), - description: dashboardStateManager.getDescription(), - expandedPanelId: dashboardStateManager.getExpandedPanelId(), - }; - }; - - const updateState = () => { - // Following the "best practice" of always have a '.' in your ng-models – - // https://github.com/angular/angular.js/wiki/Understanding-Scopes - $scope.model = { - query: dashboardStateManager.getQuery(), - filters: filterManager.getFilters(), - timeRestore: dashboardStateManager.getTimeRestore(), - title: dashboardStateManager.getTitle(), - description: dashboardStateManager.getDescription(), - timeRange: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - }; - $scope.panels = dashboardStateManager.getPanels(); - }; - - updateState(); - - let dashboardContainer: DashboardContainer | undefined; - let inputSubscription: Subscription | undefined; - let outputSubscription: Subscription | undefined; - - const dashboardDom = document.getElementById('dashboardViewport'); - const dashboardFactory = embeddable.getEmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer - >(DASHBOARD_CONTAINER_TYPE); - - searchService.session.setSearchSessionInfoProvider( - createSessionRestorationDataProvider({ - data, - getDashboardTitle: () => getDashTitle(), - getDashboardId: () => dash.id, - getAppState: () => dashboardStateManager.getAppState(), - }) - ); - - if (dashboardFactory) { - const searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - searchService.session.restore(searchSessionIdFromURL); - } - const searchSessionId = searchSessionIdFromURL ?? searchService.session.start(); - dashboardFactory - .create({ ...getDashboardInput(), searchSessionId }) - .then((container: DashboardContainer | ErrorEmbeddable | undefined) => { - if (container && !isErrorEmbeddable(container)) { - dashboardContainer = container; - - dashboardContainer.renderEmpty = () => { - const shouldShowEditHelp = getShouldShowEditHelp(); - const shouldShowViewHelp = getShouldShowViewHelp(); - const isEmptyInReadOnlyMode = shouldShowUnauthorizedEmptyState(); - const isEmptyState = - shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadOnlyMode; - return isEmptyState ? ( - - ) : null; - }; - - outputSubscription = merge( - // output of dashboard container itself - dashboardContainer.getOutput$(), - // plus output of dashboard container children, - // children may change, so make sure we subscribe/unsubscribe with switchMap - dashboardContainer.getOutput$().pipe( - map(() => dashboardContainer!.getChildIds()), - distinctUntilChanged(deepEqual), - switchMap((newChildIds: string[]) => - merge( - ...newChildIds.map((childId) => - dashboardContainer! - .getChild(childId) - .getOutput$() - .pipe(catchError(() => EMPTY)) - ) - ) - ) - ) - ) - .pipe( - mapTo(dashboardContainer), - startWith(dashboardContainer), // to trigger initial index pattern update - updateIndexPatternsOperator - ) - .subscribe(); - - inputSubscription = dashboardContainer.getInput$().subscribe(() => { - let dirty = false; - - // This has to be first because handleDashboardContainerChanges causes - // appState.save which will cause refreshDashboardContainer to be called. - - if ( - !esFilters.compareFilters( - container.getInput().filters, - filterManager.getFilters(), - esFilters.COMPARE_ALL_OPTIONS - ) - ) { - // Add filters modifies the object passed to it, hence the clone deep. - filterManager.addFilters(_.cloneDeep(container.getInput().filters)); - - dashboardStateManager.applyFilters( - $scope.model.query, - container.getInput().filters - ); - dirty = true; - } - - dashboardStateManager.handleDashboardContainerChanges(container); - $scope.$evalAsync(() => { - if (dirty) { - updateState(); - } - }); - }); - - dashboardStateManager.registerChangeListener(() => { - // we aren't checking dirty state because there are changes the container needs to know about - // that won't make the dashboard "dirty" - like a view mode change. - refreshDashboardContainer(); - }); - - // If the incomingEmbeddable does not yet exist in the panels listing, create a new panel using the container's addEmbeddable method. - if ( - incomingEmbeddable && - (!incomingEmbeddable.embeddableId || - !container.getInput().panels[incomingEmbeddable.embeddableId]) - ) { - container.addNewEmbeddable( - incomingEmbeddable.type, - incomingEmbeddable.input - ); - updateViewMode(ViewMode.EDIT); - } - } - - if (dashboardDom && container) { - container.render(dashboardDom); - } - }); - } - - // Part of the exposed plugin API - do not remove without careful consideration. - this.appStatus = { - dirty: !dash.id, - }; - - dashboardStateManager.registerChangeListener((status) => { - this.appStatus.dirty = status.dirty || !dash.id; - updateState(); - }); - - dashboardStateManager.applyFilters( - dashboardStateManager.getQuery() || queryStringManager.getDefaultQuery(), - filterManager.getFilters() - ); - - // Push breadcrumbs to new header navigation - const updateBreadcrumbs = () => { - chrome.setBreadcrumbs([ - { - text: i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { - defaultMessage: 'Dashboard', - }), - href: landingPageUrl(), - }, - { text: getDashTitle() }, - ]); - }; - - updateBreadcrumbs(); - dashboardStateManager.registerChangeListener(updateBreadcrumbs); - - const getChangesFromAppStateForContainerState = () => { - const appStateDashboardInput = getDashboardInput(); - if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { - return appStateDashboardInput; - } - - const containerInput = dashboardContainer.getInput(); - const differences: Partial = {}; - - // Filters shouldn't be compared using regular isEqual - if ( - !esFilters.compareFilters( - containerInput.filters, - appStateDashboardInput.filters, - esFilters.COMPARE_ALL_OPTIONS - ) - ) { - differences.filters = appStateDashboardInput.filters; - } - - Object.keys(_.omit(containerInput, ['filters', 'searchSessionId'])).forEach((key) => { - const containerValue = (containerInput as { [key: string]: unknown })[key]; - const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[ - key - ]; - if (!_.isEqual(containerValue, appStateValue)) { - (differences as { [key: string]: unknown })[key] = appStateValue; - } - }); - - // cloneDeep hack is needed, as there are multiple place, where container's input mutated, - // but values from appStateValue are deeply frozen, as they can't be mutated directly - return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences); - }; - - const refreshDashboardContainer = () => { - const changes = getChangesFromAppStateForContainerState(); - if (changes && dashboardContainer) { - if (getSearchSessionIdFromURL(history)) { - // going away from a background search results - removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true); - } - - // state keys change in which likely won't need a data fetch - const noRefetchKeys: Array = [ - 'viewMode', - 'title', - 'description', - 'expandedPanelId', - 'useMargins', - 'isEmbeddedExternally', - 'isFullScreenMode', - 'isEmptyState', - ]; - - const shouldRefetch = Object.keys(changes).some( - (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) - ); - - dashboardContainer.updateInput({ - ...changes, - // do not start a new session if this is irrelevant state change to prevent excessive searches - ...(shouldRefetch && { searchSessionId: searchService.session.start() }), - }); - } - }; - - $scope.handleRefresh = function (_payload, isUpdate) { - if (isUpdate === false) { - // The user can still request a reload in the query bar, even if the - // query is the same, and in that case, we have to explicitly ask for - // a reload, since no state changes will cause it. - lastReloadRequestTime = new Date().getTime(); - refreshDashboardContainer(); - } - }; - - const searchServiceSessionRefreshSubscribtion = searchService.session.onRefresh$.subscribe( - () => { - lastReloadRequestTime = new Date().getTime(); - refreshDashboardContainer(); - } - ); - - const updateStateFromSavedQuery = (savedQuery: SavedQuery) => { - const allFilters = filterManager.getFilters(); - dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters); - if (savedQuery.attributes.timefilter) { - timefilter.setTime({ - from: savedQuery.attributes.timefilter.from, - to: savedQuery.attributes.timefilter.to, - }); - if (savedQuery.attributes.timefilter.refreshInterval) { - timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); - } - } - // Making this method sync broke the updates. - // Temporary fix, until we fix the complex state in this file. - setTimeout(() => { - filterManager.setFilters(allFilters); - }, 0); - }; - - $scope.$watch('savedQuery', (newSavedQuery: SavedQuery) => { - if (!newSavedQuery) return; - dashboardStateManager.setSavedQueryId(newSavedQuery.id); - - updateStateFromSavedQuery(newSavedQuery); - }); - - $scope.$watch( - () => { - return dashboardStateManager.getSavedQueryId(); - }, - (newSavedQueryId) => { - if (!newSavedQueryId) { - $scope.savedQuery = undefined; - return; - } - if (!$scope.savedQuery || newSavedQueryId !== $scope.savedQuery.id) { - savedQueryService.getSavedQuery(newSavedQueryId).then((savedQuery: SavedQuery) => { - $scope.$evalAsync(() => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }); - }); - } - } - ); - - $scope.indexPatterns = []; - - $scope.$watch( - () => dashboardCapabilities.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability as boolean; - } - ); - - const onSavedQueryIdChange = (savedQueryId?: string) => { - dashboardStateManager.setSavedQueryId(savedQueryId); - }; - - const shouldShowFilterBar = (forceHide: boolean): boolean => - !forceHide && ($scope.model.filters.length > 0 || !dashboardStateManager.getFullScreenMode()); - - const shouldShowNavBarComponent = (forceShow: boolean): boolean => - (forceShow || $scope.isVisible) && !dashboardStateManager.getFullScreenMode(); - - const getNavBarProps = () => { - const isFullScreenMode = dashboardStateManager.getFullScreenMode(); - const screenTitle = dashboardStateManager.getTitle(); - const showTopNavMenu = shouldShowNavBarComponent(forceShowTopNavMenu); - const showQueryInput = shouldShowNavBarComponent(forceShowQueryInput); - const showDatePicker = shouldShowNavBarComponent(forceShowDatePicker); - const showQueryBar = showQueryInput || showDatePicker; - const showFilterBar = shouldShowFilterBar(forceHideFilterBar); - const showSearchBar = showQueryBar || showFilterBar; - - return { - appName: 'dashboard', - config: showTopNavMenu ? $scope.topNavMenu : undefined, - className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, - screenTitle, - showTopNavMenu, - showSearchBar, - showQueryBar, - showQueryInput, - showDatePicker, - showFilterBar, - indexPatterns: $scope.indexPatterns, - showSaveQuery: $scope.showSaveQuery, - savedQuery: $scope.savedQuery, - onSavedQueryIdChange, - savedQueryId: dashboardStateManager.getSavedQueryId(), - useDefaultBehaviors: true, - onQuerySubmit: $scope.handleRefresh, - }; - }; - const dashboardNavBar = document.getElementById('dashboardChrome'); - const updateNavBar = () => { - ReactDOM.render( - , - dashboardNavBar - ); - }; - - const unmountNavBar = () => { - if (dashboardNavBar) { - ReactDOM.unmountComponentAtNode(dashboardNavBar); - } - }; - - $scope.timefilterSubscriptions$ = new Subscription(); - const timeChanges$ = merge(timefilter.getRefreshIntervalUpdate$(), timefilter.getTimeUpdate$()); - $scope.timefilterSubscriptions$.add( - subscribeWithScope( - $scope, - timeChanges$, - { - next: () => { - updateState(); - refreshDashboardContainer(); - }, - }, - (error: AngularHttpError | Error | string) => addFatalError(fatalErrors, error) - ) - ); - - function updateViewMode(newMode: ViewMode) { - dashboardStateManager.switchViewMode(newMode); - } - - const onChangeViewMode = (newMode: ViewMode) => { - const isPageRefresh = newMode === dashboardStateManager.getViewMode(); - const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; - const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); - - if (!willLoseChanges) { - updateViewMode(newMode); - return; - } - - function revertChangesAndExitEditMode() { - dashboardStateManager.resetState(); - // This is only necessary for new dashboards, which will default to Edit mode. - updateViewMode(ViewMode.VIEW); - - // We need to do a hard reset of the timepicker. appState will not reload like - // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on - // reload will cause it not to sync. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - - // Angular's $location skips this update because of history updates from syncState which happen simultaneously - // when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it, - // the update is considered outdated and angular skips it - // so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues - dashboardStateManager.changeDashboardUrl( - dash.id ? createDashboardEditUrl(dash.id) : DashboardConstants.CREATE_NEW_DASHBOARD_URL - ); - } - - overlays - .openConfirm( - i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', { - defaultMessage: `Once you discard your changes, there's no getting them back.`, - }), - { - confirmButtonText: i18n.translate( - 'dashboard.changeViewModeConfirmModal.confirmButtonLabel', - { defaultMessage: 'Discard changes' } - ), - cancelButtonText: i18n.translate( - 'dashboard.changeViewModeConfirmModal.cancelButtonLabel', - { defaultMessage: 'Continue editing' } - ), - defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, - title: i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { - defaultMessage: 'Discard changes to dashboard?', - }), - } - ) - .then((isConfirmed) => { - if (isConfirmed) { - revertChangesAndExitEditMode(); - } - }); - - updateNavBar(); - }; - - /** - * Saves the dashboard. - * - * @param {object} [saveOptions={}] - * @property {boolean} [saveOptions.confirmOverwrite=false] - If true, attempts to create the source so it - * can confirm an overwrite if a document with the id already exists. - * @property {boolean} [saveOptions.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title - * @property {func} [saveOptions.onTitleDuplicate] - function called if duplicate title exists. - * When not provided, confirm modal will be displayed asking user to confirm or cancel save. - * @return {Promise} - * @resolved {String} - The id of the doc - */ - function save(saveOptions: SavedObjectSaveOpts): Promise { - return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions) - .then(function (id) { - if (id) { - notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { - defaultMessage: `Dashboard '{dashTitle}' was saved`, - values: { dashTitle: dash.title }, - }), - 'data-test-subj': 'saveDashboardSuccess', - }); - - if (dash.id !== $routeParams.id) { - // Angular's $location skips this update because of history updates from syncState which happen simultaneously - // when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it, - // the update is considered outdated and angular skips it - // so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues - dashboardStateManager.changeDashboardUrl(createDashboardEditUrl(dash.id)); - } else { - chrome.docTitle.change(dash.lastSavedTitle); - updateViewMode(ViewMode.VIEW); - } - } - return { id }; - }) - .catch((error) => { - notifications.toasts.addDanger({ - title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { - defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, - values: { - dashTitle: dash.title, - errorMessage: error.message, - }, - }), - 'data-test-subj': 'saveDashboardFailure', - }); - return { error }; - }); - } - - $scope.showAddPanel = () => { - dashboardStateManager.setFullScreenMode(false); - /* - * Temp solution for triggering menu click. - * When de-angularizing this code, please call the underlaying action function - * directly and not via the top nav object. - **/ - navActions[TopNavIds.ADD_EXISTING](); - }; - $scope.enterEditMode = () => { - dashboardStateManager.setFullScreenMode(false); - /* - * Temp solution for triggering menu click. - * When de-angularizing this code, please call the underlaying action function - * directly and not via the top nav object. - **/ - navActions[TopNavIds.ENTER_EDIT_MODE](); - }; - const navActions: { - [key: string]: NavAction; - } = {}; - navActions[TopNavIds.FULL_SCREEN] = () => { - dashboardStateManager.setFullScreenMode(true); - updateNavBar(); - }; - navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(ViewMode.VIEW); - navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(ViewMode.EDIT); - navActions[TopNavIds.SAVE] = () => { - const currentTitle = dashboardStateManager.getTitle(); - const currentDescription = dashboardStateManager.getDescription(); - const currentTimeRestore = dashboardStateManager.getTimeRestore(); - - let currentTags: string[] = []; - if (savedObjectsTagging) { - const dashboard = dashboardStateManager.savedDashboard; - if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { - currentTags = dashboard.getTags(); - } - } - - const onSave = ({ - newTitle, - newDescription, - newCopyOnSave, - newTimeRestore, - isTitleDuplicateConfirmed, - onTitleDuplicate, - newTags, - }: SaveOptions) => { - dashboardStateManager.setTitle(newTitle); - dashboardStateManager.setDescription(newDescription); - dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; - dashboardStateManager.setTimeRestore(newTimeRestore); - if (savedObjectsTagging && newTags) { - dashboardStateManager.setTags(newTags); - } - - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return save(saveOptions).then((response: SaveResult) => { - // If the save wasn't successful, put the original values back. - if (!(response as { id: string }).id) { - dashboardStateManager.setTitle(currentTitle); - dashboardStateManager.setDescription(currentDescription); - dashboardStateManager.setTimeRestore(currentTimeRestore); - if (savedObjectsTagging) { - dashboardStateManager.setTags(currentTags); - } - } - return response; - }); - }; - - const dashboardSaveModal = ( - {}} - title={currentTitle} - description={currentDescription} - tags={currentTags} - savedObjectsTagging={savedObjectsTagging} - timeRestore={currentTimeRestore} - showCopyOnSave={dash.id ? true : false} - /> - ); - showSaveModal(dashboardSaveModal, i18nStart.Context); - }; - navActions[TopNavIds.CLONE] = () => { - const currentTitle = dashboardStateManager.getTitle(); - const onClone = ( - newTitle: string, - isTitleDuplicateConfirmed: boolean, - onTitleDuplicate: () => void - ) => { - dashboardStateManager.savedDashboard.copyOnSave = true; - dashboardStateManager.setTitle(newTitle); - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return save(saveOptions).then((response: { id?: string } | { error: Error }) => { - // If the save wasn't successful, put the original title back. - if ((response as { error: Error }).error) { - dashboardStateManager.setTitle(currentTitle); - } - updateNavBar(); - return response; - }); - }; - - showCloneModal(onClone, currentTitle); - }; - - navActions[TopNavIds.ADD_EXISTING] = () => { - if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { - openAddPanelFlyout({ - embeddable: dashboardContainer, - getAllFactories: embeddable.getEmbeddableFactories, - getFactory: embeddable.getEmbeddableFactory, - notifications, - overlays, - SavedObjectFinder: getSavedObjectFinder(savedObjects, uiSettings), - }); - } - }; - - navActions[TopNavIds.VISUALIZE] = async () => { - const type = 'visualization'; - const factory = embeddable.getEmbeddableFactory(type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(type); - } - const explicitInput = await factory.getExplicitInput(); - if (dashboardContainer) { - await dashboardContainer.addNewEmbeddable(type, explicitInput); - } - }; - - navActions[TopNavIds.OPTIONS] = (anchorElement) => { - showOptionsPopover({ - anchorElement, - useMargins: dashboardStateManager.getUseMargins(), - onUseMarginsChange: (isChecked: boolean) => { - dashboardStateManager.setUseMargins(isChecked); - }, - hidePanelTitles: dashboardStateManager.getHidePanelTitles(), - onHidePanelTitlesChange: (isChecked: boolean) => { - dashboardStateManager.setHidePanelTitles(isChecked); - }, - }); - }; - - if (share) { - // the share button is only availabale if "share" plugin contract enabled - navActions[TopNavIds.SHARE] = (anchorElement) => { - const EmbedUrlParamExtension = ({ - setParamValue, - }: { - setParamValue: (paramUpdate: UrlParamValues) => void; - }): ReactElement => { - const [urlParamsSelectedMap, setUrlParamsSelectedMap] = useState({ - [UrlParams.SHOW_TOP_MENU]: false, - [UrlParams.SHOW_QUERY_INPUT]: false, - [UrlParams.SHOW_TIME_FILTER]: false, - [UrlParams.SHOW_FILTER_BAR]: true, - }); - - const checkboxes = [ - { - id: UrlParams.SHOW_TOP_MENU, - label: i18n.translate('dashboard.embedUrlParamExtension.topMenu', { - defaultMessage: 'Top menu', - }), - }, - { - id: UrlParams.SHOW_QUERY_INPUT, - label: i18n.translate('dashboard.embedUrlParamExtension.query', { - defaultMessage: 'Query', - }), - }, - { - id: UrlParams.SHOW_TIME_FILTER, - label: i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { - defaultMessage: 'Time filter', - }), - }, - { - id: UrlParams.SHOW_FILTER_BAR, - label: i18n.translate('dashboard.embedUrlParamExtension.filterBar', { - defaultMessage: 'Filter bar', - }), - }, - ]; - - const handleChange = (param: string): void => { - const urlParamsSelectedMapUpdate = { - ...urlParamsSelectedMap, - [param]: !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], - }; - setUrlParamsSelectedMap(urlParamsSelectedMapUpdate); - - const urlParamValues = { - [UrlParams.SHOW_TOP_MENU]: urlParamsSelectedMap[UrlParams.SHOW_TOP_MENU], - [UrlParams.SHOW_QUERY_INPUT]: urlParamsSelectedMap[UrlParams.SHOW_QUERY_INPUT], - [UrlParams.SHOW_TIME_FILTER]: urlParamsSelectedMap[UrlParams.SHOW_TIME_FILTER], - [UrlParams.HIDE_FILTER_BAR]: !urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR], - [param === UrlParams.SHOW_FILTER_BAR ? UrlParams.HIDE_FILTER_BAR : param]: - param === UrlParams.SHOW_FILTER_BAR - ? urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR] - : !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], - }; - setParamValue(urlParamValues); - }; - - return ( - - ); - }; - - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: true, - allowShortUrl: - !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, - shareableUrl: setStateToKbnUrl( - '_a', - dashboardStateManager.getAppState(), - { useHash: false, storeInHashQuery: true }, - unhashUrl(window.location.href) - ), - objectId: dash.id, - objectType: 'dashboard', - sharingData: { - title: dash.title, - }, - isDirty: dashboardStateManager.getIsDirty(), - embedUrlParamExtensions: [ - { - paramName: 'embed', - component: EmbedUrlParamExtension, - }, - ], - }); - }; - } - - updateViewMode(dashboardStateManager.getViewMode()); - - const filterChanges = merge(filterManager.getUpdates$(), queryStringManager.getUpdates$()).pipe( - debounceTime(100) - ); - - // update root source when filters update - const updateSubscription = filterChanges.subscribe({ - next: () => { - $scope.model.filters = filterManager.getFilters(); - $scope.model.query = queryStringManager.getQuery(); - dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters); - }, - }); - - const visibleSubscription = chrome.getIsVisible$().subscribe((isVisible) => { - $scope.$evalAsync(() => { - $scope.isVisible = isVisible; - updateNavBar(); - }); - }); - - dashboardStateManager.registerChangeListener(() => { - // view mode could have changed, so trigger top nav update - $scope.topNavMenu = getTopNavConfig( - dashboardStateManager.getViewMode(), - navActions, - dashboardConfig.getHideWriteControls() - ); - updateNavBar(); - }); - - $scope.$watch('indexPatterns', () => { - updateNavBar(); - }); - - $scope.$on('$destroy', () => { - // we have to unmount nav bar manually to make sure all internal subscriptions are unsubscribed - unmountNavBar(); - - updateSubscription.unsubscribe(); - stopSyncingQueryServiceStateWithUrl(); - stopSyncingAppFilters(); - visibleSubscription.unsubscribe(); - $scope.timefilterSubscriptions$.unsubscribe(); - - dashboardStateManager.destroy(); - if (inputSubscription) { - inputSubscription.unsubscribe(); - } - if (outputSubscription) { - outputSubscription.unsubscribe(); - } - if (dashboardContainer) { - dashboardContainer.destroy(); - } - searchServiceSessionRefreshSubscribtion.unsubscribe(); - searchService.session.clear(); - }); - } -} diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts new file mode 100644 index 0000000000000..0381fdb2e55b5 --- /dev/null +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -0,0 +1,287 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; + +import _, { uniqBy } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { merge, Observable, pipe } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + filter, + map, + mapTo, + startWith, + switchMap, +} from 'rxjs/operators'; + +import { DashboardCapabilities } from './types'; +import { DashboardConstants } from '../dashboard_constants'; +import { DashboardStateManager } from './dashboard_state_manager'; +import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; +import { + DashboardPanelState, + DashboardContainer, + DashboardContainerInput, + SavedDashboardPanel, +} from '.'; + +import { getQueryParams } from '../services/kibana_utils'; +import { EmbeddablePackageState, isErrorEmbeddable } from '../services/embeddable'; +import { + esFilters, + FilterManager, + IndexPattern, + IndexPatternsContract, + QueryStart, +} from '../services/data'; + +export const getChangesFromAppStateForContainerState = ({ + dashboardContainer, + appStateDashboardInput, +}: { + dashboardContainer: DashboardContainer; + appStateDashboardInput: DashboardContainerInput; +}) => { + if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { + return appStateDashboardInput; + } + const containerInput = dashboardContainer.getInput(); + const differences: Partial = {}; + + // Filters shouldn't be compared using regular isEqual + if ( + !esFilters.compareFilters( + containerInput.filters, + appStateDashboardInput.filters, + esFilters.COMPARE_ALL_OPTIONS + ) + ) { + differences.filters = appStateDashboardInput.filters; + } + + Object.keys( + _.omit(containerInput, [ + 'filters', + 'searchSessionId', + 'lastReloadRequestTime', + 'switchViewMode', + ]) + ).forEach((key) => { + const containerValue = (containerInput as { [key: string]: unknown })[key]; + const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[key]; + if (!_.isEqual(containerValue, appStateValue)) { + (differences as { [key: string]: unknown })[key] = appStateValue; + } + }); + + // last reload request time can be undefined without causing a refresh + if ( + appStateDashboardInput.lastReloadRequestTime && + containerInput.lastReloadRequestTime !== appStateDashboardInput.lastReloadRequestTime + ) { + differences.lastReloadRequestTime = appStateDashboardInput.lastReloadRequestTime; + } + + // cloneDeep hack is needed, as there are multiple places, where container's input mutated, + // but values from appStateValue are deeply frozen, as they can't be mutated directly + return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences); +}; + +export const getDashboardContainerInput = ({ + query, + searchSessionId, + incomingEmbeddable, + isEmbeddedExternally, + lastReloadRequestTime, + dashboardStateManager, + dashboardCapabilities, +}: { + dashboardCapabilities: DashboardCapabilities; + dashboardStateManager: DashboardStateManager; + incomingEmbeddable?: EmbeddablePackageState; + lastReloadRequestTime?: number; + isEmbeddedExternally: boolean; + searchSessionId?: string; + query: QueryStart; +}): DashboardContainerInput => { + const embeddablesMap: { + [key: string]: DashboardPanelState; + } = {}; + dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => { + embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); + }); + + // If the incoming embeddable state's id already exists in the embeddables map, replace the input, retaining the existing gridData for that panel. + if (incomingEmbeddable?.embeddableId && embeddablesMap[incomingEmbeddable.embeddableId]) { + const originalPanelState = embeddablesMap[incomingEmbeddable.embeddableId]; + embeddablesMap[incomingEmbeddable.embeddableId] = { + gridData: originalPanelState.gridData, + type: incomingEmbeddable.type, + explicitInput: { + ...originalPanelState.explicitInput, + ...incomingEmbeddable.input, + id: incomingEmbeddable.embeddableId, + }, + }; + } + + return { + refreshConfig: query.timefilter.timefilter.getRefreshInterval(), + hidePanelTitles: dashboardStateManager.getHidePanelTitles(), + isFullScreenMode: dashboardStateManager.getFullScreenMode(), + expandedPanelId: dashboardStateManager.getExpandedPanelId(), + description: dashboardStateManager.getDescription(), + id: dashboardStateManager.savedDashboard.id || '', + useMargins: dashboardStateManager.getUseMargins(), + viewMode: dashboardStateManager.getViewMode(), + filters: query.filterManager.getFilters(), + query: dashboardStateManager.getQuery(), + title: dashboardStateManager.getTitle(), + panels: embeddablesMap, + lastReloadRequestTime, + dashboardCapabilities, + isEmbeddedExternally, + searchSessionId, + timeRange: { + ..._.cloneDeep(query.timefilter.timefilter.getTime()), + }, + }; +}; + +export const getInputSubscription = ({ + dashboardContainer, + dashboardStateManager, + filterManager, +}: { + dashboardContainer: DashboardContainer; + dashboardStateManager: DashboardStateManager; + filterManager: FilterManager; +}) => + dashboardContainer.getInput$().subscribe(() => { + // This has to be first because handleDashboardContainerChanges causes + // appState.save which will cause refreshDashboardContainer to be called. + + if ( + !esFilters.compareFilters( + dashboardContainer.getInput().filters, + filterManager.getFilters(), + esFilters.COMPARE_ALL_OPTIONS + ) + ) { + // Add filters modifies the object passed to it, hence the clone deep. + filterManager.addFilters(_.cloneDeep(dashboardContainer.getInput().filters)); + + dashboardStateManager.applyFilters( + dashboardStateManager.getQuery(), + dashboardContainer.getInput().filters + ); + } + + dashboardStateManager.handleDashboardContainerChanges(dashboardContainer); + }); + +export const getOutputSubscription = ({ + dashboardContainer, + indexPatterns, + onUpdateIndexPatterns, +}: { + dashboardContainer: DashboardContainer; + indexPatterns: IndexPatternsContract; + onUpdateIndexPatterns: (newIndexPatterns: IndexPattern[]) => void; +}) => { + const updateIndexPatternsOperator = pipe( + filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), + map((container: DashboardContainer): IndexPattern[] => { + let panelIndexPatterns: IndexPattern[] = []; + Object.values(container.getChildIds()).forEach((id) => { + const embeddableInstance = container.getChild(id); + if (isErrorEmbeddable(embeddableInstance)) return; + const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns; + if (!embeddableIndexPatterns) return; + panelIndexPatterns.push(...embeddableIndexPatterns); + }); + panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); + return panelIndexPatterns; + }), + distinctUntilChanged((a, b) => + deepEqual( + a.map((ip) => ip.id), + b.map((ip) => ip.id) + ) + ), + // using switchMap for previous task cancellation + switchMap((panelIndexPatterns: IndexPattern[]) => { + return new Observable((observer) => { + if (panelIndexPatterns && panelIndexPatterns.length > 0) { + if (observer.closed) return; + onUpdateIndexPatterns(panelIndexPatterns); + observer.complete(); + } else { + indexPatterns.getDefault().then((defaultIndexPattern) => { + if (observer.closed) return; + onUpdateIndexPatterns([defaultIndexPattern as IndexPattern]); + observer.complete(); + }); + } + }); + }) + ); + + return merge( + // output of dashboard container itself + dashboardContainer.getOutput$(), + // plus output of dashboard container children, + // children may change, so make sure we subscribe/unsubscribe with switchMap + dashboardContainer.getOutput$().pipe( + map(() => dashboardContainer!.getChildIds()), + distinctUntilChanged(deepEqual), + switchMap((newChildIds: string[]) => + merge(...newChildIds.map((childId) => dashboardContainer!.getChild(childId).getOutput$())) + ) + ) + ) + .pipe( + mapTo(dashboardContainer), + startWith(dashboardContainer), // to trigger initial index pattern update + updateIndexPatternsOperator + ) + .subscribe(); +}; + +export const getFiltersSubscription = ({ + query, + dashboardStateManager, +}: { + query: QueryStart; + dashboardStateManager: DashboardStateManager; +}) => { + return merge(query.filterManager.getUpdates$(), query.queryString.getUpdates$()) + .pipe(debounceTime(100)) + .subscribe(() => { + dashboardStateManager.applyFilters( + query.queryString.getQuery(), + query.filterManager.getFilters() + ); + }); +}; + +export const getSearchSessionIdFromURL = (history: History): string | undefined => + getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined; diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx b/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx deleted file mode 100644 index 4904d08e958d5..0000000000000 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; - -/** READONLY VIEW CONSTANTS **/ -export const emptyDashboardTitle: string = i18n.translate('dashboard.emptyDashboardTitle', { - defaultMessage: 'This dashboard is empty.', -}); -export const emptyDashboardAdditionalPrivilege = i18n.translate( - 'dashboard.emptyDashboardAdditionalPrivilege', - { - defaultMessage: 'You need additional privileges to edit this dashboard.', - } -); -/** VIEW MODE CONSTANTS **/ -export const fillDashboardTitle: string = i18n.translate('dashboard.fillDashboardTitle', { - defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', -}); -export const howToStartWorkingOnNewDashboardDescription1: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardDescription1', - { - defaultMessage: 'Click', - } -); -export const howToStartWorkingOnNewDashboardDescription2: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardDescription2', - { - defaultMessage: 'in the menu bar above to start adding panels.', - } -); -export const howToStartWorkingOnNewDashboardEditLinkText: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardEditLinkText', - { - defaultMessage: 'Edit', - } -); -export const howToStartWorkingOnNewDashboardEditLinkAriaLabel: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', - { - defaultMessage: 'Edit dashboard', - } -); -/** EDIT MODE CONSTANTS **/ -export const addExistingVisualizationLinkText: string = i18n.translate( - 'dashboard.addExistingVisualizationLinkText', - { - defaultMessage: 'Add an existing', - } -); -export const addExistingVisualizationLinkAriaLabel: string = i18n.translate( - 'dashboard.addVisualizationLinkAriaLabel', - { - defaultMessage: 'Add an existing visualization', - } -); -export const addNewVisualizationDescription: string = i18n.translate( - 'dashboard.addNewVisualizationText', - { - defaultMessage: 'or new object to this dashboard', - } -); -export const createNewVisualizationButton: string = i18n.translate( - 'dashboard.createNewVisualizationButton', - { - defaultMessage: 'Create new', - } -); -export const createNewVisualizationButtonAriaLabel: string = i18n.translate( - 'dashboard.createNewVisualizationButtonAriaLabel', - { - defaultMessage: 'Create new visualization button', - } -); diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx new file mode 100644 index 0000000000000..9673737372478 --- /dev/null +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -0,0 +1,226 @@ +/* + * 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 './index.scss'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { parse, ParsedQuery } from 'query-string'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Switch, Route, RouteComponentProps, HashRouter } from 'react-router-dom'; + +import { DashboardListing } from './listing'; +import { DashboardApp } from './dashboard_app'; +import { addHelpMenuToAppChrome } from './lib'; +import { createDashboardListingFilterUrl } from '../dashboard_constants'; +import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings'; +import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; +import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from './types'; +import { DashboardSetupDependencies, DashboardStart, DashboardStartDependencies } from '../plugin'; + +import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils'; +import { KibanaContextProvider } from '../services/kibana_react'; +import { + AppMountParameters, + CoreSetup, + PluginInitializerContext, + ScopedHistory, +} from '../services/core'; + +export const dashboardUrlParams = { + showTopMenu: 'show-top-menu', + showQueryInput: 'show-query-input', + showTimeFilter: 'show-time-filter', + hideFilterBar: 'hide-filter-bar', +}; + +export interface DashboardMountProps { + appUnMounted: () => void; + restorePreviousUrl: () => void; + scopedHistory: ScopedHistory; + element: AppMountParameters['element']; + initializerContext: PluginInitializerContext; + onAppLeave: AppMountParameters['onAppLeave']; + core: CoreSetup; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + usageCollection: DashboardSetupDependencies['usageCollection']; +} + +export async function mountApp({ + core, + element, + onAppLeave, + appUnMounted, + scopedHistory, + usageCollection, + initializerContext, + restorePreviousUrl, + setHeaderActionMenu, +}: DashboardMountProps) { + const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); + + const { + navigation, + savedObjects, + data: dataStart, + share: shareStart, + embeddable: embeddableStart, + kibanaLegacy: { dashboardConfig }, + savedObjectsTaggingOss, + } = pluginsStart; + + const dashboardServices: DashboardAppServices = { + navigation, + onAppLeave, + savedObjects, + usageCollection, + core: coreStart, + data: dataStart, + share: shareStart, + initializerContext, + restorePreviousUrl, + setHeaderActionMenu, + chrome: coreStart.chrome, + embeddable: embeddableStart, + uiSettings: coreStart.uiSettings, + scopedHistory: () => scopedHistory, + indexPatterns: dataStart.indexPatterns, + savedQueryService: dataStart.query.savedQueries, + savedObjectsClient: coreStart.savedObjects.client, + savedDashboards: dashboardStart.getSavedDashboardLoader(), + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + dashboardCapabilities: { + hideWriteControls: dashboardConfig.getHideWriteControls(), + show: Boolean(coreStart.application.capabilities.dashboard.show), + saveQuery: Boolean(coreStart.application.capabilities.dashboard.saveQuery), + createNew: Boolean(coreStart.application.capabilities.dashboard.createNew), + mapsCapabilities: { save: Boolean(coreStart.application.capabilities.maps?.save) }, + createShortUrl: Boolean(coreStart.application.capabilities.dashboard.createShortUrl), + visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) }, + }, + }; + + const getUrlStateStorage = (history: RouteComponentProps['history']) => + createKbnUrlStateStorage({ + history, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(core.notifications.toasts), + }); + + const redirect = (routeProps: RouteComponentProps, redirectTo: RedirectToProps) => { + const historyFunction = redirectTo.useReplace + ? routeProps.history.replace + : routeProps.history.push; + let destination; + if (redirectTo.destination === 'dashboard') { + destination = redirectTo.id + ? createDashboardEditUrl(redirectTo.id) + : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + } else { + destination = createDashboardListingFilterUrl(redirectTo.filter); + } + historyFunction(destination); + }; + + const getDashboardEmbedSettings = ( + routeParams: ParsedQuery + ): DashboardEmbedSettings | undefined => { + if (!routeParams.embed) { + return undefined; + } + return { + forceShowTopNavMenu: Boolean(routeParams[dashboardUrlParams.showTopMenu]), + forceShowQueryInput: Boolean(routeParams[dashboardUrlParams.showQueryInput]), + forceShowDatePicker: Boolean(routeParams[dashboardUrlParams.showTimeFilter]), + forceHideFilterBar: Boolean(routeParams[dashboardUrlParams.hideFilterBar]), + }; + }; + + const renderDashboard = (routeProps: RouteComponentProps<{ id?: string }>) => { + const routeParams = parse(routeProps.history.location.search); + const embedSettings = getDashboardEmbedSettings(routeParams); + return ( + redirect(routeProps, props)} + /> + ); + }; + + const renderListingPage = (routeProps: RouteComponentProps) => { + coreStart.chrome.docTitle.change(getDashboardPageTitle()); + const routeParams = parse(routeProps.history.location.search); + const title = (routeParams.title as string) || undefined; + const filter = (routeParams.filter as string) || undefined; + + return ( + redirect(routeProps, props)} + /> + ); + }; + + // make sure the index pattern list is up to date + await dataStart.indexPatterns.clearCache(); + + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + const unlistenParentHistory = scopedHistory.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + const app = ( + + + + + + + + + + + ); + + addHelpMenuToAppChrome(dashboardServices.chrome, coreStart.docLinks); + if (dashboardServices.dashboardCapabilities.hideWriteControls) { + coreStart.chrome.setBadge({ + text: dashboardReadonlyBadge.getText(), + tooltip: dashboardReadonlyBadge.getTooltip(), + iconType: 'glasses', + }); + } + render(app, element); + return () => { + dataStart.search.session.clear(); + unlistenParentHistory(); + unmountComponentAtNode(element); + appUnMounted(); + }; +} diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index 14c12115fd8f5..b07ea762f35e0 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -18,14 +18,16 @@ */ import { createBrowserHistory } from 'history'; -import { DashboardStateManager } from './dashboard_state_manager'; import { getSavedDashboardMock } from './test_helpers'; -import { InputTimeRange, TimefilterContract, TimeRange } from 'src/plugins/data/public'; -import { ViewMode } from 'src/plugins/embeddable/public'; -import { createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; import { DashboardContainer, DashboardContainerInput } from '.'; -import { DashboardContainerOptions } from './embeddable/dashboard_container'; -import { embeddablePluginMock } from '../../../embeddable/public/mocks'; +import { DashboardStateManager } from './dashboard_state_manager'; +import { DashboardContainerServices } from './embeddable/dashboard_container'; + +import { ViewMode } from '../services/embeddable'; +import { createKbnUrlStateStorage } from '../services/kibana_utils'; +import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; describe('DashboardState', function () { let dashboardState: DashboardStateManager; @@ -71,7 +73,7 @@ describe('DashboardState', function () { panels: {} as DashboardContainerInput['panels'], }; const input = { ...defaultInput, ...(initialInput ?? {}) }; - return new DashboardContainer(input, { embeddable: doStart() } as DashboardContainerOptions); + return new DashboardContainer(input, { embeddable: doStart() } as DashboardContainerServices); } describe('syncTimefilterWithDashboard', function () { diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 6ef109ff60e42..daa0bbdfc9f8a 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -17,20 +17,18 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; +import { i18n } from '@kbn/i18n'; import { History } from 'history'; +import { Observable, Subscription } from 'rxjs'; -import { Filter, Query, TimefilterContract as Timefilter } from 'src/plugins/data/public'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; +import { FilterUtils } from './lib/filter_utils'; +import { DashboardContainer } from './embeddable'; +import { DashboardSavedObject } from '../saved_dashboards'; import { migrateLegacyQuery } from './lib/migrate_legacy_query'; - -import { ViewMode } from '../embeddable_plugin'; import { getAppStateDefaults, migrateAppState, getDashboardIdFromUrl } from './lib'; -import { FilterUtils } from './lib/filter_utils'; +import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters'; import { DashboardAppState, DashboardAppStateDefaults, @@ -38,16 +36,18 @@ import { DashboardAppStateTransitions, SavedDashboardPanel, } from '../types'; + +import { ViewMode } from '../services/embeddable'; +import { UsageCollectionSetup } from '../services/usage_collection'; +import { Filter, Query, TimefilterContract as Timefilter } from '../services/data'; +import type { SavedObjectTagDecoratorTypeGuard } from '../services/saved_objects_tagging_oss'; import { createStateContainer, IKbnUrlStateStorage, ISyncStateRef, ReduxLikeStateContainer, syncState, -} from '../../../kibana_utils/public'; -import { SavedObjectDashboard } from '../saved_dashboards'; -import { DashboardContainer } from './embeddable'; -import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters'; +} from '../services/kibana_utils'; /** * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the @@ -56,7 +56,7 @@ import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/ * versa. They should be as decoupled as possible so updating the store won't affect bwc of urls. */ export class DashboardStateManager { - public savedDashboard: SavedObjectDashboard; + public savedDashboard: DashboardSavedObject; public lastSavedDashboardFilters: { timeTo?: string | Moment; timeFrom?: string | Moment; @@ -105,7 +105,7 @@ export class DashboardStateManager { usageCollection, hasTaggingCapabilities, }: { - savedDashboard: SavedObjectDashboard; + savedDashboard: DashboardSavedObject; hideWriteControls: boolean; kibanaVersion: string; kbnUrlStateStorage: IKbnUrlStateStorage; diff --git a/src/plugins/dashboard/public/application/dashboard_strings.ts b/src/plugins/dashboard/public/application/dashboard_strings.ts deleted file mode 100644 index 9109012adcfa6..0000000000000 --- a/src/plugins/dashboard/public/application/dashboard_strings.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import { ViewMode } from '../embeddable_plugin'; - -/** - * @param title {string} the current title of the dashboard - * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. - * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the - * end of the title. - * @returns {string} A title to display to the user based on the above parameters. - */ -export function getDashboardTitle( - title: string, - viewMode: ViewMode, - isDirty: boolean, - isNew: boolean -): string { - const isEditMode = viewMode === ViewMode.EDIT; - let displayTitle: string; - const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', { - defaultMessage: 'New Dashboard', - }); - const dashboardTitle = isNew ? newDashboardTitle : title; - - if (isEditMode && isDirty) { - displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { - defaultMessage: 'Editing {title} (unsaved)', - values: { title: dashboardTitle }, - }); - } else if (isEditMode) { - displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { - defaultMessage: 'Editing {title}', - values: { title: dashboardTitle }, - }); - } else { - displayTitle = dashboardTitle; - } - - return displayTitle; -} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 9c337ef1259a9..2d892ac49e9d2 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -17,21 +17,40 @@ * under the License. */ -import { nextTick } from '@kbn/test/jest'; -import { isErrorEmbeddable, ViewMode } from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerOptions } from './dashboard_container'; +import React from 'react'; +import { mount } from 'enzyme'; + +import { findTestSubject, nextTick } from '@kbn/test/jest'; +import { DashboardContainer, DashboardContainerServices } from './dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; +import { I18nProvider } from '@kbn/i18n/react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + +import { KibanaContextProvider } from '../../services/kibana_react'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddablePanel, + isErrorEmbeddable, + ViewMode, +} from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddable, - ContactCardEmbeddableOutput, EMPTY_EMBEDDABLE, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + ContactCardEmbeddableOutput, + createEditModeAction, +} from '../../services/embeddable_test_samples'; +import { + applicationServiceMock, + coreMock, + uiSettingsServiceMock, +} from '../../../../../core/public/mocks'; +import { inspectorPluginMock } from '../../../../inspector/public/mocks'; +import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; -const options: DashboardContainerOptions = { +const options: DashboardContainerServices = { application: {} as any, embeddable: {} as any, notifications: {} as any, @@ -40,6 +59,8 @@ const options: DashboardContainerOptions = { SavedObjectFinder: () => null, ExitFullScreenButton: () => null, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; beforeEach(() => { @@ -49,6 +70,7 @@ beforeEach(() => { new ContactCardEmbeddableFactory((() => null) as any, {} as any) ); options.embeddable = doStart(); + options.application = applicationServiceMock.createStartContract(); }); test('DashboardContainer initializes embeddables', async (done) => { @@ -199,3 +221,71 @@ test('searchSessionId propagates to children', async () => { expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2); }); + +test('DashboardContainer in edit mode shows edit mode actions', async () => { + const inspector = inspectorPluginMock.createStartContract(); + const uiActionsSetup = uiActionsPluginMock.createSetupContract(); + + const editModeAction = createEditModeAction(); + uiActionsSetup.registerAction(editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); + + const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); + const container = new DashboardContainer(initialInput, options); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Bob', + }); + + const component = mount( + + + Promise.resolve([])} + getAllEmbeddableFactories={(() => []) as any} + getEmbeddableFactory={(() => null) as any} + notifications={{} as any} + application={options.application} + overlays={{} as any} + inspector={inspector} + SavedObjectFinder={() => null} + /> + + + ); + + const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); + + expect(button.length).toBe(1); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + + expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); + + const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); + + expect(editAction.length).toBe(0); + + container.updateInput({ viewMode: ViewMode.EDIT }); + await nextTick(); + component.update(); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + await nextTick(); + component.update(); + expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + await nextTick(); + component.update(); + expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1); + + await nextTick(); + component.update(); + + // TODO: Address this. + // const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); + // expect(action.length).toBe(1); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index e80d387fa3066..a4a79a5d183ae 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -20,21 +20,24 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { RefreshInterval, TimeRange, Query, Filter } from 'src/plugins/data/public'; -import { CoreStart } from 'src/core/public'; -import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import uuid from 'uuid'; -import { UiActionsStart } from '../../ui_actions_plugin'; +import { CoreStart, IUiSettingsClient } from 'src/core/public'; +import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; + +import { UiActionsStart } from '../../services/ui_actions'; +import { RefreshInterval, TimeRange, Query, Filter } from '../../services/data'; import { + ViewMode, Container, + PanelState, + IEmbeddable, ContainerInput, EmbeddableInput, - ViewMode, - EmbeddableFactory, - IEmbeddable, EmbeddableStart, - PanelState, -} from '../../embeddable_plugin'; + EmbeddableOutput, + EmbeddableFactory, + EmbeddableStateTransfer, +} from '../../services/embeddable'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import { createPanelState } from './panel'; import { DashboardPanelState } from './types'; @@ -43,27 +46,39 @@ import { KibanaContextProvider, KibanaReactContext, KibanaReactContextValue, -} from '../../../../kibana_react/public'; +} from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { EmbeddableStateTransfer, EmbeddableOutput } from '../../../../embeddable/public'; +import { DashboardCapabilities } from '../types'; export interface DashboardContainerInput extends ContainerInput { - viewMode: ViewMode; - filters: Filter[]; - query: Query; - timeRange: TimeRange; + dashboardCapabilities?: DashboardCapabilities; refreshConfig?: RefreshInterval; + isEmbeddedExternally?: boolean; + isFullScreenMode: boolean; expandedPanelId?: string; + timeRange: TimeRange; + description?: string; useMargins: boolean; + viewMode: ViewMode; + filters: Filter[]; title: string; - description?: string; - isEmbeddedExternally?: boolean; - isFullScreenMode: boolean; + query: Query; panels: { [panelId: string]: DashboardPanelState; }; - isEmptyState?: boolean; +} +export interface DashboardContainerServices { + ExitFullScreenButton: React.ComponentType; + SavedObjectFinder: React.ComponentType; + notifications: CoreStart['notifications']; + application: CoreStart['application']; + inspector: InspectorStartContract; + overlays: CoreStart['overlays']; + uiSettings: IUiSettingsClient; + embeddable: EmbeddableStart; + uiActions: UiActionsStart; + http: CoreStart['http']; } interface IndexSignature { @@ -81,42 +96,45 @@ export interface InheritedChildInput extends IndexSignature { searchSessionId?: string; } -export interface DashboardContainerOptions { - application: CoreStart['application']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - embeddable: EmbeddableStart; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; - ExitFullScreenButton: React.ComponentType; - uiActions: UiActionsStart; -} +export type DashboardReactContextValue = KibanaReactContextValue; +export type DashboardReactContext = KibanaReactContext; -export type DashboardReactContextValue = KibanaReactContextValue; -export type DashboardReactContext = KibanaReactContext; +const defaultCapabilities = { + show: false, + createNew: false, + saveQuery: false, + createShortUrl: false, + hideWriteControls: true, + mapsCapabilities: { save: false }, + visualizeCapabilities: { save: false }, +}; export class DashboardContainer extends Container { public readonly type = DASHBOARD_CONTAINER_TYPE; - public renderEmpty?: undefined | (() => React.ReactNode); - private embeddablePanel: EmbeddableStart['EmbeddablePanel']; + public switchViewMode?: (newViewMode: ViewMode) => void; + + public getPanelCount = () => { + return Object.keys(this.getInput().panels).length; + }; constructor( initialInput: DashboardContainerInput, - private readonly options: DashboardContainerOptions, + private readonly services: DashboardContainerServices, stateTransfer?: EmbeddableStateTransfer, parent?: Container ) { super( { + dashboardCapabilities: defaultCapabilities, ...initialInput, }, { embeddableLoaded: {} }, - options.embeddable.getEmbeddableFactory, + services.embeddable.getEmbeddableFactory, parent ); - this.embeddablePanel = options.embeddable.getEmbeddablePanel(stateTransfer); + this.embeddablePanel = services.embeddable.getEmbeddablePanel(stateTransfer); } protected createNewPanelState< @@ -239,11 +257,11 @@ export class DashboardContainer extends Container - + , diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx index 77b836ee54f5e..ebae3ff2fc7cc 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { DashboardContainerInput } from './dashboard_container'; import { DashboardContainerFactory } from './dashboard_container_factory'; -import { EmbeddableRenderer } from '../../../../embeddable/public'; +import { EmbeddableRenderer } from '../../services/embeddable'; interface Props { input: DashboardContainerInput; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 4107a00ba80ce..98b4947066c00 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -18,31 +18,21 @@ */ import { i18n } from '@kbn/i18n'; -import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { CoreStart, ScopedHistory } from 'src/core/public'; -import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; -import { EmbeddableFactory, EmbeddableStart } from '../../../../embeddable/public'; +import { ScopedHistory } from 'src/core/public'; import { + Container, + ErrorEmbeddable, ContainerOutput, + EmbeddableFactory, EmbeddableFactoryDefinition, - ErrorEmbeddable, - Container, -} from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerInput } from './dashboard_container'; +} from '../../services/embeddable'; +import { + DashboardContainer, + DashboardContainerInput, + DashboardContainerServices, +} from './dashboard_container'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; -interface StartServices { - capabilities: CoreStart['application']['capabilities']; - application: CoreStart['application']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - embeddable: EmbeddableStart; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; - ExitFullScreenButton: React.ComponentType; - uiActions: UiActionsStart; -} - export type DashboardContainerFactory = EmbeddableFactory< DashboardContainerInput, ContainerOutput, @@ -55,13 +45,13 @@ export class DashboardContainerFactoryDefinition public readonly type = DASHBOARD_CONTAINER_TYPE; constructor( - private readonly getStartServices: () => Promise, + private readonly getStartServices: () => Promise, private getHistory: () => ScopedHistory ) {} public isEditable = async () => { - const { capabilities } = await this.getStartServices(); - return !!capabilities.createNew && !!capabilities.showWriteControls; + // Currently unused for dashboards + return false; }; public readonly getDisplayName = () => { diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap similarity index 99% rename from src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap rename to src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 68d8a6a42eb5d..1f86a8eb7f28b 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -620,7 +620,7 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` >
{ const setupMock = coreMock.createSetup(); diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx similarity index 82% rename from src/plugins/dashboard/public/application/dashboard_empty_screen.tsx rename to src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx index 955d5244ce190..6529cae6f50cb 100644 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx @@ -29,7 +29,7 @@ import { EuiButton, } from '@elastic/eui'; import { IUiSettingsClient, HttpStart } from 'kibana/public'; -import * as constants from './dashboard_empty_screen_constants'; +import { emptyScreenStrings } from '../../../dashboard_strings'; export interface DashboardEmptyScreenProps { showLinkToVisualize: boolean; @@ -52,6 +52,7 @@ export function DashboardEmptyScreen({ const emptyStateGraphicURL = IS_DARK_THEME ? '/plugins/home/assets/welcome_graphic_dark_2x.png' : '/plugins/home/assets/welcome_graphic_light_2x.png'; + const linkToVisualizeParagraph = (

- {constants.createNewVisualizationButton} + {emptyScreenStrings.getCreateNewVisualizationButton()}

); @@ -88,16 +89,16 @@ export function DashboardEmptyScreen({ ); }; const enterEditModeParagraph = paragraph( - constants.howToStartWorkingOnNewDashboardDescription1, - constants.howToStartWorkingOnNewDashboardDescription2, - constants.howToStartWorkingOnNewDashboardEditLinkText, - constants.howToStartWorkingOnNewDashboardEditLinkAriaLabel + emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription1(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription2(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkText(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkAriaLabel() ); const enterViewModeParagraph = paragraph( null, - constants.addNewVisualizationDescription, - constants.addExistingVisualizationLinkText, - constants.addExistingVisualizationLinkAriaLabel + emptyScreenStrings.getAddNewVisualizationDescription(), + emptyScreenStrings.getAddExistingVisualizationLinkText(), + emptyScreenStrings.getAddExistingVisualizationLinkAriaLabel() ); const page = (mainText: string, showAdditionalParagraph?: boolean, additionalText?: string) => { return ( @@ -130,13 +131,13 @@ export function DashboardEmptyScreen({ ); }; const readonlyMode = page( - constants.emptyDashboardTitle, + emptyScreenStrings.getEmptyDashboardTitle(), false, - constants.emptyDashboardAdditionalPrivilege + emptyScreenStrings.getEmptyDashboardAdditionalPrivilege() ); - const viewMode = page(constants.fillDashboardTitle, true); + const viewMode = page(emptyScreenStrings.getFillDashboardTitle(), true); const editMode = ( -
+
{enterViewModeParagraph} {linkToVisualizeParagraph} diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx index 5c4b976b15225..fb29ef7b3c036 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx @@ -24,14 +24,15 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { skip } from 'rxjs/operators'; import { DashboardGrid, DashboardGridProps } from './dashboard_grid'; -import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container'; +import { DashboardContainer, DashboardContainerServices } from '../dashboard_container'; import { getSampleDashboardInput } from '../../test_helpers'; +import { KibanaContextProvider } from '../../../services/kibana_react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, -} from '../../../embeddable_plugin_test_samples'; -import { KibanaContextProvider } from '../../../../../kibana_react/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../../services/embeddable_test_samples'; +import { coreMock, uiSettingsServiceMock } from '../../../../../../core/public/mocks'; let dashboardContainer: DashboardContainer | undefined; @@ -58,7 +59,7 @@ function prepare(props?: Partial) { }, }, }); - const options: DashboardContainerOptions = { + const options: DashboardContainerServices = { application: {} as any, embeddable: { getTriggerCompatibleActions: (() => []) as any, @@ -76,6 +77,8 @@ function prepare(props?: Partial) { uiActions: { getTriggerCompatibleActions: (() => []) as any, } as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; dashboardContainer = new DashboardContainer(initialInput, options); const defaultTestProps: DashboardGridProps = { diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index 03c92d91a80cc..c2e8661e2ab12 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -30,10 +30,10 @@ import React from 'react'; import { Subscription } from 'rxjs'; import ReactGridLayout, { Layout } from 'react-grid-layout'; import { GridData } from '../../../../common'; -import { ViewMode, EmbeddableChildPanel, EmbeddableStart } from '../../../embeddable_plugin'; +import { ViewMode, EmbeddableChildPanel, EmbeddableStart } from '../../../services/embeddable'; import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; -import { withKibana } from '../../../../../kibana_react/public'; +import { withKibana } from '../../../services/kibana_react'; import { DashboardContainerInput } from '../dashboard_container'; import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container'; diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts index 7c11ac8a5031b..ef7de640328ab 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts @@ -17,11 +17,11 @@ * under the License. */ +import { EmbeddableInput } from '../../../services/embeddable'; +import { CONTACT_CARD_EMBEDDABLE } from '../../../../../embeddable/public/lib/test_samples'; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; import { createPanelState } from './create_panel_state'; -import { EmbeddableInput } from '../../../embeddable_plugin'; -import { CONTACT_CARD_EMBEDDABLE } from '../../../embeddable_plugin_test_samples'; interface TestInput extends EmbeddableInput { test: string; diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts index a6928c0608bd2..bcae9abce4bf6 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PanelState, EmbeddableInput } from '../../../embeddable_plugin'; +import { PanelState, EmbeddableInput } from '../../../services/embeddable'; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; import { diff --git a/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts index 5ecd57d670ae8..392172debdc3a 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import { PanelNotFoundError } from '../../../embeddable_plugin'; +import { PanelNotFoundError } from '../../../services/embeddable'; import { GridData } from '../../../../common'; import { DashboardPanelState, DASHBOARD_GRID_COLUMN_COUNT } from '..'; diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx index 1a5c3386bdeda..c1be365d79a13 100644 --- a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx +++ b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx @@ -21,7 +21,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { EuiLoadingChart } from '@elastic/eui'; import classNames from 'classnames'; -import { Embeddable, EmbeddableInput, IContainer } from '../../../embeddable_plugin'; +import { Embeddable, EmbeddableInput, IContainer } from '../../../services/embeddable'; export const PLACEHOLDER_EMBEDDABLE = 'placeholder'; diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts index b3ce2f1e57d5f..ece0e4e49c81b 100644 --- a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts +++ b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts @@ -23,7 +23,7 @@ import { EmbeddableFactoryDefinition, EmbeddableInput, IContainer, -} from '../../../embeddable_plugin'; +} from '../../../services/embeddable'; import { PlaceholderEmbeddable, PLACEHOLDER_EMBEDDABLE } from './placeholder_embeddable'; export class PlaceholderEmbeddableFactory implements EmbeddableFactoryDefinition { diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 94d0f8890c494..e5a1852fa61a5 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -24,15 +24,19 @@ import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { nextTick } from '@kbn/test/jest'; import { DashboardViewport, DashboardViewportProps } from './dashboard_viewport'; -import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container'; +import { DashboardContainer, DashboardContainerServices } from '../dashboard_container'; import { getSampleDashboardInput } from '../../test_helpers'; +import { KibanaContextProvider } from '../../../services/kibana_react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { + applicationServiceMock, + coreMock, + uiSettingsServiceMock, +} from '../../../../../../core/public/mocks'; import { - CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, -} from '../../../embeddable_plugin_test_samples'; -import { KibanaContextProvider } from '../../../../../kibana_react/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { applicationServiceMock } from '../../../../../../core/public/mocks'; + CONTACT_CARD_EMBEDDABLE, +} from '../../../../../embeddable/public/lib/test_samples'; let dashboardContainer: DashboardContainer | undefined; @@ -40,7 +44,7 @@ const ExitFullScreenButton = () =>
function getProps( props?: Partial -): { props: DashboardViewportProps; options: DashboardContainerOptions } { +): { props: DashboardViewportProps; options: DashboardContainerServices } { const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, @@ -48,8 +52,10 @@ function getProps( ); const start = doStart(); - const options: DashboardContainerOptions = { + const options: DashboardContainerServices = { application: applicationServiceMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, embeddable: { getTriggerCompatibleActions: (() => []) as any, getEmbeddablePanel: jest.fn(), @@ -125,9 +131,8 @@ test('renders DashboardViewport with no visualizations', () => { }); test('renders DashboardEmptyScreen', () => { - const renderEmptyScreen = jest.fn(); - const { props, options } = getProps({ renderEmpty: renderEmptyScreen }); - props.container.updateInput({ isEmptyState: true }); + const { props, options } = getProps(); + props.container.updateInput({ panels: {} }); const component = mount( @@ -137,7 +142,6 @@ test('renders DashboardEmptyScreen', () => { ); const dashboardEmptyScreenDiv = component.find('.dshDashboardEmptyScreen'); expect(dashboardEmptyScreenDiv.length).toBe(1); - expect(renderEmptyScreen).toHaveBeenCalled(); component.unmount(); }); @@ -169,10 +173,8 @@ test('renders exit full screen button when in full screen mode', async () => { }); test('renders exit full screen button when in full screen mode and empty screen', async () => { - const renderEmptyScreen = jest.fn(); - renderEmptyScreen.mockReturnValue(React.createElement('div')); - const { props, options } = getProps({ renderEmpty: renderEmptyScreen }); - props.container.updateInput({ isEmptyState: true, isFullScreenMode: true }); + const { props, options } = getProps(); + props.container.updateInput({ panels: {}, isFullScreenMode: true }); const component = mount( @@ -180,7 +182,7 @@ test('renders exit full screen button when in full screen mode and empty screen' ); - expect((component.find('.dshDashboardEmptyScreen').childAt(0).type() as any).name).toBe( + expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).toBe( 'ExitFullScreenButton' ); @@ -188,7 +190,7 @@ test('renders exit full screen button when in full screen mode and empty screen' component.update(); await nextTick(); - expect((component.find('.dshDashboardEmptyScreen').childAt(0).type() as any).name).not.toBe( + expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).not.toBe( 'ExitFullScreenButton' ); diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 15a486f99a37f..558867ba50091 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -19,15 +19,23 @@ import React from 'react'; import { Subscription } from 'rxjs'; -import { PanelState, EmbeddableStart } from '../../../embeddable_plugin'; +import { + PanelState, + EmbeddableStart, + ViewMode, + isErrorEmbeddable, + openAddPanelFlyout, + EmbeddableFactoryNotFoundError, +} from '../../../services/embeddable'; import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container'; import { DashboardGrid } from '../grid'; -import { context } from '../../../../../kibana_react/public'; +import { context } from '../../../services/kibana_react'; +import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; export interface DashboardViewportProps { - container: DashboardContainer; PanelComponent: EmbeddableStart['EmbeddablePanel']; - renderEmpty?: () => React.ReactNode; + switchViewMode?: (newViewMode: ViewMode) => void; + container: DashboardContainer; } interface State { @@ -37,7 +45,6 @@ interface State { description?: string; panels: { [key: string]: PanelState }; isEmbeddedExternally?: boolean; - isEmptyState?: boolean; } export class DashboardViewport extends React.Component { @@ -54,7 +61,6 @@ export class DashboardViewport extends React.Component - {isFullScreenMode && ( - - )} - {renderEmpty && renderEmpty()} -
- ); - } + private createNewEmbeddable = async () => { + const type = 'visualization'; + const factory = this.context.services.embeddable.getEmbeddableFactory(type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + const explicitInput = await factory.getExplicitInput(); + await this.props.container.addNewEmbeddable(type, explicitInput); + }; + + private addFromLibrary = () => { + if (!isErrorEmbeddable(this.props.container)) { + openAddPanelFlyout({ + embeddable: this.props.container, + getAllFactories: this.context.services.embeddable.getEmbeddableFactories, + getFactory: this.context.services.embeddable.getEmbeddableFactory, + notifications: this.context.services.notifications, + overlays: this.context.services.overlays, + SavedObjectFinder: this.context.services.SavedObjectFinder, + }); + } + }; - private renderContainerScreen() { + public render() { const { container, PanelComponent } = this.props; + const isEditMode = container.getInput().viewMode !== ViewMode.VIEW; const { isEmbeddedExternally, isFullScreenMode, @@ -130,30 +141,41 @@ export class DashboardViewport extends React.Component - {isFullScreenMode && ( - - )} - -
- ); - } - - public render() { return ( - {this.state.isEmptyState ? this.renderEmptyScreen() : null} - {this.renderContainerScreen()} +
+ {isFullScreenMode && ( + + )} + {this.props.container.getPanelCount() === 0 && ( +
+ this.props.switchViewMode?.(ViewMode.EDIT) + } + showLinkToVisualize={isEditMode} + onVisualizeClick={this.createNewEmbeddable} + uiSettings={this.context.services.uiSettings} + http={this.context.services.http} + /> +
+ )} + +
); } diff --git a/src/plugins/dashboard/public/application/hooks/index.ts b/src/plugins/dashboard/public/application/hooks/index.ts new file mode 100644 index 0000000000000..33d771e8b11d6 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { useSavedDashboard } from './use_saved_dashboard'; +export { useDashboardContainer } from './use_dashboard_container'; +export { useDashboardBreadcrumbs } from './use_dashboard_breadcrumbs'; +export { useDashboardStateManager } from './use_dashboard_state_manager'; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts new file mode 100644 index 0000000000000..2a9e3e0a5a9b2 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect } from 'react'; +import _ from 'lodash'; +import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; + +import { useKibana } from '../../services/kibana_react'; + +import { DashboardStateManager } from '../dashboard_state_manager'; +import { + getDashboardBreadcrumb, + getDashboardTitle, + leaveConfirmStrings, +} from '../../dashboard_strings'; +import { DashboardAppServices, DashboardRedirect } from '../types'; + +export const useDashboardBreadcrumbs = ( + dashboardStateManager: DashboardStateManager | null, + redirectTo: DashboardRedirect +) => { + const { data, core, chrome } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { setBreadcrumbs } = chrome; + const { timefilter } = data.query.timefilter; + const { openConfirm } = core.overlays; + + // Sync breadcrumbs when Dashboard State Manager changes + useEffect(() => { + if (!dashboardStateManager) { + return; + } + + const { + getConfirmButtonText, + getCancelButtonText, + getLeaveTitle, + getLeaveSubtitle, + } = leaveConfirmStrings; + + setBreadcrumbs([ + { + text: getDashboardBreadcrumb(), + 'data-test-subj': 'dashboardListingBreadcrumb', + onClick: () => { + if (dashboardStateManager.getIsDirty()) { + openConfirm(getLeaveSubtitle(), { + confirmButtonText: getConfirmButtonText(), + cancelButtonText: getCancelButtonText(), + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: getLeaveTitle(), + }).then((isConfirmed) => { + if (isConfirmed) { + redirectTo({ destination: 'listing' }); + } + }); + } else { + redirectTo({ destination: 'listing' }); + } + }, + }, + { + text: getDashboardTitle( + dashboardStateManager.getTitle(), + dashboardStateManager.getViewMode(), + dashboardStateManager.getIsDirty(timefilter), + dashboardStateManager.isNew() + ), + }, + ]); + }, [dashboardStateManager, timefilter, openConfirm, redirectTo, setBreadcrumbs]); +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts new file mode 100644 index 0000000000000..a331871ea7e36 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -0,0 +1,135 @@ +/* + * 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 { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { History } from 'history'; + +import { useKibana } from '../../services/kibana_react'; +import { + ContainerOutput, + EmbeddableFactoryNotFoundError, + EmbeddableInput, + isErrorEmbeddable, + ViewMode, +} from '../../services/embeddable'; + +import { DashboardStateManager } from '../dashboard_state_manager'; +import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashboard_app_functions'; +import { DashboardContainer, DashboardContainerInput } from '../..'; +import { DashboardAppServices } from '../types'; +import { DASHBOARD_CONTAINER_TYPE } from '..'; + +export const useDashboardContainer = ( + dashboardStateManager: DashboardStateManager | null, + history: History, + isEmbeddedExternally: boolean +) => { + const { + dashboardCapabilities, + data, + embeddable, + scopedHistory, + } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { query } = data; + const { session: searchSession } = data.search; + + const [dashboardContainer, setDashboardContainer] = useState(null); + + useEffect(() => { + if (!dashboardStateManager) { + return; + } + + // Load dashboard container + const dashboardFactory = embeddable.getEmbeddableFactory< + DashboardContainerInput, + ContainerOutput, + DashboardContainer + >(DASHBOARD_CONTAINER_TYPE); + + if (!dashboardFactory) { + throw new EmbeddableFactoryNotFoundError( + 'dashboard app requires dashboard embeddable factory' + ); + } + + const searchSessionIdFromURL = getSearchSessionIdFromURL(history); + + if (searchSessionIdFromURL) { + searchSession.restore(searchSessionIdFromURL); + } + + const incomingEmbeddable = embeddable + .getStateTransfer(scopedHistory()) + .getIncomingEmbeddablePackage(); + + (async function createContainer() { + const newContainer = await dashboardFactory.create( + getDashboardContainerInput({ + dashboardCapabilities, + dashboardStateManager, + incomingEmbeddable, + isEmbeddedExternally, + query, + searchSessionId: searchSessionIdFromURL ?? searchSession.start(), + }) + ); + + if (!newContainer || isErrorEmbeddable(newContainer)) { + return; + } + + // inject switch view mode callback for the empty screen to use + newContainer.switchViewMode = (newViewMode: ViewMode) => + dashboardStateManager.switchViewMode(newViewMode); + + // If the incoming embeddable is newly created, or doesn't exist in the current panels list, + // add it with `addNewEmbeddable` + if ( + incomingEmbeddable && + (!incomingEmbeddable?.embeddableId || + (incomingEmbeddable.embeddableId && + !newContainer.getInput().panels[incomingEmbeddable.embeddableId])) + ) { + dashboardStateManager.switchViewMode(ViewMode.EDIT); + newContainer.addNewEmbeddable( + incomingEmbeddable.type, + incomingEmbeddable.input + ); + } + setDashboardContainer(newContainer); + })(); + return () => setDashboardContainer(null); + }, [ + dashboardCapabilities, + dashboardStateManager, + isEmbeddedExternally, + searchSession, + scopedHistory, + embeddable, + history, + query, + ]); + + return dashboardContainer; +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts new file mode 100644 index 0000000000000..7aadfe40ebf08 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts @@ -0,0 +1,200 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { History } from 'history'; +import _ from 'lodash'; +import { map } from 'rxjs/operators'; + +import { createKbnUrlStateStorage, withNotifyOnErrors } from '../../services/kibana_utils'; +import { useKibana } from '../../services/kibana_react'; +import { + connectToQueryState, + esFilters, + QueryState, + syncQueryStateWithUrl, +} from '../../services/data'; +import { SavedObject } from '../../services/saved_objects'; +import type { TagDecoratedSavedObject } from '../../services/saved_objects_tagging_oss'; + +import { DashboardSavedObject } from '../../saved_dashboards'; +import { migrateLegacyQuery } from '../lib/migrate_legacy_query'; +import { createSessionRestorationDataProvider } from '../lib/session_restoration'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { getDashboardTitle } from '../../dashboard_strings'; +import { DashboardAppServices } from '../types'; + +// TS is picky with type guards, we can't just inline `() => false` +function defaultTaggingGuard(_obj: SavedObject): _obj is TagDecoratedSavedObject { + return false; +} + +export const useDashboardStateManager = ( + savedDashboard: DashboardSavedObject | null, + history: History +): DashboardStateManager | null => { + const { + data: dataPlugin, + core, + uiSettings, + usageCollection, + initializerContext, + dashboardCapabilities, + savedObjectsTagging, + } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { query: queryService } = dataPlugin; + const { session: searchSession } = dataPlugin.search; + const { filterManager, queryString: queryStringManager } = queryService; + const { timefilter } = queryService.timefilter; + const { toasts } = core.notifications; + const { hideWriteControls } = dashboardCapabilities; + const { version: kibanaVersion } = initializerContext.env.packageInfo; + + const [dashboardStateManager, setDashboardStateManager] = useState( + null + ); + + const hasTaggingCapabilities = savedObjectsTagging?.ui.hasTagDecoration || defaultTaggingGuard; + + useEffect(() => { + if (!savedDashboard) { + return; + } + + const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(toasts), + }); + + const stateManager = new DashboardStateManager({ + hasTaggingCapabilities, + hideWriteControls, + history, + kbnUrlStateStorage, + kibanaVersion, + savedDashboard, + usageCollection, + }); + + // sync initial app filters from state to filterManager + // if there is an existing similar global filter, then leave it as global + filterManager.setAppFilters(_.cloneDeep(stateManager.appState.filters)); + queryStringManager.setQuery(migrateLegacyQuery(stateManager.appState.query)); + + // setup syncing of app filters between appState and filterManager + const stopSyncingAppFilters = connectToQueryState( + queryService, + { + set: ({ filters, query }) => { + stateManager.setFilters(filters || []); + stateManager.setQuery(query || queryStringManager.getDefaultQuery()); + }, + get: () => ({ + filters: stateManager.appState.filters, + query: stateManager.getQuery(), + }), + state$: stateManager.appState$.pipe( + map((appState) => ({ + filters: appState.filters, + query: queryStringManager.formatQuery(appState.query), + })) + ), + }, + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + // Apply initial filters to Dashboard State Manager + stateManager.applyFilters( + stateManager.getQuery() || queryStringManager.getDefaultQuery(), + filterManager.getFilters() + ); + + // The hash check is so we only update the time filter on dashboard open, not during + // normal cross app navigation. + if (stateManager.getIsTimeSavedWithDashboard()) { + const initialGlobalStateInUrl = kbnUrlStateStorage.get('_g'); + if (!initialGlobalStateInUrl?.time) { + stateManager.syncTimefilterWithDashboardTime(timefilter); + } + if (!initialGlobalStateInUrl?.refreshInterval) { + stateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + } + + // starts syncing `_g` portion of url with query services + // it is important to start this syncing after `dashboardStateManager.syncTimefilterWithDashboard(timefilter);` above is run, + // otherwise it will case redundant browser history records + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + queryService, + kbnUrlStateStorage + ); + + // starts syncing `_a` portion of url + stateManager.startStateSyncing(); + + const dashboardTitle = getDashboardTitle( + stateManager.getTitle(), + stateManager.getViewMode(), + stateManager.getIsDirty(timefilter), + stateManager.isNew() + ); + + searchSession.setSearchSessionInfoProvider( + createSessionRestorationDataProvider({ + data: dataPlugin, + getDashboardTitle: () => dashboardTitle, + getDashboardId: () => savedDashboard?.id || '', + getAppState: () => stateManager.getAppState(), + }) + ); + + setDashboardStateManager(stateManager); + + return () => { + stateManager?.destroy(); + setDashboardStateManager(null); + stopSyncingAppFilters(); + stopSyncingQueryServiceStateWithUrl(); + }; + }, [ + dataPlugin, + filterManager, + hasTaggingCapabilities, + hideWriteControls, + history, + kibanaVersion, + queryService, + queryStringManager, + savedDashboard, + searchSession, + timefilter, + toasts, + uiSettings, + usageCollection, + ]); + + return dashboardStateManager; +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts new file mode 100644 index 0000000000000..f0d8b5f5e000d --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts @@ -0,0 +1,83 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { History } from 'history'; +import _ from 'lodash'; + +import { useKibana } from '../../services/kibana_react'; + +import { DashboardConstants } from '../..'; +import { DashboardSavedObject } from '../../saved_dashboards'; +import { getDashboard60Warning } from '../../dashboard_strings'; +import { DashboardAppServices } from '../types'; + +export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => { + const { data, core, chrome, savedDashboards } = useKibana().services; + const [savedDashboard, setSavedDashboard] = useState(null); + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { indexPatterns } = data; + const { recentlyAccessed: recentlyAccessedPaths, docTitle } = chrome; + const { addDanger: showDangerToast, addWarning: showWarningToast } = core.notifications.toasts; + + useEffect(() => { + (async function loadSavedDashboard() { + if (savedDashboardId === 'create') { + history.replace({ + ...history.location, // preserve query, + pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, + }); + + showWarningToast(getDashboard60Warning()); + return; + } + + await indexPatterns.ensureDefaultIndexPattern(); + + try { + const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject; + const { title, getFullPath } = dashboard; + if (savedDashboardId) { + recentlyAccessedPaths.add(getFullPath(), title, savedDashboardId); + } + + docTitle.change(title); + setSavedDashboard(dashboard); + } catch (error) { + // E.g. a corrupt or deleted dashboard + showDangerToast(error.message); + history.push(DashboardConstants.LANDING_PAGE_PATH); + } + })(); + return () => setSavedDashboard(null); + }, [ + docTitle, + history, + indexPatterns, + recentlyAccessedPaths, + savedDashboardId, + savedDashboards, + showDangerToast, + showWarningToast, + ]); + + return savedDashboard; +}; diff --git a/src/plugins/dashboard/public/application/index.scss b/src/plugins/dashboard/public/application/index.scss index 6e158b2ec2e47..d76e022c97ccf 100644 --- a/src/plugins/dashboard/public/application/index.scss +++ b/src/plugins/dashboard/public/application/index.scss @@ -4,9 +4,6 @@ @import './embeddable/panel/index'; @import './embeddable/viewport/index'; -// Temporary hacks -@import './hacks'; - // Prefix all styles with "dsh" to avoid conflicts. // Examples // dshChart @@ -15,4 +12,3 @@ // dshChart__legend-isLoading @import './dashboard_app'; - diff --git a/src/plugins/dashboard/public/application/index.ts b/src/plugins/dashboard/public/application/index.ts index 2558c49648b10..fdc370ad7570f 100644 --- a/src/plugins/dashboard/public/application/index.ts +++ b/src/plugins/dashboard/public/application/index.ts @@ -19,4 +19,3 @@ export * from './embeddable'; export * from './actions'; -export type { RenderDeps } from './application'; diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js deleted file mode 100644 index 3867991d94295..0000000000000 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import { parse } from 'query-string'; - -import dashboardTemplate from './dashboard_app.html'; -import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; -import { createHashHistory } from 'history'; - -import { initDashboardAppDirective } from './dashboard_app'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { - createKbnUrlStateStorage, - redirectWhenMissing, - SavedObjectNotFound, - withNotifyOnErrors, -} from '../../../kibana_utils/public'; -import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; -import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; -import { syncQueryStateWithUrl } from '../../../data/public'; - -export function initDashboardApp(app, deps) { - initDashboardAppDirective(app, deps); - - app.directive('dashboardListing', function (reactDirective) { - return reactDirective(DashboardListing, [ - ['core', { watchDepth: 'reference' }], - ['createItem', { watchDepth: 'reference' }], - ['getViewUrl', { watchDepth: 'reference' }], - ['editItem', { watchDepth: 'reference' }], - ['findItems', { watchDepth: 'reference' }], - ['deleteItems', { watchDepth: 'reference' }], - ['listingLimit', { watchDepth: 'reference' }], - ['hideWriteControls', { watchDepth: 'reference' }], - ['initialFilter', { watchDepth: 'reference' }], - ['initialPageSize', { watchDepth: 'reference' }], - ['taggingApi', { watchDepth: 'reference' }], - ]); - }); - - function createNewDashboardCtrl($scope) { - $scope.visitVisualizeAppLinkText = i18n.translate('dashboard.visitVisualizeAppLinkText', { - defaultMessage: 'visit the Visualize app', - }); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - } - - app.factory('history', () => createHashHistory()); - app.factory('kbnUrlStateStorage', (history) => - createKbnUrlStateStorage({ - history, - useHash: deps.uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(deps.core.notifications.toasts), - }) - ); - - app.config(function ($routeProvider) { - const defaults = { - reloadOnSearch: false, - requireUICapability: 'dashboard.show', - badge: () => { - if (deps.dashboardCapabilities.showWriteControls) { - return undefined; - } - - return { - text: i18n.translate('dashboard.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', - }), - iconType: 'glasses', - }; - }, - }; - - $routeProvider - .when('/', { - redirectTo: DashboardConstants.LANDING_PAGE_PATH, - }) - .when(DashboardConstants.LANDING_PAGE_PATH, { - ...defaults, - template: dashboardListingTemplate, - controller: function ($scope, kbnUrlStateStorage, history) { - deps.core.chrome.docTitle.change( - i18n.translate('dashboard.dashboardPageTitle', { defaultMessage: 'Dashboards' }) - ); - const service = deps.savedDashboards; - const dashboardConfig = deps.dashboardConfig; - - // syncs `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - deps.data.query, - kbnUrlStateStorage - ); - - $scope.listingLimit = deps.savedObjects.settings.getListingLimit(); - $scope.initialPageSize = deps.savedObjects.settings.getPerPage(); - $scope.taggingApi = deps.savedObjectsTagging; - $scope.create = () => { - history.push(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - }; - $scope.find = async (search) => { - let searchTerm = search; - let references = undefined; - - if (deps.savedObjectsTagging) { - const parsed = deps.savedObjectsTagging.ui.parseSearchQuery(search, { - useName: true, - }); - searchTerm = parsed.searchTerm; - references = parsed.tagReferences; - } - - return service.find(searchTerm, { - size: $scope.listingLimit, - hasReference: references, - }); - }; - $scope.editItem = ({ id }) => { - history.push(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); - }; - $scope.getViewUrl = ({ id }) => { - return deps.addBasePath(`#${createDashboardEditUrl(id)}`); - }; - $scope.delete = (dashboards) => { - return service.delete(dashboards.map((d) => d.id)); - }; - $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); - $scope.initialFilter = parse(history.location.search).filter || EMPTY_FILTER; - deps.chrome.setBreadcrumbs([ - { - text: i18n.translate('dashboard.dashboardBreadcrumbsTitle', { - defaultMessage: 'Dashboards', - }), - }, - ]); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - $scope.core = deps.core; - - $scope.$on('$destroy', () => { - stopSyncingQueryServiceStateWithUrl(); - }); - }, - resolve: { - dash: function ($route, history) { - return deps.data.indexPatterns.ensureDefaultIndexPattern(history).then(() => { - const savedObjectsClient = deps.savedObjectsClient; - const title = $route.current.params.title; - if (title) { - return savedObjectsClient - .find({ - search: `"${title}"`, - search_fields: 'title', - type: 'dashboard', - }) - .then((results) => { - // The search isn't an exact match, lets see if we can find a single exact match to use - const matchingDashboards = results.savedObjects.filter( - (dashboard) => - dashboard.attributes.title.toLowerCase() === title.toLowerCase() - ); - if (matchingDashboards.length === 1) { - history.replace(createDashboardEditUrl(matchingDashboards[0].id)); - } else { - history.replace(`${DashboardConstants.LANDING_PAGE_PATH}?filter="${title}"`); - $route.reload(); - } - return new Promise(() => {}); - }); - } - }); - }, - }, - }) - .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - requireUICapability: 'dashboard.createNew', - resolve: { - dash: (history) => - deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get()) - .catch( - redirectWhenMissing({ - history, - navigateToApp: deps.core.application.navigateToApp, - mapping: { - dashboard: DashboardConstants.LANDING_PAGE_PATH, - }, - toastNotifications: deps.core.notifications.toasts, - }) - ), - }, - }) - .when(createDashboardEditUrl(':id'), { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - resolve: { - dash: function ($route, history) { - const id = $route.current.params.id; - - return deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get(id)) - .then((savedDashboard) => { - deps.chrome.recentlyAccessed.add( - savedDashboard.getFullPath(), - savedDashboard.title, - id - ); - return savedDashboard; - }) - .catch((error) => { - // Preserve BWC of v5.3.0 links for new, unsaved dashboards. - // See https://github.com/elastic/kibana/issues/10951 for more context. - if (error instanceof SavedObjectNotFound && id === 'create') { - // Note preserve querystring part is necessary so the state is preserved through the redirect. - history.replace({ - ...history.location, // preserve query, - pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, - }); - - deps.core.notifications.toasts.addWarning( - i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { - defaultMessage: - 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', - }) - ); - return new Promise(() => {}); - } else { - // E.g. a corrupt or deleted dashboard - deps.core.notifications.toasts.addDanger(error.message); - history.push(DashboardConstants.LANDING_PAGE_PATH); - return new Promise(() => {}); - } - }); - }, - }, - }) - .otherwise({ - resolveRedirectTo: function ($rootScope) { - const path = window.location.hash.substr(1); - deps.restorePreviousUrl(); - $rootScope.$applyAsync(() => { - const { navigated } = deps.navigateToLegacyKibanaUrl(path); - if (!navigated) { - deps.navigateToDefaultApp(); - } - }); - // prevent angular from completing the navigation - return new Promise(() => {}); - }, - }); - }); -} diff --git a/src/plugins/dashboard/public/application/lib/filter_utils.ts b/src/plugins/dashboard/public/application/lib/filter_utils.ts index b6b935d6050ae..ada5ec593cb51 100644 --- a/src/plugins/dashboard/public/application/lib/filter_utils.ts +++ b/src/plugins/dashboard/public/application/lib/filter_utils.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import moment, { Moment } from 'moment'; -import { Filter } from '../../../../data/public'; +import { Filter } from '../../services/data'; /** * @typedef {Object} QueryFilter diff --git a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts index 5599aafe688f0..37952b8beda0d 100644 --- a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts +++ b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts @@ -17,13 +17,13 @@ * under the License. */ -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; -import { ViewMode } from '../../embeddable_plugin'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss'; +import { ViewMode } from '../../services/embeddable'; +import { DashboardSavedObject } from '../../saved_dashboards'; import { DashboardAppStateDefaults } from '../../types'; export function getAppStateDefaults( - savedDashboard: SavedObjectDashboard, + savedDashboard: DashboardSavedObject, hideWriteControls: boolean, hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard ): DashboardAppStateDefaults { diff --git a/src/plugins/dashboard/public/application/help_menu/help_menu_util.ts b/src/plugins/dashboard/public/application/lib/help_menu_util.ts similarity index 100% rename from src/plugins/dashboard/public/application/help_menu/help_menu_util.ts rename to src/plugins/dashboard/public/application/lib/help_menu_util.ts diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts index 6741bbbc5d4b1..03825ad7765f8 100644 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ b/src/plugins/dashboard/public/application/lib/index.ts @@ -22,3 +22,5 @@ export { getAppStateDefaults } from './get_app_state_defaults'; export { migrateAppState } from './migrate_app_state'; export { getDashboardIdFromUrl } from './url'; export { createSessionRestorationDataProvider } from './session_restoration'; +export { addHelpMenuToAppChrome } from './help_menu_util'; +export { attemptLoadDashboardByTitle } from './load_dashboard_by_title'; diff --git a/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts b/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts new file mode 100644 index 0000000000000..4adf229baf34b --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DashboardSavedObject } from '../..'; +import { SavedObjectsClientContract } from '../../../../../core/public'; + +export async function attemptLoadDashboardByTitle( + title: string, + savedObjectsClient: SavedObjectsClientContract +): Promise<{ id: string } | undefined> { + const results = await savedObjectsClient.find({ + search: `"${title}"`, + searchFields: ['title'], + type: 'dashboard', + }); + // The search isn't an exact match, lets see if we can find a single exact match to use + const matchingDashboards = results.savedObjects.filter( + (dashboard) => dashboard.attributes.title.toLowerCase() === title.toLowerCase() + ); + if (matchingDashboards.length === 1) { + return { id: matchingDashboards[0].id }; + } +} diff --git a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts index eaa774e272b2b..2dfb8608a547a 100644 --- a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts +++ b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts @@ -21,7 +21,7 @@ import semverSatisfies from 'semver/functions/satisfies'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { UsageCollectionSetup } from '../../services/usage_collection'; import { DashboardAppState, SavedDashboardPanel } from '../../types'; import { migratePanelsTo730, diff --git a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts b/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts index 8d9b50d5a66b2..92c7c4fb55b89 100644 --- a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts +++ b/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts @@ -18,7 +18,7 @@ */ import { has } from 'lodash'; -import { Query } from 'src/plugins/data/public'; +import { Query } from '../../services/data'; /** * Creates a standardized query object from old queries that were either strings or pure ES query DSL diff --git a/src/plugins/dashboard/public/application/lib/save_dashboard.ts b/src/plugins/dashboard/public/application/lib/save_dashboard.ts index 9560b3d90892c..85742cba888df 100644 --- a/src/plugins/dashboard/public/application/lib/save_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/save_dashboard.ts @@ -17,8 +17,8 @@ * under the License. */ -import { TimefilterContract } from 'src/plugins/data/public'; -import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; +import { TimefilterContract } from '../../services/data'; +import { SavedObjectSaveOpts } from '../../services/saved_objects'; import { updateSavedDashboard } from './update_saved_dashboard'; import { DashboardStateManager } from '../dashboard_state_manager'; diff --git a/src/plugins/dashboard/public/application/lib/session_restoration.ts b/src/plugins/dashboard/public/application/lib/session_restoration.ts index f8ea8f8dcd76d..5f05fa122e161 100644 --- a/src/plugins/dashboard/public/application/lib/session_restoration.ts +++ b/src/plugins/dashboard/public/application/lib/session_restoration.ts @@ -18,7 +18,7 @@ */ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState } from '../../url_generator'; -import { DataPublicPluginStart } from '../../../../data/public'; +import { DataPublicPluginStart } from '../../services/data'; import { DashboardAppState } from '../../types'; export function createSessionRestorationDataProvider(deps: { diff --git a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts index 9a4fa0822d5af..b8f05962a7338 100644 --- a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts @@ -18,15 +18,14 @@ */ import _ from 'lodash'; -import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; +import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss'; +import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data'; import { FilterUtils } from './filter_utils'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import { DashboardSavedObject } from '../../saved_dashboards'; import { DashboardAppState } from '../../types'; -import { esFilters } from '../../../../data/public'; export function updateSavedDashboard( - savedDashboard: SavedObjectDashboard, + savedDashboard: DashboardSavedObject, appState: DashboardAppState, timeFilter: TimefilterContract, hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard, diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap similarity index 70% rename from src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap rename to src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index e817e898cca67..fad7d8ddaabfe 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -1,68 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`after fetch hideWriteControls 1`] = ` - - - - - - } - /> -
- } - rowHeader="title" - searchFilters={Array []} - tableCaption="Dashboards" - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, - Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], - } +exports[`after fetch When given a title that matches multiple dashboards, filter on the title 1`] = ` + - -`; - -exports[`after fetch initialFilter 1`] = ` - + } + redirectTo={[MockFunction]} + title="search by title" +> - + > + +
+ +
+ +
+
+ +
`; -exports[`after fetch renders call to action when no dashboards exist 1`] = ` - +exports[`after fetch hideWriteControls 1`] = ` + + + + + + } + /> +
+ } + rowHeader="title" + searchFilters={Array []} + tableCaption="Dashboards" + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "field": "description", + "name": "Description", + "render": [Function], + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + > + +
+ +
+ +
+
+ +
+`; + +exports[`after fetch initialFilter 1`] = ` + - + > + +
+ +
+ +
+
+
+
`; -exports[`after fetch renders table rows 1`] = ` - +exports[`after fetch renders all table rows 1`] = ` + - + > + +
+ +
+ +
+
+ + `; -exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` - +exports[`after fetch renders call to action when no dashboards exist 1`] = ` + - + > + +
+ +
+ +
+
+ + `; -exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` - +exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` + - + > + +
+ +
+ +
+
+ + `; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.js deleted file mode 100644 index 1af89f4bcb71f..0000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.js +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; - -import { TableListView } from '../../../../kibana_react/public'; - -export const EMPTY_FILTER = ''; - -// saved object client does not support sorting by title because title is only mapped as analyzed -// the legacy implementation got around this by pulling `listingLimit` items and doing client side sorting -// and not supporting server-side paging. -// This component does not try to tackle these problems (yet) and is just feature matching the legacy component -// TODO support server side sorting/paging once title and description are sortable on the server. -export class DashboardListing extends React.Component { - constructor(props) { - super(props); - } - - render() { - return ( - - - - ); - } - - getNoItemsMessage() { - if (this.props.hideWriteControls) { - return ( -
- - - - } - /> -
- ); - } - - return ( -
- - - - } - body={ - -

- -

-

- - this.props.core.application.navigateToApp('home', { - path: '#/tutorial_directory/sampleData', - }) - } - > - - - ), - }} - /> -

-
- } - actions={ - - - - } - /> -
- ); - } - - getTableColumns() { - const { taggingApi } = this.props; - - const tableColumns = [ - { - field: 'title', - name: i18n.translate('dashboard.listing.table.titleColumnName', { - defaultMessage: 'Title', - }), - sortable: true, - render: (field, record) => ( - - {field} - - ), - }, - { - field: 'description', - name: i18n.translate('dashboard.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - dataType: 'string', - sortable: true, - }, - ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), - ]; - return tableColumns; - } -} - -DashboardListing.propTypes = { - createItem: PropTypes.func.isRequired, - findItems: PropTypes.func.isRequired, - deleteItems: PropTypes.func.isRequired, - editItem: PropTypes.func.isRequired, - getViewUrl: PropTypes.func.isRequired, - listingLimit: PropTypes.number.isRequired, - hideWriteControls: PropTypes.bool.isRequired, - initialFilter: PropTypes.string, - initialPageSize: PropTypes.number.isRequired, - taggingApi: PropTypes.object, -}; - -DashboardListing.defaultProps = { - initialFilter: EMPTY_FILTER, -}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js deleted file mode 100644 index cc2c0a2e828ca..0000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock( - 'lodash', - () => ({ - ...jest.requireActual('lodash'), - // mock debounce to fire immediately with no internal timer - debounce: (func) => { - function debounced(...args) { - return func.apply(this, args); - } - return debounced; - }, - }), - { virtual: true } -); - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { DashboardListing } from './dashboard_listing'; - -const find = (num) => { - const hits = []; - for (let i = 0; i < num; i++) { - hits.push({ - id: `dashboard${i}`, - title: `dashboard${i} title`, - description: `dashboard${i} desc`, - }); - } - return Promise.resolve({ - total: num, - hits: hits, - }); -}; - -test('renders empty page in before initial fetch to avoid flickering', () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - hideWriteControls={false} - initialPageSize={10} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - expect(component).toMatchSnapshot(); -}); - -describe('after fetch', () => { - test('initialFilter', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - hideWriteControls={false} - initialPageSize={10} - initialFilter="my dashboard" - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders table rows', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders call to action when no dashboards exist', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('hideWriteControls', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={true} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders warning when listingLimit is exceeded', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx new file mode 100644 index 0000000000000..3aee05554b0d9 --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -0,0 +1,219 @@ +/* + * 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 { mount } from 'enzyme'; +import { + IUiSettingsClient, + PluginInitializerContext, + ScopedHistory, + SimpleSavedObject, +} from '../../../../../core/public'; + +import { SavedObjectLoader, SavedObjectLoaderFindOptions } from '../../services/saved_objects'; +import { IndexPatternsContract, SavedQueryService } from '../../services/data'; +import { NavigationPublicPluginStart } from '../../services/navigation'; +import { KibanaContextProvider } from '../../services/kibana_react'; +import { createKbnUrlStateStorage } from '../../services/kibana_utils'; + +import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; +import { DashboardListing, DashboardListingProps } from './dashboard_listing'; +import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; +import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; +import { I18nProvider } from '@kbn/i18n/react'; +import React from 'react'; + +function makeDefaultServices(): DashboardAppServices { + const core = coreMock.createStart(); + const savedDashboards = {} as SavedObjectLoader; + savedDashboards.find = (search: string, sizeOrOptions: number | SavedObjectLoaderFindOptions) => { + const size = typeof sizeOrOptions === 'number' ? sizeOrOptions : sizeOrOptions.size ?? 10; + const hits = []; + for (let i = 0; i < size; i++) { + hits.push({ + id: `dashboard${i}`, + title: `dashboard${i} - ${search} - title`, + description: `dashboard${i} desc`, + }); + } + return Promise.resolve({ + total: size, + hits, + }); + }; + return { + savedObjects: savedObjectsPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createInstance().doStart(), + dashboardCapabilities: {} as DashboardCapabilities, + initializerContext: {} as PluginInitializerContext, + chrome: chromeServiceMock.createStartContract(), + navigation: {} as NavigationPublicPluginStart, + savedObjectsClient: core.savedObjects.client, + data: dataPluginMock.createStartContract(), + indexPatterns: {} as IndexPatternsContract, + scopedHistory: () => ({} as ScopedHistory), + savedQueryService: {} as SavedQueryService, + setHeaderActionMenu: (mountPoint) => {}, + uiSettings: {} as IUiSettingsClient, + restorePreviousUrl: () => {}, + onAppLeave: (handler) => {}, + savedDashboards, + core, + }; +} + +function makeDefaultProps(): DashboardListingProps { + return { + redirectTo: jest.fn(), + kbnUrlStateStorage: createKbnUrlStateStorage(), + }; +} + +function mountWith({ + props: incomingProps, + services: incomingServices, +}: { + props?: DashboardListingProps; + services?: DashboardAppServices; +}) { + const services = incomingServices ?? makeDefaultServices(); + const props = incomingProps ?? makeDefaultProps(); + const wrappingComponent: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + return ( + + {children} + + ); + }; + const component = mount(, { wrappingComponent }); + return { component, props, services }; +} + +describe('after fetch', () => { + test('renders all table rows', async () => { + const { component } = mountWith({}); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('renders call to action when no dashboards exist', async () => { + const services = makeDefaultServices(); + services.savedDashboards.find = () => { + return Promise.resolve({ + total: 0, + hits: [], + }); + }; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('initialFilter', async () => { + const props = makeDefaultProps(); + props.initialFilter = 'testFilter'; + const { component } = mountWith({ props }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('When given a title that matches multiple dashboards, filter on the title', async () => { + const title = 'search by title'; + const props = makeDefaultProps(); + props.title = title; + const services = makeDefaultServices(); + services.savedObjectsClient.find = () => { + return Promise.resolve({ + perPage: 10, + total: 2, + page: 0, + savedObjects: [ + { attributes: { title: `${title}_number1` }, id: 'hello there' } as SimpleSavedObject, + { attributes: { title: `${title}_number2` }, id: 'goodbye' } as SimpleSavedObject, + ], + }); + }; + const { component } = mountWith({ props, services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + expect(props.redirectTo).not.toHaveBeenCalled(); + }); + + test('When given a title that matches one dashboard, redirect to dashboard', async () => { + const title = 'search by title'; + const props = makeDefaultProps(); + props.title = title; + const services = makeDefaultServices(); + services.savedObjectsClient.find = () => { + return Promise.resolve({ + perPage: 10, + total: 1, + page: 0, + savedObjects: [{ attributes: { title }, id: 'you_found_me' } as SimpleSavedObject], + }); + }; + const { component } = mountWith({ props, services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(props.redirectTo).toHaveBeenCalledWith({ + destination: 'dashboard', + id: 'you_found_me', + useReplace: true, + }); + }); + + test('hideWriteControls', async () => { + const services = makeDefaultServices(); + services.dashboardCapabilities.hideWriteControls = true; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('renders warning when listingLimit is exceeded', async () => { + const services = makeDefaultServices(); + services.savedObjects.settings.getListingLimit = () => 1; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx new file mode 100644 index 0000000000000..40c033322799f --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -0,0 +1,288 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import React, { Fragment, useCallback, useEffect, useMemo } from 'react'; + +import { attemptLoadDashboardByTitle } from '../lib'; +import { DashboardAppServices, DashboardRedirect } from '../types'; +import { getDashboardBreadcrumb, dashboardListingTable } from '../../dashboard_strings'; +import { ApplicationStart, SavedObjectsFindOptionsReference } from '../../../../../core/public'; + +import { syncQueryStateWithUrl } from '../../services/data'; +import { IKbnUrlStateStorage } from '../../services/kibana_utils'; +import { TableListView, useKibana } from '../../services/kibana_react'; +import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss'; + +export interface DashboardListingProps { + kbnUrlStateStorage: IKbnUrlStateStorage; + redirectTo: DashboardRedirect; + initialFilter?: string; + title?: string; +} + +export const DashboardListing = ({ + title, + redirectTo, + initialFilter, + kbnUrlStateStorage, +}: DashboardListingProps) => { + const { + services: { + core, + data, + savedObjects, + savedDashboards, + savedObjectsClient, + savedObjectsTagging, + dashboardCapabilities, + chrome: { setBreadcrumbs }, + }, + } = useKibana(); + + // Set breadcrumbs useEffect + useEffect(() => { + setBreadcrumbs([ + { + text: getDashboardBreadcrumb(), + }, + ]); + }, [setBreadcrumbs]); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + data.query, + kbnUrlStateStorage + ); + if (title) { + attemptLoadDashboardByTitle(title, savedObjectsClient).then((result) => { + if (!result) return; + redirectTo({ + destination: 'dashboard', + id: result.id, + useReplace: true, + }); + }); + } + + return () => { + stopSyncingQueryServiceStateWithUrl(); + }; + }, [title, savedObjectsClient, redirectTo, data.query, kbnUrlStateStorage]); + + const hideWriteControls = dashboardCapabilities.hideWriteControls; + const listingLimit = savedObjects.settings.getListingLimit(); + const defaultFilter = title ? `"${title}"` : ''; + + const tableColumns = useMemo( + () => + getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging), + [savedObjectsTagging, redirectTo] + ); + + const noItemsFragment = useMemo( + () => + getNoItemsMessage(hideWriteControls, core.application, () => + redirectTo({ destination: 'dashboard' }) + ), + [redirectTo, core.application, hideWriteControls] + ); + + const fetchItems = useCallback( + (filter: string) => { + let searchTerm = filter; + let references: SavedObjectsFindOptionsReference[] | undefined; + + if (savedObjectsTagging) { + const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, { + useName: true, + }); + searchTerm = parsed.searchTerm; + references = parsed.tagReferences; + } + + return savedDashboards.find(searchTerm, { + size: listingLimit, + hasReference: references, + }); + }, + [listingLimit, savedDashboards, savedObjectsTagging] + ); + + const deleteItems = useCallback( + (dashboards: Array<{ id: string }>) => savedDashboards.delete(dashboards.map((d) => d.id)), + [savedDashboards] + ); + + const editItem = useCallback( + ({ id }: { id: string | undefined }) => redirectTo({ destination: 'dashboard', id }), + [redirectTo] + ); + + const searchFilters = useMemo(() => { + return savedObjectsTagging + ? [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })] + : []; + }, [savedObjectsTagging]); + + const { + getEntityName, + getTableCaption, + getTableListTitle, + getEntityNamePlural, + } = dashboardListingTable; + return ( + redirectTo({ destination: 'dashboard' })} + deleteItems={hideWriteControls ? undefined : deleteItems} + initialPageSize={savedObjects.settings.getPerPage()} + editItem={hideWriteControls ? undefined : editItem} + initialFilter={initialFilter ?? defaultFilter} + toastNotifications={core.notifications.toasts} + headingId="dashboardListingHeading" + findItems={fetchItems} + rowHeader="title" + entityNamePlural={getEntityNamePlural()} + tableListTitle={getTableListTitle()} + tableCaption={getTableCaption()} + entityName={getEntityName()} + {...{ + noItemsFragment, + searchFilters, + listingLimit, + tableColumns, + }} + /> + ); +}; + +const getTableColumns = ( + redirectTo: (id?: string) => void, + savedObjectsTagging?: SavedObjectsTaggingApi +) => { + return [ + { + field: 'title', + name: dashboardListingTable.getTitleColumnName(), + sortable: true, + render: (field: string, record: { id: string; title: string }) => ( + redirectTo(record.id)} + data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`} + > + {field} + + ), + }, + { + field: 'description', + name: dashboardListingTable.getDescriptionColumnName(), + render: (field: string, record: { description: string }) => {record.description}, + sortable: true, + }, + ...(savedObjectsTagging ? [savedObjectsTagging.ui.getTableColumnDefinition()] : []), + ]; +}; + +const getNoItemsMessage = ( + hideWriteControls: boolean, + application: ApplicationStart, + createItem: () => void +) => { + if (hideWriteControls) { + return ( +
+ + + + } + /> +
+ ); + } + + return ( +
+ + + + } + body={ + +

+ +

+

+ + application.navigateToApp('home', { + path: '#/tutorial_directory/sampleData', + }) + } + > + + + ), + }} + /> +

+
+ } + actions={ + + + + } + /> +
+ ); +}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html deleted file mode 100644 index dd0a40f71beb8..0000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/src/plugins/dashboard/public/ui_actions_plugin.ts b/src/plugins/dashboard/public/application/listing/index.ts similarity index 93% rename from src/plugins/dashboard/public/ui_actions_plugin.ts rename to src/plugins/dashboard/public/application/listing/index.ts index c8778025e7713..ab5ef3441112d 100644 --- a/src/plugins/dashboard/public/ui_actions_plugin.ts +++ b/src/plugins/dashboard/public/application/listing/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/ui_actions/public'; +export { DashboardListing } from './dashboard_listing'; diff --git a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts b/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts index ca5b146f9a370..26db5c1ace984 100644 --- a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts +++ b/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ViewMode, EmbeddableInput } from '../../embeddable_plugin'; +import { ViewMode, EmbeddableInput } from '../../services/embeddable'; import { DashboardContainerInput, DashboardPanelState } from '../embeddable'; export function getSampleDashboardInput( diff --git a/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts b/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts index ee59c68cce451..ea6792e466127 100644 --- a/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts +++ b/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts @@ -18,11 +18,11 @@ */ import { dataPluginMock } from '../../../../data/public/mocks'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import { DashboardSavedObject } from '../../saved_dashboards'; export function getSavedDashboardMock( - config?: Partial -): SavedObjectDashboard { + config?: Partial +): DashboardSavedObject { const searchSource = dataPluginMock.createStartContract(); return { @@ -43,5 +43,5 @@ export function getSavedDashboardMock( getQuery: () => ({ query: '', language: 'kuery' }), getFilters: () => [], ...config, - } as SavedObjectDashboard; + } as DashboardSavedObject; } diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx deleted file mode 100644 index f8f7226d23454..0000000000000 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { findTestSubject } from '@elastic/eui/lib/test'; -import React from 'react'; -import { mount } from 'enzyme'; -import { nextTick } from '@kbn/test/jest'; -import { I18nProvider } from '@kbn/i18n/react'; -import { ViewMode, CONTEXT_MENU_TRIGGER, EmbeddablePanel } from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerOptions } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput } from '../test_helpers'; -import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddable, - ContactCardEmbeddableOutput, - createEditModeAction, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; -import { inspectorPluginMock } from '../../../../inspector/public/mocks'; -import { KibanaContextProvider } from '../../../../kibana_react/public'; -import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; -import { applicationServiceMock } from '../../../../../core/public/mocks'; - -test('DashboardContainer in edit mode shows edit mode actions', async () => { - const inspector = inspectorPluginMock.createStartContract(); - const { setup, doStart } = embeddablePluginMock.createInstance(); - const uiActionsSetup = uiActionsPluginMock.createSetupContract(); - - const editModeAction = createEditModeAction(); - uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); - setup.registerEmbeddableFactory( - CONTACT_CARD_EMBEDDABLE, - new ContactCardEmbeddableFactory((() => null) as any, {} as any) - ); - - const start = doStart(); - - const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); - const options: DashboardContainerOptions = { - application: applicationServiceMock.createStartContract(), - embeddable: start, - notifications: {} as any, - overlays: {} as any, - inspector: {} as any, - SavedObjectFinder: () => null, - ExitFullScreenButton: () => null, - uiActions: {} as any, - }; - const container = new DashboardContainer(initialInput, options); - - const embeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Bob', - }); - - const component = mount( - - - Promise.resolve([])} - getAllEmbeddableFactories={(() => []) as any} - getEmbeddableFactory={(() => null) as any} - notifications={{} as any} - application={options.application} - overlays={{} as any} - inspector={inspector} - SavedObjectFinder={() => null} - /> - - - ); - - const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); - - expect(button.length).toBe(1); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - - expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); - - const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); - - expect(editAction.length).toBe(0); - - container.updateInput({ viewMode: ViewMode.EDIT }); - await nextTick(); - component.update(); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - await nextTick(); - component.update(); - expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - await nextTick(); - component.update(); - expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1); - - await nextTick(); - component.update(); - - // TODO: Address this. - // const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); - // expect(action.length).toBe(1); -}); diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx new file mode 100644 index 0000000000000..38d46c9ec5a68 --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -0,0 +1,446 @@ +/* + * 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 { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import angular from 'angular'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useKibana } from '../../services/kibana_react'; +import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data'; +import { + EmbeddableFactoryNotFoundError, + isErrorEmbeddable, + openAddPanelFlyout, + ViewMode, +} from '../../services/embeddable'; +import { + getSavedObjectFinder, + SavedObjectSaveOpts, + SaveResult, + showSaveModal, +} from '../../services/saved_objects'; + +import { NavAction } from '../../types'; +import { DashboardSavedObject } from '../..'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { leaveConfirmStrings } from '../../dashboard_strings'; +import { saveDashboard } from '../lib'; +import { + DashboardAppServices, + DashboardEmbedSettings, + DashboardRedirect, + DashboardSaveOptions, +} from '../types'; +import { getTopNavConfig } from './get_top_nav_config'; +import { DashboardSaveModal } from './save_modal'; +import { showCloneModal } from './show_clone_modal'; +import { showOptionsPopover } from './show_options_popover'; +import { TopNavIds } from './top_nav_ids'; +import { ShowShareModal } from './show_share_modal'; +import { DashboardContainer } from '..'; + +export interface DashboardTopNavState { + chromeIsVisible: boolean; + savedQuery?: SavedQuery; +} + +export interface DashboardTopNavProps { + onQuerySubmit: (_payload: unknown, isUpdate: boolean | undefined) => void; + dashboardStateManager: DashboardStateManager; + dashboardContainer: DashboardContainer; + embedSettings?: DashboardEmbedSettings; + savedDashboard: DashboardSavedObject; + timefilter: TimefilterContract; + indexPatterns: IndexPattern[]; + redirectTo: DashboardRedirect; + lastDashboardId?: string; +} + +export function DashboardTopNav({ + dashboardStateManager, + dashboardContainer, + lastDashboardId, + savedDashboard, + onQuerySubmit, + embedSettings, + indexPatterns, + redirectTo, + timefilter, +}: DashboardTopNavProps) { + const { + core, + data, + share, + chrome, + embeddable, + navigation, + uiSettings, + setHeaderActionMenu, + savedObjectsTagging, + dashboardCapabilities, + } = useKibana().services; + + const [state, setState] = useState({ chromeIsVisible: false }); + + useEffect(() => { + const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { + setState((s) => ({ ...s, chromeIsVisible })); + }); + return () => visibleSubscription.unsubscribe(); + }, [chrome]); + + const addFromLibrary = useCallback(() => { + if (!isErrorEmbeddable(dashboardContainer)) { + openAddPanelFlyout({ + embeddable: dashboardContainer, + getAllFactories: embeddable.getEmbeddableFactories, + getFactory: embeddable.getEmbeddableFactory, + notifications: core.notifications, + overlays: core.overlays, + SavedObjectFinder: getSavedObjectFinder(core.savedObjects, uiSettings), + }); + } + }, [ + embeddable.getEmbeddableFactories, + embeddable.getEmbeddableFactory, + dashboardContainer, + core.notifications, + core.savedObjects, + core.overlays, + uiSettings, + ]); + + const createNew = useCallback(async () => { + const type = 'visualization'; + const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + const explicitInput = await factory.getExplicitInput(); + if (dashboardContainer) { + await dashboardContainer.addNewEmbeddable(type, explicitInput); + } + }, [dashboardContainer, embeddable]); + + const onChangeViewMode = useCallback( + (newMode: ViewMode) => { + const isPageRefresh = newMode === dashboardStateManager.getViewMode(); + const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; + const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); + + if (!willLoseChanges) { + dashboardStateManager.switchViewMode(newMode); + return; + } + + function revertChangesAndExitEditMode() { + dashboardStateManager.resetState(); + // This is only necessary for new dashboards, which will default to Edit mode. + dashboardStateManager.switchViewMode(ViewMode.VIEW); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardStateManager.getIsTimeSavedWithDashboard()) { + dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); + dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + redirectTo({ destination: 'dashboard', id: savedDashboard.id }); + } + + core.overlays + .openConfirm(leaveConfirmStrings.getDiscardSubtitle(), { + confirmButtonText: leaveConfirmStrings.getConfirmButtonText(), + cancelButtonText: leaveConfirmStrings.getCancelButtonText(), + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: leaveConfirmStrings.getDiscardTitle(), + }) + .then((isConfirmed) => { + if (isConfirmed) { + revertChangesAndExitEditMode(); + } + }); + }, + [redirectTo, timefilter, core.overlays, savedDashboard.id, dashboardStateManager] + ); + + /** + * Saves the dashboard. + * + * @param {object} [saveOptions={}] + * @property {boolean} [saveOptions.confirmOverwrite=false] - If true, attempts to create the source so it + * can confirm an overwrite if a document with the id already exists. + * @property {boolean} [saveOptions.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title + * @property {func} [saveOptions.onTitleDuplicate] - function called if duplicate title exists. + * When not provided, confirm modal will be displayed asking user to confirm or cancel save. + * @return {Promise} + * @resolved {String} - The id of the doc + */ + const save = useCallback( + async (saveOptions: SavedObjectSaveOpts) => { + return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions) + .then(function (id) { + if (id) { + core.notifications.toasts.addSuccess({ + title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { + defaultMessage: `Dashboard '{dashTitle}' was saved`, + values: { dashTitle: dashboardStateManager.savedDashboard.title }, + }), + 'data-test-subj': 'saveDashboardSuccess', + }); + + if (id !== lastDashboardId) { + redirectTo({ destination: 'dashboard', id }); + } else { + chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle); + dashboardStateManager.switchViewMode(ViewMode.VIEW); + } + } + return { id }; + }) + .catch((error) => { + core.notifications?.toasts.addDanger({ + title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { + defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, + values: { + dashTitle: dashboardStateManager.savedDashboard.title, + errorMessage: error.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return { error }; + }); + }, + [ + core.notifications.toasts, + dashboardStateManager, + lastDashboardId, + chrome.docTitle, + redirectTo, + timefilter, + ] + ); + + const runSave = useCallback(async () => { + const currentTitle = dashboardStateManager.getTitle(); + const currentDescription = dashboardStateManager.getDescription(); + const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + + const onSave = ({ + newTitle, + newDescription, + newCopyOnSave, + newTimeRestore, + onTitleDuplicate, + isTitleDuplicateConfirmed, + newTags, + }: DashboardSaveOptions): Promise => { + dashboardStateManager.setTitle(newTitle); + dashboardStateManager.setDescription(newDescription); + dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; + dashboardStateManager.setTimeRestore(newTimeRestore); + if (savedObjectsTagging && newTags) { + dashboardStateManager.setTags(newTags); + } + + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + return save(saveOptions).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + dashboardStateManager.setTitle(currentTitle); + dashboardStateManager.setDescription(currentDescription); + dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } + } + return response; + }); + }; + + const dashboardSaveModal = ( + {}} + title={currentTitle} + description={currentDescription} + tags={currentTags} + savedObjectsTagging={savedObjectsTagging} + timeRestore={currentTimeRestore} + showCopyOnSave={lastDashboardId ? true : false} + /> + ); + showSaveModal(dashboardSaveModal, core.i18n.Context); + }, [save, core.i18n.Context, savedObjectsTagging, dashboardStateManager, lastDashboardId]); + + const runClone = useCallback(() => { + const currentTitle = dashboardStateManager.getTitle(); + const onClone = async ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + dashboardStateManager.savedDashboard.copyOnSave = true; + dashboardStateManager.setTitle(newTitle); + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + return save(saveOptions).then((response: { id?: string } | { error: Error }) => { + // If the save wasn't successful, put the original title back. + if ((response as { error: Error }).error) { + dashboardStateManager.setTitle(currentTitle); + } + return response; + }); + }; + + showCloneModal(onClone, currentTitle); + }, [dashboardStateManager, save]); + + const dashboardTopNavActions = useMemo(() => { + const actions = { + [TopNavIds.FULL_SCREEN]: () => { + dashboardStateManager.setFullScreenMode(true); + }, + [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), + [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), + [TopNavIds.SAVE]: runSave, + [TopNavIds.CLONE]: runClone, + [TopNavIds.ADD_EXISTING]: addFromLibrary, + [TopNavIds.VISUALIZE]: createNew, + [TopNavIds.OPTIONS]: (anchorElement) => { + showOptionsPopover({ + anchorElement, + useMargins: dashboardStateManager.getUseMargins(), + onUseMarginsChange: (isChecked: boolean) => { + dashboardStateManager.setUseMargins(isChecked); + }, + hidePanelTitles: dashboardStateManager.getHidePanelTitles(), + onHidePanelTitlesChange: (isChecked: boolean) => { + dashboardStateManager.setHidePanelTitles(isChecked); + }, + }); + }, + } as { [key: string]: NavAction }; + if (share) { + actions[TopNavIds.SHARE] = (anchorElement) => + ShowShareModal({ + share, + anchorElement, + savedDashboard, + dashboardStateManager, + dashboardCapabilities, + }); + } + return actions; + }, [ + dashboardCapabilities, + dashboardStateManager, + onChangeViewMode, + savedDashboard, + addFromLibrary, + createNew, + runClone, + runSave, + share, + ]); + + const getNavBarProps = () => { + const shouldShowNavBarComponent = (forceShow: boolean): boolean => + (forceShow || state.chromeIsVisible) && !dashboardStateManager.getFullScreenMode(); + + const shouldShowFilterBar = (forceHide: boolean): boolean => + !forceHide && + (data.query.filterManager.getFilters().length > 0 || + !dashboardStateManager.getFullScreenMode()); + + const isFullScreenMode = dashboardStateManager.getFullScreenMode(); + const screenTitle = dashboardStateManager.getTitle(); + const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); + const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput)); + const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); + const showQueryBar = showQueryInput || showDatePicker; + const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); + const showSearchBar = showQueryBar || showFilterBar; + + const topNav = getTopNavConfig( + dashboardStateManager.getViewMode(), + dashboardTopNavActions, + dashboardCapabilities.hideWriteControls + ); + + return { + appName: 'dashboard', + config: showTopNavMenu ? topNav : undefined, + className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, + screenTitle, + showTopNavMenu, + showSearchBar, + showQueryBar, + showQueryInput, + showDatePicker, + showFilterBar, + setMenuMountPoint: embedSettings ? undefined : setHeaderActionMenu, + indexPatterns, + showSaveQuery: dashboardCapabilities.saveQuery, + useDefaultBehaviors: true, + onQuerySubmit, + onSavedQueryUpdated: (savedQuery: SavedQuery) => { + const allFilters = data.query.filterManager.getFilters(); + data.query.filterManager.setFilters(allFilters); + dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters); + if (savedQuery.attributes.timefilter) { + timefilter.setTime({ + from: savedQuery.attributes.timefilter.from, + to: savedQuery.attributes.timefilter.to, + }); + if (savedQuery.attributes.timefilter.refreshInterval) { + timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); + } + } + setState((s) => ({ ...s, savedQuery })); + }, + savedQuery: state.savedQuery, + savedQueryId: dashboardStateManager.getSavedQueryId(), + onSavedQueryIdChange: (newId: string | undefined) => + dashboardStateManager.setSavedQueryId(newId), + }; + }; + + const { TopNavMenu } = navigation.ui; + return ; +} diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 5713996ca9f78..c8d65923de2fc 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -18,8 +18,7 @@ */ import { i18n } from '@kbn/i18n'; -import { AppMountParameters } from 'kibana/public'; -import { ViewMode } from '../../embeddable_plugin'; +import { ViewMode } from '../../services/embeddable'; import { TopNavIds } from './top_nav_ids'; import { NavAction } from '../../types'; @@ -32,8 +31,7 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean, - onAppLeave?: AppMountParameters['onAppLeave'] + hideWriteControls: boolean ) { switch (dashboardMode) { case ViewMode.VIEW: @@ -185,9 +183,9 @@ function getCreateNewConfig(action: NavAction) { }; } -/** - * @returns {kbnTopNavConfig} - */ +// /** +// * @returns {kbnTopNavConfig} +// */ function getShareConfig(action: NavAction | undefined) { return { id: 'share', @@ -198,7 +196,7 @@ function getShareConfig(action: NavAction | undefined) { defaultMessage: 'Share Dashboard', }), testId: 'shareTopNavButton', - run: action, + run: action ?? (() => {}), // disable the Share button if no action specified disableButton: !action, }; diff --git a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx index 71c3623805462..4a30944ba8616 100644 --- a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx @@ -21,21 +21,20 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui'; -import type { SavedObjectsTaggingApi } from '../../../../saved_objects_tagging_oss/public'; -import { SavedObjectSaveModal } from '../../../../saved_objects/public'; - -export interface SaveOptions { - newTitle: string; - newDescription: string; - newTags?: string[]; - newCopyOnSave: boolean; - newTimeRestore: boolean; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; -} +import type { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss'; +import { SavedObjectSaveModal } from '../../services/saved_objects'; +import { DashboardSaveOptions } from '../types'; interface Props { - onSave: (options: SaveOptions) => void; + onSave: ({ + newTitle, + newDescription, + newCopyOnSave, + newTags, + newTimeRestore, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: DashboardSaveOptions) => void; onClose: () => void; title: string; description: string; diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx new file mode 100644 index 0000000000000..236789ef82e56 --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EuiCheckboxGroup } from '@elastic/eui'; +import React from 'react'; +import { ReactElement, useState } from 'react'; +import { DashboardSavedObject } from '../..'; +import { setStateToKbnUrl, unhashUrl } from '../../services/kibana_utils'; +import { SharePluginStart } from '../../services/share'; +import { dashboardUrlParams } from '../dashboard_router'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { shareModalStrings } from '../../dashboard_strings'; +import { DashboardCapabilities } from '../types'; + +const showFilterBarId = 'showFilterBar'; + +interface ShowShareModalProps { + share: SharePluginStart; + anchorElement: HTMLElement; + savedDashboard: DashboardSavedObject; + dashboardCapabilities: DashboardCapabilities; + dashboardStateManager: DashboardStateManager; +} + +export function ShowShareModal({ + share, + anchorElement, + savedDashboard, + dashboardCapabilities, + dashboardStateManager, +}: ShowShareModalProps) { + const EmbedUrlParamExtension = ({ + setParamValue, + }: { + setParamValue: (paramUpdate: { [key: string]: boolean }) => void; + }): ReactElement => { + const [urlParamsSelectedMap, seturlParamsSelectedMap] = useState<{ [key: string]: boolean }>({ + showFilterBar: true, + }); + + const checkboxes = [ + { + id: dashboardUrlParams.showTopMenu, + label: shareModalStrings.getTopMenuCheckbox(), + }, + { + id: dashboardUrlParams.showQueryInput, + label: shareModalStrings.getQueryCheckbox(), + }, + { + id: dashboardUrlParams.showTimeFilter, + label: shareModalStrings.getTimeFilterCheckbox(), + }, + { + id: showFilterBarId, + label: shareModalStrings.getFilterBarCheckbox(), + }, + ]; + + const handleChange = (param: string): void => { + const newSelectedMap = { + ...urlParamsSelectedMap, + [param]: !urlParamsSelectedMap[param], + }; + + const urlParamValues = { + [dashboardUrlParams.showTopMenu]: newSelectedMap[dashboardUrlParams.showTopMenu], + [dashboardUrlParams.showQueryInput]: newSelectedMap[dashboardUrlParams.showQueryInput], + [dashboardUrlParams.showTimeFilter]: newSelectedMap[dashboardUrlParams.showTimeFilter], + [dashboardUrlParams.hideFilterBar]: !newSelectedMap[showFilterBarId], + }; + seturlParamsSelectedMap(newSelectedMap); + setParamValue(urlParamValues); + }; + + return ( + + ); + }; + + share.toggleShareContextMenu({ + anchorElement, + allowEmbed: true, + allowShortUrl: !dashboardCapabilities.hideWriteControls || dashboardCapabilities.createShortUrl, + shareableUrl: setStateToKbnUrl( + '_a', + dashboardStateManager.getAppState(), + { useHash: false, storeInHashQuery: true }, + unhashUrl(window.location.href) + ), + objectId: savedDashboard.id, + objectType: 'dashboard', + sharingData: { + title: savedDashboard.title, + }, + isDirty: dashboardStateManager.getIsDirty(), + embedUrlParamExtensions: [ + { + paramName: 'embed', + component: EmbedUrlParamExtension, + }, + ], + }); +} diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts new file mode 100644 index 0000000000000..d1caaa349d80b --- /dev/null +++ b/src/plugins/dashboard/public/application/types.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + AppMountParameters, + CoreStart, + SavedObjectsClientContract, + ScopedHistory, + ChromeStart, + IUiSettingsClient, + PluginInitializerContext, +} from 'kibana/public'; + +import { SharePluginStart } from '../services/share'; +import { EmbeddableStart } from '../services/embeddable'; +import { UsageCollectionSetup } from '../services/usage_collection'; +import { NavigationPublicPluginStart } from '../services/navigation'; +import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss'; +import { DataPublicPluginStart, IndexPatternsContract } from '../services/data'; +import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; + +export type DashboardRedirect = (props: RedirectToProps) => void; +export type RedirectToProps = + | { destination: 'dashboard'; id?: string; useReplace?: boolean } + | { destination: 'listing'; filter?: string; useReplace?: boolean }; + +export interface DashboardEmbedSettings { + forceShowTopNavMenu?: boolean; + forceShowQueryInput?: boolean; + forceShowDatePicker?: boolean; + forceHideFilterBar?: boolean; +} + +export interface DashboardSaveOptions { + newTitle: string; + newTags?: string[]; + newDescription: string; + newCopyOnSave: boolean; + newTimeRestore: boolean; + onTitleDuplicate: () => void; + isTitleDuplicateConfirmed: boolean; +} + +export interface DashboardCapabilities { + visualizeCapabilities: { save: boolean }; + mapsCapabilities: { save: boolean }; + hideWriteControls: boolean; + createShortUrl: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; +} + +export interface DashboardAppServices { + core: CoreStart; + chrome: ChromeStart; + share?: SharePluginStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; + uiSettings: IUiSettingsClient; + restorePreviousUrl: () => void; + savedObjects: SavedObjectsStart; + savedDashboards: SavedObjectLoader; + scopedHistory: () => ScopedHistory; + indexPatterns: IndexPatternsContract; + usageCollection?: UsageCollectionSetup; + navigation: NavigationPublicPluginStart; + dashboardCapabilities: DashboardCapabilities; + initializerContext: PluginInitializerContext; + onAppLeave: AppMountParameters['onAppLeave']; + savedObjectsTagging?: SavedObjectsTaggingApi; + savedObjectsClient: SavedObjectsClientContract; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + savedQueryService: DataPublicPluginStart['query']['savedQueries']; +} diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 12f6b70617907..1498485816adc 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -20,6 +20,7 @@ export const DashboardConstants = { LANDING_PAGE_PATH: '/list', CREATE_NEW_DASHBOARD_URL: '/create', + VIEW_DASHBOARD_URL: '/view', ADD_EMBEDDABLE_ID: 'addEmbeddableId', ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', DASHBOARDS_ID: 'dashboards', @@ -28,5 +29,11 @@ export const DashboardConstants = { }; export function createDashboardEditUrl(id: string) { - return `/view/${id}`; + return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}`; +} + +export function createDashboardListingFilterUrl(filter: string | undefined) { + return filter + ? `${DashboardConstants.LANDING_PAGE_PATH}?filter="${filter}"` + : DashboardConstants.LANDING_PAGE_PATH; } diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts new file mode 100644 index 0000000000000..239846638d3aa --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -0,0 +1,332 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { ViewMode } from './services/embeddable'; + +/** + * @param title {string} the current title of the dashboard + * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. + * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the + * end of the title. + * @returns {string} A title to display to the user based on the above parameters. + */ +export function getDashboardTitle( + title: string, + viewMode: ViewMode, + isDirty: boolean, + isNew: boolean +): string { + const isEditMode = viewMode === ViewMode.EDIT; + let displayTitle: string; + const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', { + defaultMessage: 'New Dashboard', + }); + const dashboardTitle = isNew ? newDashboardTitle : title; + + if (isEditMode && isDirty) { + displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { + defaultMessage: 'Editing {title} (unsaved)', + values: { title: dashboardTitle }, + }); + } else if (isEditMode) { + displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { + defaultMessage: 'Editing {title}', + values: { title: dashboardTitle }, + }); + } else { + displayTitle = dashboardTitle; + } + + return displayTitle; +} + +/* + Plugin +*/ + +export const getDashboardBreadcrumb = () => + i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { + defaultMessage: 'Dashboard', + }); + +export const getDashboardPageTitle = () => + i18n.translate('dashboard.dashboardPageTitle', { + defaultMessage: 'Dashboards', + }); + +export const dashboardFeatureCatalog = { + getTitle: () => + i18n.translate('dashboard.featureCatalogue.dashboardTitle', { + defaultMessage: 'Dashboard', + }), + getSubtitle: () => + i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { + defaultMessage: 'Analyze data in dashboards.', + }), + getDescription: () => + i18n.translate('dashboard.featureCatalogue.dashboardDescription', { + defaultMessage: 'Display and share a collection of visualizations and saved searches.', + }), +}; + +/* + Actions +*/ +export const dashboardAddToLibraryAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.AddToLibrary', { + defaultMessage: 'Add to library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.addToLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} was added to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardClonePanelAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.clonePanel', { + defaultMessage: 'Clone panel', + }), + getClonedTag: () => + i18n.translate('dashboard.panel.title.clonedTag', { + defaultMessage: 'copy', + }), + getSuccessMessage: () => + i18n.translate('dashboard.panel.clonedToast', { + defaultMessage: 'Cloned panel', + }), +}; + +export const dashboardExpandPanelAction = { + getMinimizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.expandedDisplayName', { + defaultMessage: 'Minimize', + }), + getMaximizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName', { + defaultMessage: 'Maximize panel', + }), +}; + +export const dashboardExportCsvAction = { + getDisplayName: () => + i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { + defaultMessage: 'Download as CSV', + }), + getUntitledFilename: () => + i18n.translate('dashboard.actions.downloadOptionsUnsavedFilename', { + defaultMessage: 'unsaved', + }), +}; + +export const dashboardUnlinkFromLibraryAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} is no longer connected to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardLibraryNotification = { + getDisplayName: () => + i18n.translate('dashboard.panel.LibraryNotification', { + defaultMessage: 'Visualize Library Notification', + }), + getTooltip: () => + i18n.translate('dashboard.panel.libraryNotification.toolTip', { + defaultMessage: + 'Editing this panel might affect other dashboards. To change to this panel only, unlink it from the library.', + }), + getPopoverAriaLabel: () => + i18n.translate('dashboard.panel.libraryNotification.ariaLabel', { + defaultMessage: 'View library information and unlink this panel', + }), +}; + +export const dashboardReplacePanelAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.removePanel.replacePanel', { + defaultMessage: 'Replace panel', + }), + getSuccessMessage: (savedObjectName: string) => + i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { + defaultMessage: '{savedObjectName} was added', + values: { + savedObjectName, + }, + }), + getNoMatchingObjectsMessage: () => + i18n.translate('dashboard.addPanel.noMatchingObjectsMessage', { + defaultMessage: 'No matching objects found.', + }), +}; + +/* + Dashboard Editor +*/ +export const shareModalStrings = { + getTopMenuCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.topMenu', { + defaultMessage: 'Top menu', + }), + getQueryCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.query', { + defaultMessage: 'Query', + }), + getTimeFilterCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { + defaultMessage: 'Time filter', + }), + getFilterBarCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.filterBar', { + defaultMessage: 'Filter bar', + }), + getCheckboxLegend: () => + i18n.translate('dashboard.embedUrlParamExtension.include', { + defaultMessage: 'Include', + }), +}; + +export const getDashboard60Warning = () => + i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { + defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', + }); + +export const dashboardReadonlyBadge = { + getText: () => + i18n.translate('dashboard.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + getTooltip: () => + i18n.translate('dashboard.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save dashboards', + }), +}; + +export const leaveConfirmStrings = { + getLeaveTitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', { + defaultMessage: 'Unsaved changes', + }), + getLeaveSubtitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { + defaultMessage: 'Leave Dashboard with unsaved work?', + }), + getDiscardTitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { + defaultMessage: 'Discard changes to dashboard?', + }), + getDiscardSubtitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', { + defaultMessage: `Once you discard your changes, there's no getting them back.`, + }), + getConfirmButtonText: () => + i18n.translate('dashboard.changeViewModeConfirmModal.confirmButtonLabel', { + defaultMessage: 'Discard changes', + }), + getCancelButtonText: () => + i18n.translate('dashboard.changeViewModeConfirmModal.cancelButtonLabel', { + defaultMessage: 'Continue editing', + }), +}; + +/* + Empty Screen +*/ +export const emptyScreenStrings = { + getEmptyDashboardTitle: () => + i18n.translate('dashboard.emptyDashboardTitle', { + defaultMessage: 'This dashboard is empty.', + }), + getEmptyDashboardAdditionalPrivilege: () => + i18n.translate('dashboard.emptyDashboardAdditionalPrivilege', { + defaultMessage: 'You need additional privileges to edit this dashboard.', + }), + getFillDashboardTitle: () => + i18n.translate('dashboard.fillDashboardTitle', { + defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', + }), + getHowToStartWorkingOnNewDashboardDescription1: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription1', { + defaultMessage: 'Click', + }), + getHowToStartWorkingOnNewDashboardDescription2: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription2', { + defaultMessage: 'in the menu bar above to start adding panels.', + }), + getHowToStartWorkingOnNewDashboardEditLinkText: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkText', { + defaultMessage: 'Edit', + }), + getHowToStartWorkingOnNewDashboardEditLinkAriaLabel: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', { + defaultMessage: 'Edit dashboard', + }), + getAddExistingVisualizationLinkText: () => + i18n.translate('dashboard.addExistingVisualizationLinkText', { + defaultMessage: 'Add an existing', + }), + getAddExistingVisualizationLinkAriaLabel: () => + i18n.translate('dashboard.addVisualizationLinkAriaLabel', { + defaultMessage: 'Add an existing visualization', + }), + getAddNewVisualizationDescription: () => + i18n.translate('dashboard.addNewVisualizationText', { + defaultMessage: 'or new object to this dashboard', + }), + getCreateNewVisualizationButton: () => + i18n.translate('dashboard.createNewVisualizationButton', { + defaultMessage: 'Create new', + }), + getCreateNewVisualizationButtonAriaLabel: () => + i18n.translate('dashboard.createNewVisualizationButtonAriaLabel', { + defaultMessage: 'Create new visualization button', + }), +}; + +/* + Dashboard Listing Page +*/ +export const dashboardListingTable = { + getEntityName: () => + i18n.translate('dashboard.listing.table.entityName', { + defaultMessage: 'dashboard', + }), + getEntityNamePlural: () => + i18n.translate('dashboard.listing.table.entityNamePlural', { + defaultMessage: 'dashboards', + }), + getTableListTitle: () => getDashboardPageTitle(), + getTableCaption: () => getDashboardPageTitle(), + getTitleColumnName: () => + i18n.translate('dashboard.listing.table.titleColumnName', { + defaultMessage: 'Title', + }), + getDescriptionColumnName: () => + i18n.translate('dashboard.listing.table.descriptionColumnName', { + defaultMessage: 'Description', + }), +}; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 004b1a901bca9..9a70598e43add 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -25,9 +25,6 @@ export { DashboardContainerInput, DashboardContainerFactoryDefinition, DASHBOARD_CONTAINER_TYPE, - // Types below here can likely be made private when dashboard app moved into this NP plugin. - DEFAULT_PANEL_WIDTH, - DEFAULT_PANEL_HEIGHT, } from './application'; export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; @@ -42,8 +39,7 @@ export { createDashboardUrlGenerator, DashboardUrlGeneratorState, } from './url_generator'; -export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; -export { SavedObjectDashboard } from './saved_dashboards'; +export { DashboardSavedObject } from './saved_dashboards'; export { SavedDashboardPanel } from './types'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 76b1ccc037e89..97e3174fba098 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -20,51 +20,46 @@ import * as React from 'react'; import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; +import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; +import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import { App, - AppMountParameters, - AppUpdater, + Plugin, CoreSetup, CoreStart, - Plugin, + AppUpdater, + ScopedHistory, + AppMountParameters, + DEFAULT_APP_CATEGORIES, PluginInitializerContext, SavedObjectsClientContract, - ScopedHistory, -} from 'src/core/public'; -import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; -import { UsageCollectionSetup } from '../../usage_collection/public'; +} from '../../../core/public'; + +import { createKbnUrlTracker } from './services/kibana_utils'; +import { UsageCollectionSetup } from './services/usage_collection'; +import { UiActionsSetup, UiActionsStart } from './services/ui_actions'; +import { KibanaLegacySetup, KibanaLegacyStart } from './services/kibana_legacy'; +import { FeatureCatalogueCategory, HomePublicPluginSetup } from './services/home'; +import { NavigationPublicPluginStart as NavigationStart } from './services/navigation'; +import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from './services/data'; +import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from './services/share'; +import type { SavedObjectTaggingOssPluginStart } from './services/saved_objects_tagging_oss'; +import { + getSavedObjectFinder, + SavedObjectLoader, + SavedObjectsStart, +} from './services/saved_objects'; import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart, PANEL_NOTIFICATION_TRIGGER, -} from '../../embeddable/public'; -import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from '../../data/public'; -import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from '../../share/public'; -import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; - -import { Start as InspectorStartContract } from '../../inspector/public'; -import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; -import { - getSavedObjectFinder, - SavedObjectLoader, - SavedObjectsStart, -} from '../../saved_objects/public'; +} from './services/embeddable'; import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, -} from '../../kibana_react/public'; -import { createKbnUrlTracker, Storage } from '../../kibana_utils/public'; -import { - initAngularBootstrap, - KibanaLegacySetup, - KibanaLegacyStart, -} from '../../kibana_legacy/public'; -import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../plugins/home/public'; -import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +} from './services/kibana_react'; import { ACTION_CLONE_PANEL, @@ -78,7 +73,6 @@ import { DashboardContainerFactoryDefinition, ExpandPanelAction, ExpandPanelActionContext, - RenderDeps, ReplacePanelAction, ReplacePanelActionContext, ACTION_UNLINK_FROM_LIBRARY, @@ -98,7 +92,6 @@ import { } from './url_generator'; import { createSavedDashboardLoader } from './saved_dashboards'; import { DashboardConstants } from './dashboard_constants'; -import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { @@ -106,6 +99,7 @@ import { ExportContext, ExportCSVAction, } from './application/actions/export_csv_action'; +import { dashboardFeatureCatalog } from './dashboard_strings'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -119,7 +113,7 @@ export interface DashboardFeatureFlagConfig { allowByValueEmbeddables: boolean; } -interface SetupDependencies { +export interface DashboardSetupDependencies { data: DataPublicPluginSetup; embeddable: EmbeddableSetup; home?: HomePublicPluginSetup; @@ -130,7 +124,7 @@ interface SetupDependencies { usageCollection?: UsageCollectionSetup; } -interface StartDependencies { +export interface DashboardStartDependencies { data: DataPublicPluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; @@ -148,10 +142,6 @@ export type DashboardSetup = void; export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; - addEmbeddableToDashboard: (options: { - embeddableId: string; - embeddableType: string; - }) => void | undefined; dashboardUrlGenerator?: DashboardUrlGenerator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; DashboardContainerByValueRenderer: ReturnType; @@ -170,25 +160,30 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardPlugin - implements Plugin { + implements + Plugin { constructor(private initializerContext: PluginInitializerContext) {} private appStateUpdater = new BehaviorSubject(() => ({})); private stopUrlTracking: (() => void) | undefined = undefined; - private getActiveUrl: (() => string) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; private dashboardUrlGenerator?: DashboardUrlGenerator; public setup( - core: CoreSetup, - { share, uiActions, embeddable, home, urlForwarding, data, usageCollection }: SetupDependencies + core: CoreSetup, + { + share, + uiActions, + embeddable, + home, + urlForwarding, + data, + usageCollection, + }: DashboardSetupDependencies ): DashboardSetup { this.dashboardFeatureFlagConfig = this.initializerContext.config.get(); - const expandPanelAction = new ExpandPanelAction(); - uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -230,32 +225,37 @@ export class DashboardPlugin return ; }; return { - capabilities: coreStart.application.capabilities, - application: coreStart.application, + SavedObjectFinder: getSavedObjectFinder(coreStart.savedObjects, coreStart.uiSettings), + hideWriteControls: deps.kibanaLegacy.dashboardConfig.getHideWriteControls(), notifications: coreStart.notifications, + application: coreStart.application, + uiSettings: coreStart.uiSettings, overlays: coreStart.overlays, embeddable: deps.embeddable, + uiActions: deps.uiActions, inspector: deps.inspector, - SavedObjectFinder: getSavedObjectFinder(coreStart.savedObjects, coreStart.uiSettings), + http: coreStart.http, ExitFullScreenButton, - uiActions: deps.uiActions, }; }; - const factory = new DashboardContainerFactoryDefinition( - getStartServices, - () => this.currentHistory! - ); - embeddable.registerEmbeddableFactory(factory.type, factory); - - const placeholderFactory = new PlaceholderEmbeddableFactory(); - embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); + if (share) { + this.dashboardUrlGenerator = share.urlGenerators.registerUrlGenerator( + createDashboardUrlGenerator(async () => { + const [coreStart, , selfStart] = await core.getStartServices(); + return { + appBasePath: coreStart.application.getUrlForApp('dashboards'), + useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'), + savedDashboardLoader: selfStart.getSavedDashboardLoader(), + }; + }) + ); + } const { appMounted, appUnMounted, stop: stopUrlTracker, - getActiveUrl, restorePreviousUrl, } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/dashboards'), @@ -280,7 +280,15 @@ export class DashboardPlugin getHistory: () => this.currentHistory!, }); - this.getActiveUrl = getActiveUrl; + const factory = new DashboardContainerFactoryDefinition( + getStartServices, + () => this.currentHistory! + ); + embeddable.registerEmbeddableFactory(factory.type, factory); + + const placeholderFactory = new PlaceholderEmbeddableFactory(); + embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); + this.stopUrlTracking = () => { stopUrlTracker(); }; @@ -294,63 +302,24 @@ export class DashboardPlugin updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { - const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); this.currentHistory = params.history; + params.element.classList.add('dshAppContainer'); + const { mountApp } = await import('./application/dashboard_router'); appMounted(); - const { - embeddable: embeddableStart, - navigation, - share: shareStart, - data: dataStart, - kibanaLegacy: { dashboardConfig }, - urlForwarding: { navigateToDefaultApp, navigateToLegacyKibanaUrl }, - savedObjects, - savedObjectsTaggingOss, - } = pluginsStart; - - const deps: RenderDeps = { - pluginInitializerContext: this.initializerContext, - core: coreStart, - dashboardConfig, - navigateToDefaultApp, - navigateToLegacyKibanaUrl, - navigation, - share: shareStart, - data: dataStart, - savedObjectsClient: coreStart.savedObjects.client, - savedDashboards: dashboardStart.getSavedDashboardLoader(), - chrome: coreStart.chrome, - addBasePath: coreStart.http.basePath.prepend, - uiSettings: coreStart.uiSettings, - savedQueryService: dataStart.query.savedQueries, - embeddable: embeddableStart, - dashboardCapabilities: coreStart.application.capabilities.dashboard, - embeddableCapabilities: { - visualizeCapabilities: coreStart.application.capabilities.visualize, - mapsCapabilities: coreStart.application.capabilities.maps, - }, - localStorage: new Storage(localStorage), + return mountApp({ + core, + appUnMounted, usageCollection, - scopedHistory: () => this.currentHistory!, - setHeaderActionMenu: params.setHeaderActionMenu, - savedObjects, - savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + onAppLeave: params.onAppLeave, + initializerContext: this.initializerContext, restorePreviousUrl, - }; - // make sure the index pattern list is up to date - await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./application/application'); - params.element.classList.add('dshAppContainer'); - const unmount = renderApp(params.element, params.appBasePath, deps); - return () => { - unmount(); - appUnMounted(); - }; + element: params.element, + scopedHistory: this.currentHistory!, + setHeaderActionMenu: params.setHeaderActionMenu, + }); }, }; - initAngularBootstrap(); - core.application.register(app); urlForwarding.forwardApp( DashboardConstants.DASHBOARDS_ID, @@ -382,15 +351,9 @@ export class DashboardPlugin if (home) { home.featureCatalogue.register({ id: DashboardConstants.DASHBOARD_ID, - title: i18n.translate('dashboard.featureCatalogue.dashboardTitle', { - defaultMessage: 'Dashboard', - }), - subtitle: i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { - defaultMessage: 'Analyze data in dashboards.', - }), - description: i18n.translate('dashboard.featureCatalogue.dashboardDescription', { - defaultMessage: 'Display and share a collection of visualizations and saved searches.', - }), + title: dashboardFeatureCatalog.getTitle(), + subtitle: dashboardFeatureCatalog.getSubtitle(), + description: dashboardFeatureCatalog.getDescription(), icon: 'dashboardApp', path: `/app/dashboards#${DashboardConstants.LANDING_PAGE_PATH}`, showOnHomePage: false, @@ -401,29 +364,16 @@ export class DashboardPlugin } } - private addEmbeddableToDashboard( - core: CoreStart, - { embeddableId, embeddableType }: { embeddableId: string; embeddableType: string } - ) { - if (!this.getActiveUrl) { - throw new Error('dashboard is not ready yet.'); - } - - const lastDashboardUrl = this.getActiveUrl(); - const dashboardUrl = addEmbeddableToDashboardUrl( - lastDashboardUrl, - embeddableId, - embeddableType - ); - core.application.navigateToApp('dashboards', { path: dashboardUrl }); - } - - public start(core: CoreStart, plugins: StartDependencies): DashboardStart { + public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { const { notifications } = core; const { uiActions, data, share } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); + const expandPanelAction = new ExpandPanelAction(); + uiActions.registerAction(expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + const changeViewAction = new ReplacePanelAction( core, SavedObjectFinder, @@ -467,7 +417,6 @@ export class DashboardPlugin return { getSavedDashboardLoader: () => savedDashboardLoader, - addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core), dashboardUrlGenerator: this.dashboardUrlGenerator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index e3bfe346fbc07..e9645b36af660 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -16,15 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { SavedObject, SavedObjectsStart } from '../../../../plugins/saved_objects/public'; +import { EmbeddableStart } from '../services/embeddable'; +import { SavedObject, SavedObjectsStart } from '../services/saved_objects'; +import { Filter, ISearchSource, Query, RefreshInterval } from '../services/data'; -import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public'; import { createDashboardEditUrl } from '../dashboard_constants'; -import { EmbeddableStart } from '../../../embeddable/public'; -import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { extractReferences, injectReferences } from '../../common/saved_dashboard_references'; -export interface SavedObjectDashboard extends SavedObject { +import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; + +export interface DashboardSavedObject extends SavedObject { id?: string; timeRestore: boolean; timeTo?: string; @@ -45,7 +46,7 @@ export interface SavedObjectDashboard extends SavedObject { export function createSavedDashboardClass( savedObjectStart: SavedObjectsStart, embeddableStart: EmbeddableStart -): new (id: string) => SavedObjectDashboard { +): new (id: string) => DashboardSavedObject { class SavedDashboard extends savedObjectStart.SavedObjectClass { // save these objects with the 'dashboard' type public static type = 'dashboard'; @@ -84,7 +85,7 @@ export function createSavedDashboardClass( attributes: SavedObjectAttributes; references: SavedObjectReference[]; }) => extractReferences(opts, { embeddablePersistableStateService: embeddableStart }), - injectReferences: (so: SavedObjectDashboard, references: SavedObjectReference[]) => { + injectReferences: (so: DashboardSavedObject, references: SavedObjectReference[]) => { const newAttributes = injectReferences( { attributes: so._serialize().attributes, references }, { @@ -129,5 +130,5 @@ export function createSavedDashboardClass( // Unfortunately this throws a typescript error without the casting. I think it's due to the // convoluted way SavedObjects are created. - return (SavedDashboard as unknown) as new (id: string) => SavedObjectDashboard; + return (SavedDashboard as unknown) as new (id: string) => DashboardSavedObject; } diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 7193a77fd0ec9..85deab0b1711d 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -18,9 +18,11 @@ */ import { SavedObjectsClientContract } from 'kibana/public'; -import { SavedObjectLoader, SavedObjectsStart } from '../../../../plugins/saved_objects/public'; + +import { EmbeddableStart } from '../services/embeddable'; +import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; + import { createSavedDashboardClass } from './saved_dashboard'; -import { EmbeddableStart } from '../../../embeddable/public'; interface Services { savedObjectsClient: SavedObjectsClientContract; diff --git a/src/plugins/dashboard/public/services/core.ts b/src/plugins/dashboard/public/services/core.ts new file mode 100644 index 0000000000000..e7dcc8463bb50 --- /dev/null +++ b/src/plugins/dashboard/public/services/core.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + AppMountParameters, + CoreSetup, + PluginInitializerContext, + ScopedHistory, + NotificationsStart, +} from '../../../../core/public'; diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js b/src/plugins/dashboard/public/services/data.ts similarity index 95% rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js rename to src/plugins/dashboard/public/services/data.ts index d675702ae54e9..8e1c96fea388c 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js +++ b/src/plugins/dashboard/public/services/data.ts @@ -17,4 +17,4 @@ * under the License. */ -export { jobs } from './jobs'; +export * from '../../../data/public'; diff --git a/src/plugins/dashboard/public/embeddable_plugin.ts b/src/plugins/dashboard/public/services/embeddable.ts similarity index 93% rename from src/plugins/dashboard/public/embeddable_plugin.ts rename to src/plugins/dashboard/public/services/embeddable.ts index 30c0ec4975141..8e1d91ca76f92 100644 --- a/src/plugins/dashboard/public/embeddable_plugin.ts +++ b/src/plugins/dashboard/public/services/embeddable.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/embeddable/public'; +export * from '../../../embeddable/public'; diff --git a/src/plugins/dashboard/public/embeddable_plugin_test_samples.ts b/src/plugins/dashboard/public/services/embeddable_test_samples.ts similarity index 92% rename from src/plugins/dashboard/public/embeddable_plugin_test_samples.ts rename to src/plugins/dashboard/public/services/embeddable_test_samples.ts index 45759bf078911..3e2c188420828 100644 --- a/src/plugins/dashboard/public/embeddable_plugin_test_samples.ts +++ b/src/plugins/dashboard/public/services/embeddable_test_samples.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/embeddable/public/lib/test_samples'; +export * from '../../../embeddable/public/lib/test_samples'; diff --git a/src/plugins/dashboard/public/services/home.ts b/src/plugins/dashboard/public/services/home.ts new file mode 100644 index 0000000000000..d5fb6f77f1922 --- /dev/null +++ b/src/plugins/dashboard/public/services/home.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../home/public'; diff --git a/src/plugins/dashboard/public/services/kibana_legacy.ts b/src/plugins/dashboard/public/services/kibana_legacy.ts new file mode 100644 index 0000000000000..4f6f87b3bdab3 --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_legacy.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { KibanaLegacySetup, KibanaLegacyStart } from '../../../kibana_legacy/public'; diff --git a/src/plugins/dashboard/public/services/kibana_react.ts b/src/plugins/dashboard/public/services/kibana_react.ts new file mode 100644 index 0000000000000..6cb70c1526eb7 --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_react.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + context, + useKibana, + withKibana, + toMountPoint, + TableListView, + reactToUiComponent, + KibanaReactContext, + ExitFullScreenButton, + KibanaContextProvider, + KibanaReactContextValue, + ExitFullScreenButtonProps, +} from '../../../kibana_react/public'; diff --git a/src/plugins/dashboard/public/services/kibana_utils.ts b/src/plugins/dashboard/public/services/kibana_utils.ts new file mode 100644 index 0000000000000..876e9c0773542 --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_utils.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + unhashUrl, + syncState, + ISyncStateRef, + getQueryParams, + setStateToKbnUrl, + removeQueryParam, + withNotifyOnErrors, + IKbnUrlStateStorage, + createKbnUrlTracker, + SavedObjectNotFound, + createStateContainer, + ReduxLikeStateContainer, + createKbnUrlStateStorage, +} from '../../../kibana_utils/public'; diff --git a/src/plugins/dashboard/public/services/navigation.ts b/src/plugins/dashboard/public/services/navigation.ts new file mode 100644 index 0000000000000..23049caaf48f3 --- /dev/null +++ b/src/plugins/dashboard/public/services/navigation.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { NavigationPublicPluginStart } from '../../../navigation/public'; diff --git a/src/plugins/dashboard/public/services/saved_objects.ts b/src/plugins/dashboard/public/services/saved_objects.ts new file mode 100644 index 0000000000000..d93c69ab25571 --- /dev/null +++ b/src/plugins/dashboard/public/services/saved_objects.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + SaveResult, + SavedObject, + showSaveModal, + SavedObjectLoader, + SavedObjectsStart, + SavedObjectSaveOpts, + SavedObjectSaveModal, + getSavedObjectFinder, + SavedObjectLoaderFindOptions, +} from '../../../saved_objects/public'; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts b/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts new file mode 100644 index 0000000000000..858af8f79d866 --- /dev/null +++ b/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type { + SavedObjectsTaggingApi, + TagDecoratedSavedObject, + SavedObjectTagDecoratorTypeGuard, + SavedObjectTaggingOssPluginStart, +} from '../../../saved_objects_tagging_oss/public'; diff --git a/src/plugins/dashboard/public/services/share.ts b/src/plugins/dashboard/public/services/share.ts new file mode 100644 index 0000000000000..b1c5df542711c --- /dev/null +++ b/src/plugins/dashboard/public/services/share.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + SharePluginStart, + SharePluginSetup, + downloadMultipleAs, + UrlGeneratorContract, +} from '../../../share/public'; diff --git a/src/plugins/dashboard/public/services/ui_actions.ts b/src/plugins/dashboard/public/services/ui_actions.ts new file mode 100644 index 0000000000000..4c9ac590191f6 --- /dev/null +++ b/src/plugins/dashboard/public/services/ui_actions.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + ActionByType, + IncompatibleActionError, + UiActionsSetup, + UiActionsStart, +} from '../../../ui_actions/public'; diff --git a/src/plugins/dashboard/public/services/usage_collection.ts b/src/plugins/dashboard/public/services/usage_collection.ts new file mode 100644 index 0000000000000..e740a42cede5a --- /dev/null +++ b/src/plugins/dashboard/public/services/usage_collection.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { UsageCollectionSetup } from '../../../usage_collection/public'; diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 8f6fe7fce5cfe..7e859a81d9d4d 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -17,19 +17,13 @@ * under the License. */ -import { Query, Filter } from 'src/plugins/data/public'; import { SavedObject as SavedObjectType, SavedObjectAttributes } from 'src/core/public'; - -import { ViewMode } from './embeddable_plugin'; +import { Query, Filter } from './services/data'; +import { ViewMode } from './services/embeddable'; import { SavedDashboardPanel } from '../common/types'; export { SavedDashboardPanel }; -export interface DashboardCapabilities { - showWriteControls: boolean; - createNew: boolean; -} - // TODO: Replace Saved object interfaces by the ones Core will provide when it is ready. export type SavedObjectAttribute = | string diff --git a/src/plugins/dashboard/public/url_utils/url_helper.test.ts b/src/plugins/dashboard/public/url_utils/url_helper.test.ts deleted file mode 100644 index d2210e7380667..0000000000000 --- a/src/plugins/dashboard/public/url_utils/url_helper.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { addEmbeddableToDashboardUrl } from './url_helper'; - -describe('', () => { - it('addEmbeddableToDashboardUrl when dashboard is not saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/dashboards#/create?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - - expect(addEmbeddableToDashboardUrl(url, id, 'visualization')).toBe( - '/pep/app/dashboards?addEmbeddableId=123eb456cd&addEmbeddableType=visualization#%2Fcreate%3F_g%3D%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29%26_a%3D%28description%3A%27%27%2Cfilters%3A%21%28%29%29' - ); - }); - it('addEmbeddableToDashboardUrl when dashboard is saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/dashboards#/view/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - expect(addEmbeddableToDashboardUrl(url, id, 'visualization')).toBe( - '/pep/app/dashboards?addEmbeddableId=123eb456cd&addEmbeddableType=visualization#%2Fview%2F9b780cd0-3dd3-11e8-b2b9-5d5dc1715159%3F_g%3D%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29%26_a%3D%28description%3A%27%27%2Cfilters%3A%21%28%29%29' - ); - }); -}); diff --git a/src/plugins/dashboard/public/url_utils/url_helper.ts b/src/plugins/dashboard/public/url_utils/url_helper.ts deleted file mode 100644 index 1f4706f0b8a4d..0000000000000 --- a/src/plugins/dashboard/public/url_utils/url_helper.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { parseUrl, stringifyUrl } from 'query-string'; -import { DashboardConstants } from '../index'; - -/** * - * Returns relative dashboard URL with added embeddableType and embeddableId query params - * eg. - * input: url: #/create?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 - * output: #/create?addEmbeddableType=visualization&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) - * @param url dasbhoard hash part of the url - * @param embeddableId id of the saved embeddable - * @param embeddableType type of the embeddable - */ -export function addEmbeddableToDashboardUrl( - dashboardUrl: string, - embeddableId: string, - embeddableType: string -) { - const { url, query, fragmentIdentifier } = parseUrl(dashboardUrl, { - parseFragmentIdentifier: true, - }); - - if (embeddableId) { - query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = embeddableType; - query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; - } - - return stringifyUrl({ url, query, fragmentIdentifier }); -} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 179d6c35b3ab6..35546c33aaa80 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -31,7 +31,6 @@ import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { ExclusiveUnion } from '@elastic/eui'; import { ExecutionContext } from 'src/plugins/expressions/common'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; diff --git a/src/plugins/data/public/ui/filter_bar/_index.scss b/src/plugins/data/public/ui/filter_bar/_index.scss index 9e2478cb0704e..5333aff8b87da 100644 --- a/src/plugins/data/public/ui/filter_bar/_index.scss +++ b/src/plugins/data/public/ui/filter_bar/_index.scss @@ -1,4 +1,3 @@ @import 'variables'; @import 'global_filter_group'; @import 'global_filter_item'; -@import 'filter_editor/index'; diff --git a/src/plugins/data/public/ui/filter_bar/_variables.scss b/src/plugins/data/public/ui/filter_bar/_variables.scss index efe2e28ac3b8a..3a9a0df4332c8 100644 --- a/src/plugins/data/public/ui/filter_bar/_variables.scss +++ b/src/plugins/data/public/ui/filter_bar/_variables.scss @@ -1,4 +1,3 @@ $kbnGlobalFilterItemBorderColor: tintOrShade($euiColorMediumShade, 35%, 20%); $kbnGlobalFilterItemBorderColorExcluded: tintOrShade($euiColorDanger, 70%, 50%); $kbnGlobalFilterItemPinnedColorExcluded: tintOrShade($euiColorDanger, 30%, 20%); -$kbnGlobalFilterItemEditorWidth: 420px; // if changing this make sure to also change `FILTER_EDITOR_WIDTH` in ./filter_item.tsx diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss b/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss deleted file mode 100644 index 736e06ee9bdea..0000000000000 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss +++ /dev/null @@ -1,5 +0,0 @@ -.globalFilterEditor__fieldInput { - @include euiBreakpoint('m', 'l', 'xl') { - max-width: $kbnGlobalFilterItemEditorWidth * 0.66; - } -} diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss b/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss deleted file mode 100644 index 3d416aade9a53..0000000000000 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'filter_editor'; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 2b8e11fc8f756..d25c092bc97f4 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -154,10 +154,12 @@ class FilterEditorUI extends Component { id: 'data.filter.filterEditor.createCustomLabelInputLabel', defaultMessage: 'Custom label', })} + fullWidth > @@ -218,12 +220,14 @@ class FilterEditorUI extends Component { { return ( { onChange={this.onFieldChange} singleSelection={{ asPlainText: true }} isClearable={false} - className="globalFilterEditor__fieldInput" data-test-subj="filterFieldSuggestionList" /> @@ -293,12 +298,14 @@ class FilterEditorUI extends Component { const operators = selectedField ? getOperatorOptions(selectedField) : []; return ( { private renderCustomEditor() { return ( { value={this.state.params} onChange={this.onParamsChange} data-test-subj="phraseValueInput" + fullWidth /> ); case 'phrases': @@ -367,6 +376,7 @@ class FilterEditorUI extends Component { field={this.state.selectedField} values={this.state.params} onChange={this.onParamsChange} + fullWidth /> ); case 'range': @@ -375,6 +385,7 @@ class FilterEditorUI extends Component { field={this.state.selectedField} value={this.state.params} onChange={this.onParamsChange} + fullWidth /> ); } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx index ca94970afbafd..1ae88b8a21832 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx @@ -30,12 +30,14 @@ interface Props extends PhraseSuggestorProps { value?: string; onChange: (value: string | number | boolean) => void; intl: InjectedIntl; + fullWidth?: boolean; } class PhraseValueInputUI extends PhraseSuggestorUI { public render() { return ( { this.renderWithSuggestions() ) : ( { private renderWithSuggestions() { const { suggestions } = this.state; - const { value, intl, onChange } = this.props; + const { value, intl, onChange, fullWidth } = this.props; // there are cases when the value is a number, this would cause an exception const valueAsStr = String(value); const options = value ? uniq([valueAsStr, ...suggestions]) : suggestions; return ( void; intl: InjectedIntl; + fullWidth?: boolean; } class PhrasesValuesInputUI extends PhraseSuggestorUI { public render() { const { suggestions } = this.state; - const { values, intl, onChange } = this.props; + const { values, intl, onChange, fullWidth } = this.props; const options = values ? uniq([...values, ...suggestions]) : suggestions; return ( void; intl: InjectedIntl; + fullWidth?: boolean; } function RangeValueInputUI(props: Props) { @@ -71,6 +72,7 @@ function RangeValueInputUI(props: Props) { return (
{ @@ -42,6 +43,7 @@ class ValueInputTypeUI extends Component { case 'string': inputElement = ( { case 'number': inputElement = ( { case 'date': inputElement = ( { case 'ip': inputElement = ( { value={value} onChange={this.onBoolChange} className={this.props.className} + fullWidth={this.props.fullWidth} /> ); break; diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 48dbfea634256..5e6fd5323c0b7 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -62,12 +62,7 @@ export type FilterLabelStatus = | typeof FILTER_ITEM_WARNING | typeof FILTER_ITEM_ERROR; -/** - * @remarks - * if changing this make sure to also change - * $kbnGlobalFilterItemEditorWidth - */ -export const FILTER_EDITOR_WIDTH = 420; +export const FILTER_EDITOR_WIDTH = 800; export function FilterItem(props: Props) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js b/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js deleted file mode 100644 index 39ebd9595eeaf..0000000000000 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export const jobs = [ - { - job_id: 'foo1', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - node: [ - { - agg: 'terms', - }, - ], - temperature: [ - { - agg: 'min', - }, - { - agg: 'max', - }, - { - agg: 'sum', - }, - ], - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'UTC', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 5, - }, - { - agg: 'sum', - }, - ], - }, - }, - { - job_id: 'foo2', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - host: [ - { - agg: 'terms', - }, - ], - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'UTC', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 20, - }, - ], - }, - }, - { - job_id: 'foo3', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'PST', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 5, - }, - { - agg: 'sum', - }, - ], - }, - }, -]; diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js b/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js similarity index 52% rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js rename to src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js index e3c93ac1f8616..c8b0b90eb7999 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js +++ b/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js @@ -17,46 +17,137 @@ * under the License. */ -import expect from '@kbn/expect'; -import { areJobsCompatible, mergeJobConfigurations } from '../jobs_compatibility'; -import { jobs } from './fixtures'; +import { areJobsCompatible, mergeJobConfigurations } from './jobs_compatibility'; + +const jobs = [ + { + job_id: 'foo1', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + node: [ + { + agg: 'terms', + }, + ], + temperature: [ + { + agg: 'min', + }, + { + agg: 'max', + }, + { + agg: 'sum', + }, + ], + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'UTC', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 5, + }, + { + agg: 'sum', + }, + ], + }, + }, + { + job_id: 'foo2', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + host: [ + { + agg: 'terms', + }, + ], + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'UTC', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 20, + }, + ], + }, + }, + { + job_id: 'foo3', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'PST', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 5, + }, + { + agg: 'sum', + }, + ], + }, + }, +]; describe('areJobsCompatible', () => { it('should return false for invalid jobs arg', () => { - expect(areJobsCompatible(123)).to.eql(false); - expect(areJobsCompatible('foo')).to.eql(false); + expect(areJobsCompatible(123)).toEqual(false); + expect(areJobsCompatible('foo')).toEqual(false); }); it('should return true for no jobs or one job', () => { - expect(areJobsCompatible()).to.eql(true); - expect(areJobsCompatible([])).to.eql(true); - expect(areJobsCompatible([jobs[1]])).to.eql(true); + expect(areJobsCompatible()).toEqual(true); + expect(areJobsCompatible([])).toEqual(true); + expect(areJobsCompatible([jobs[1]])).toEqual(true); }); it('should return true for 2 or more compatible jobs', () => { - expect(areJobsCompatible([jobs[0], jobs[1]])).to.eql(true); - expect(areJobsCompatible([jobs[1], jobs[0], jobs[1]])).to.eql(true); + expect(areJobsCompatible([jobs[0], jobs[1]])).toEqual(true); + expect(areJobsCompatible([jobs[1], jobs[0], jobs[1]])).toEqual(true); }); it('should return false for 2 or more incompatible jobs', () => { - expect(areJobsCompatible([jobs[1], jobs[2]])).to.eql(false); - expect(areJobsCompatible([jobs[2], jobs[1], jobs[0]])).to.eql(false); + expect(areJobsCompatible([jobs[1], jobs[2]])).toEqual(false); + expect(areJobsCompatible([jobs[2], jobs[1], jobs[0]])).toEqual(false); }); }); describe('mergeJobConfigurations', () => { it('should throw an error for null/invalid jobs', () => { - expect(mergeJobConfigurations).withArgs().to.throwException(); - expect(mergeJobConfigurations).withArgs(null).to.throwException(); - expect(mergeJobConfigurations).withArgs(undefined).to.throwException(); - expect(mergeJobConfigurations).withArgs(true).to.throwException(); - expect(mergeJobConfigurations).withArgs('foo').to.throwException(); - expect(mergeJobConfigurations).withArgs(123).to.throwException(); - expect(mergeJobConfigurations).withArgs([]).to.throwException(); + expect(() => mergeJobConfigurations()).toThrow(); + expect(() => mergeJobConfigurations(null)).toThrow(); + expect(() => mergeJobConfigurations(undefined)).toThrow(); + expect(() => mergeJobConfigurations(true)).toThrow(); + expect(() => mergeJobConfigurations('foo')).toThrow(); + expect(() => mergeJobConfigurations(123)).toThrow(); + expect(() => mergeJobConfigurations([])).toThrow(); }); it('should return aggregations for one job', () => { - expect(mergeJobConfigurations([jobs[0]])).to.eql({ + expect(mergeJobConfigurations([jobs[0]])).toEqual({ aggs: { terms: { node: { @@ -100,7 +191,7 @@ describe('mergeJobConfigurations', () => { }); it('should return merged aggregations for 2 jobs', () => { - expect(mergeJobConfigurations([jobs[0], jobs[1]])).to.eql({ + expect(mergeJobConfigurations([jobs[0], jobs[1]])).toEqual({ aggs: { terms: { node: { @@ -147,6 +238,6 @@ describe('mergeJobConfigurations', () => { }); it('should throw an error if jobs are not compatible', () => { - expect(mergeJobConfigurations).withArgs([jobs[0], jobs[1], jobs[2]]).to.throwException(); + expect(() => mergeJobConfigurations([jobs[0], jobs[1], jobs[2]])).toThrow(); }); }); diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 0d2dcf208f2ef..0fc7c7965010b 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -54,6 +54,7 @@ export { ErrorEmbeddable, IContainer, IEmbeddable, + isEmbeddable, isErrorEmbeddable, openAddPanelFlyout, OutputSpec, @@ -70,6 +71,7 @@ export { isSavedObjectEmbeddableInput, isRangeSelectTriggerContext, isValueClickTriggerContext, + isRowClickTriggerContext, isContextMenuTriggerContext, EmbeddableStateTransfer, EmbeddableEditorState, diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 5a73df2e13861..a19ec2345db8d 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -33,7 +33,7 @@ export { EmbeddableInput }; export interface EmbeddableOutput { // Whether the embeddable is actively loading. loading?: boolean; - // Whether the embeddable finshed loading with an error. + // Whether the embeddable finished loading with an error. error?: EmbeddableError; editUrl?: string; editApp?: string; diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 2f6de1be60c9c..4c2baa3bbf809 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -17,6 +17,7 @@ * under the License. */ export { EmbeddableOutput, EmbeddableInput, IEmbeddable } from './i_embeddable'; +export { isEmbeddable } from './is_embeddable'; export { Embeddable } from './embeddable'; export * from './embeddable_factory'; export * from './embeddable_factory_definition'; diff --git a/src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts new file mode 100644 index 0000000000000..e660fdbc4472c --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IEmbeddable } from './i_embeddable'; + +export const isEmbeddable = (x: unknown): x is IEmbeddable => { + if (!x) return false; + if (typeof x !== 'object') return false; + if (typeof (x as IEmbeddable).id !== 'string') return false; + if (typeof (x as IEmbeddable).getInput !== 'function') return false; + if (typeof (x as IEmbeddable).supportedTriggers !== 'function') return false; + return true; +}; diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index b2965b55dbdfa..c3b1496b8eca8 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { Datatable } from '../../../../expressions'; -import { Trigger } from '../../../../ui_actions/public'; +import { Trigger, RowClickContext } from '../../../../ui_actions/public'; import { IEmbeddable } from '..'; export interface EmbeddableContext { @@ -52,7 +52,8 @@ export interface RangeSelectContext { export type ChartActionContext = | ValueClickContext - | RangeSelectContext; + | RangeSelectContext + | RowClickContext; export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER'; export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = { @@ -95,6 +96,11 @@ export const isRangeSelectTriggerContext = ( context: ChartActionContext ): context is RangeSelectContext => context.data && 'range' in context.data; +export const isRowClickTriggerContext = (context: ChartActionContext): context is RowClickContext => + !!context.data && + typeof context.data === 'object' && + typeof (context as RowClickContext).data.rowIndex === 'number'; + export const isContextMenuTriggerContext = (context: unknown): context is EmbeddableContext => !!context && typeof context === 'object' && diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 8f7ea6f51c785..4b7d60b4dc9ec 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -31,7 +31,6 @@ import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { ExclusiveUnion } from '@elastic/eui'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { History } from 'history'; import { Href } from 'history'; @@ -177,10 +176,11 @@ export class AttributeService>; } +// Warning: (ae-forgotten-export) The symbol "RowClickContext" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ChartActionContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type ChartActionContext = ValueClickContext | RangeSelectContext; +export type ChartActionContext = ValueClickContext | RangeSelectContext | RowClickContext; // Warning: (ae-missing-release-tag) "Container" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -727,6 +727,11 @@ export interface IEmbeddable context is EmbeddableContext; +// Warning: (ae-missing-release-tag) "isEmbeddable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isEmbeddable: (x: unknown) => x is IEmbeddable; + // Warning: (ae-missing-release-tag) "isErrorEmbeddable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -742,6 +747,11 @@ export const isRangeSelectTriggerContext: (context: ChartActionContext) => conte // @public (undocumented) export function isReferenceOrValueEmbeddable(incoming: unknown): incoming is ReferenceOrValueEmbeddable; +// Warning: (ae-missing-release-tag) "isRowClickTriggerContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isRowClickTriggerContext: (context: ChartActionContext) => context is RowClickContext; + // Warning: (ae-missing-release-tag) "isSavedObjectEmbeddableInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index dd3124c7d17ee..88aca4c07ee31 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -82,6 +82,7 @@ export interface IInterpreterRenderHandlers { reload: () => void; update: (params: any) => void; event: (event: any) => void; + hasCompatibleActions?: (event: any) => Promise; getRenderMode: () => RenderMode; uiState?: PersistedState; } diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 983a344c0e1a1..e9e0fa18af6c3 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -64,6 +64,7 @@ export class ExpressionLoader { this.renderHandler = new ExpressionRenderHandler(element, { onRenderError: params && params.onRenderError, renderMode: params?.renderMode, + hasCompatibleActions: params?.hasCompatibleActions, }); this.render$ = this.renderHandler.render$; this.update$ = this.renderHandler.update$; diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index 97ff00db0966c..6eb0e71c58e3f 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -532,7 +532,7 @@ export interface ExpressionRenderError extends Error { // @public (undocumented) export class ExpressionRenderHandler { // Warning: (ae-forgotten-export) The symbol "ExpressionRenderHandlerParams" needs to be exported by the entry point index.d.ts - constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); + constructor(element: HTMLElement, { onRenderError, renderMode, hasCompatibleActions, }?: ExpressionRenderHandlerParams); // (undocumented) destroy: () => void; // (undocumented) @@ -544,7 +544,7 @@ export class ExpressionRenderHandler { // (undocumented) render$: Observable; // (undocumented) - render: (data: any, uiState?: any) => Promise; + render: (value: any, uiState?: any) => Promise; // Warning: (ae-forgotten-export) The symbol "UpdateValue" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -888,6 +888,8 @@ export interface IExpressionLoaderParams { // (undocumented) disableCaching?: boolean; // (undocumented) + hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; + // (undocumented) inspectorAdapters?: Adapters; // Warning: (ae-forgotten-export) The symbol "RenderErrorHandlerFnType" needs to be exported by the entry point index.d.ts // @@ -917,6 +919,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) getRenderMode: () => RenderMode; // (undocumented) + hasCompatibleActions?: (event: any) => Promise; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index c44683f6779c0..3fc0100db489d 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -126,6 +126,31 @@ describe('ExpressionRenderHandler', () => { expect(getHandledError()!.message).toEqual('renderer error'); }); + it('should pass through provided "hasCompatibleActions" to the expression renderer', async () => { + const hasCompatibleActions = jest.fn(); + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ get: () => true }); + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ + get: () => ({ + render: (domNode: HTMLElement, config: unknown, handlers: IInterpreterRenderHandlers) => { + handlers.hasCompatibleActions!({ + foo: 'bar', + }); + }, + }), + }); + + const expressionRenderHandler = new ExpressionRenderHandler(element, { + onRenderError: mockMockErrorRenderFunction, + hasCompatibleActions, + }); + expect(hasCompatibleActions).toHaveBeenCalledTimes(0); + await expressionRenderHandler.render({ type: 'render', as: 'something' }); + expect(hasCompatibleActions).toHaveBeenCalledTimes(1); + expect(hasCompatibleActions.mock.calls[0][0]).toEqual({ + foo: 'bar', + }); + }); + it('sends a next observable once rendering is complete', () => { const expressionRenderHandler = new ExpressionRenderHandler(element); expect.assertions(1); diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 4390033b5be60..717776a2861b4 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -29,8 +29,9 @@ import { getRenderersRegistry } from './services'; export type IExpressionRendererExtraHandlers = Record; export interface ExpressionRenderHandlerParams { - onRenderError: RenderErrorHandlerFnType; - renderMode: RenderMode; + onRenderError?: RenderErrorHandlerFnType; + renderMode?: RenderMode; + hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; } export interface ExpressionRendererEvent { @@ -59,7 +60,11 @@ export class ExpressionRenderHandler { constructor( element: HTMLElement, - { onRenderError, renderMode }: Partial = {} + { + onRenderError, + renderMode, + hasCompatibleActions = async () => false, + }: ExpressionRenderHandlerParams = {} ) { this.element = element; @@ -96,17 +101,18 @@ export class ExpressionRenderHandler { getRenderMode: () => { return renderMode || 'display'; }, + hasCompatibleActions, }; } - render = async (data: any, uiState: any = {}) => { - if (!data || typeof data !== 'object') { + render = async (value: any, uiState: any = {}) => { + if (!value || typeof value !== 'object') { return this.handleRenderError(new Error('invalid data provided to the expression renderer')); } - if (data.type !== 'render' || !data.as) { - if (data.type === 'error') { - return this.handleRenderError(data.error); + if (value.type !== 'render' || !value.as) { + if (value.type === 'error') { + return this.handleRenderError(value.error); } else { return this.handleRenderError( new Error('invalid data provided to the expression renderer') @@ -114,15 +120,15 @@ export class ExpressionRenderHandler { } } - if (!getRenderersRegistry().get(data.as)) { - return this.handleRenderError(new Error(`invalid renderer id '${data.as}'`)); + if (!getRenderersRegistry().get(value.as)) { + return this.handleRenderError(new Error(`invalid renderer id '${value.as}'`)); } try { // Rendering is asynchronous, completed by handlers.done() await getRenderersRegistry() - .get(data.as)! - .render(this.element, data.value, { + .get(value.as)! + .render(this.element, value.value, { ...this.handlers, uiState, } as any); @@ -152,7 +158,7 @@ export class ExpressionRenderHandler { export function render( element: HTMLElement, data: any, - options?: Partial + options?: ExpressionRenderHandlerParams ): ExpressionRenderHandler { const handler = new ExpressionRenderHandler(element, options); handler.render(data); diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 5bae985699476..f37107abbb716 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -25,6 +25,7 @@ import { SerializableState, RenderMode, } from '../../common'; +import { ExpressionRenderHandlerParams } from '../render'; /** * @deprecated @@ -56,6 +57,7 @@ export interface IExpressionLoaderParams { onRenderError?: RenderErrorHandlerFnType; searchSessionId?: string; renderMode?: RenderMode; + hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; } export interface ExpressionRenderError extends Error { diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index 761ddba8f9270..7c1ab11f75027 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -736,6 +736,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) getRenderMode: () => RenderMode; // (undocumented) + hasCompatibleActions?: (event: any) => Promise; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/home/public/application/components/feature_directory.js b/src/plugins/home/public/application/components/feature_directory.js deleted file mode 100644 index 36ececcdfd8df..0000000000000 --- a/src/plugins/home/public/application/components/feature_directory.js +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { Synopsis } from './synopsis'; -import { - EuiTabs, - EuiTab, - EuiFlexItem, - EuiFlexGrid, - EuiPage, - EuiPageBody, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; - -import { FeatureCatalogueCategory } from '../../services'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { createAppNavigationHandler } from './app_navigation_handler'; - -const ALL_TAB_ID = 'all'; -const OTHERS_TAB_ID = 'others'; - -const isOtherCategory = (directory) => { - return ( - directory.category !== FeatureCatalogueCategory.DATA && - directory.category !== FeatureCatalogueCategory.ADMIN - ); -}; - -export class FeatureDirectory extends React.Component { - constructor(props) { - super(props); - - this.tabs = [ - { - id: ALL_TAB_ID, - name: i18n.translate('home.directory.tabs.allTitle', { defaultMessage: 'All' }), - }, - { - id: FeatureCatalogueCategory.DATA, - name: i18n.translate('home.directory.tabs.dataTitle', { - defaultMessage: 'Data Exploration & Visualization', - }), - }, - { - id: FeatureCatalogueCategory.ADMIN, - name: i18n.translate('home.directory.tabs.administrativeTitle', { - defaultMessage: 'Administrative', - }), - }, - ]; - if (props.directories.some(isOtherCategory)) { - this.tabs.push({ - id: OTHERS_TAB_ID, - name: i18n.translate('home.directory.tabs.otherTitle', { defaultMessage: 'Other' }), - }); - } - - this.state = { - selectedTabId: ALL_TAB_ID, - }; - } - - onSelectedTabChanged = (id) => { - this.setState({ - selectedTabId: id, - }); - }; - - renderTabs = () => { - return this.tabs.map((tab, index) => ( - this.onSelectedTabChanged(tab.id)} - isSelected={tab.id === this.state.selectedTabId} - key={index} - > - {tab.name} - - )); - }; - - renderDirectories = () => { - return this.props.directories - .filter((directory) => { - if (this.state.selectedTabId === ALL_TAB_ID) { - return true; - } - if (this.state.selectedTabId === OTHERS_TAB_ID) { - return isOtherCategory(directory); - } - return this.state.selectedTabId === directory.category; - }) - .map((directory) => { - return ( - - - - ); - }); - }; - - render() { - return ( - - - -

- -

-
- - {this.renderTabs()} - - {this.renderDirectories()} -
-
- ); - } -} - -FeatureDirectory.propTypes = { - addBasePath: PropTypes.func.isRequired, - directories: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - showOnHomePage: PropTypes.bool.isRequired, - category: PropTypes.string.isRequired, - order: PropTypes.number, - }) - ), -}; diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 734100fe584ab..2ea96ad904b21 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -21,7 +21,6 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; import { Home } from './home'; -import { FeatureDirectory } from './feature_directory'; import { TutorialDirectory } from './tutorial_directory'; import { Tutorial } from './tutorial/tutorial'; import { HashRouter as Router, Switch, Route } from 'react-router-dom'; @@ -78,9 +77,6 @@ export function HomeApp({ directories, solutions }) { - - - - -
-
- - - - - + +
diff --git a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx index f90ecdda93242..568677ee389fa 100644 --- a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx +++ b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx @@ -28,7 +28,7 @@ jest.mock('../../app_links', () => ({ jest.mock('../../context', () => ({ useKibana: jest.fn().mockReturnValue({ services: { - application: { capabilities: { advancedSettings: { show: true } } }, + application: { capabilities: { advancedSettings: { show: true, save: true } } }, notifications: { toast: { addSuccess: jest.fn() } }, }, }), diff --git a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx index 113992099aee1..576046092d512 100644 --- a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx +++ b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx @@ -32,7 +32,7 @@ interface Props { path: string; /** Callback function to invoke when the user wants to set their default route to the current page */ onSetDefaultRoute?: (event: MouseEvent) => void; - /** Callback function to invoke when the user wants to change their default route button is changed */ + /** Callback function to invoke when the user wants to change their default route button is changed */ onChangeDefaultRoute?: (event: MouseEvent) => void; } @@ -51,9 +51,9 @@ export const OverviewPageFooter: FC = ({ } = useKibana(); const { show, save } = application.capabilities.advancedSettings; - const isAdvancedSettingsEnabled = show && save; + if (!show && !save) return <>; - const defaultRoutebutton = defaultRoute.includes(path) ? ( + const defaultRouteButton = defaultRoute.includes(path) ? ( = ({
-
{isAdvancedSettingsEnabled ? defaultRoutebutton : null}
-
- - -
- - - - - -
+
{defaultRouteButton}
diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx index 2565f1c6290c8..0cf1f8e65db07 100644 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { EuiComboBox } from '@elastic/eui'; import { SavedObjectsClientContract } from '../../../../core/public'; -import { SavedObjectDashboard } from '../../../../plugins/dashboard/public'; +import { DashboardSavedObject } from '../../../../plugins/dashboard/public'; export interface DashboardPickerProps { onChange: (dashboard: { name: string; id: string } | null) => void; @@ -48,7 +48,7 @@ export function DashboardPicker(props: DashboardPickerProps) { setIsLoadingDashboards(true); setDashboards([]); - const { savedObjects } = await savedObjectsClient.find({ + const { savedObjects } = await savedObjectsClient.find({ type: 'dashboard', search: query ? `${query}*` : '', searchFields: ['title'], diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index b9f4a4a0426bf..d223c0abcccb7 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -50,6 +50,9 @@ export { visualizeFieldTrigger, VISUALIZE_GEO_FIELD_TRIGGER, visualizeGeoFieldTrigger, + ROW_CLICK_TRIGGER, + rowClickTrigger, + RowClickContext, } from './triggers'; export { TriggerContextMapping, diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 87a1df959ccbd..fdb75e9a426e9 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,6 +23,7 @@ import { UiActionsService } from './service'; import { selectRangeTrigger, valueClickTrigger, + rowClickTrigger, applyFilterTrigger, visualizeFieldTrigger, visualizeGeoFieldTrigger, @@ -48,6 +49,7 @@ export class UiActionsPlugin implements Plugin { public setup(core: CoreSetup): UiActionsSetup { this.service.registerTrigger(selectRangeTrigger); this.service.registerTrigger(valueClickTrigger); + this.service.registerTrigger(rowClickTrigger); this.service.registerTrigger(applyFilterTrigger); this.service.registerTrigger(visualizeFieldTrigger); this.service.registerTrigger(visualizeGeoFieldTrigger); diff --git a/src/plugins/ui_actions/public/public.api.md b/src/plugins/ui_actions/public/public.api.md index ca27e19b247c2..2384dfab13c8c 100644 --- a/src/plugins/ui_actions/public/public.api.md +++ b/src/plugins/ui_actions/public/public.api.md @@ -133,6 +133,32 @@ export class IncompatibleActionError extends Error { // @public (undocumented) export function plugin(initializerContext: PluginInitializerContext): UiActionsPlugin; +// Warning: (ae-missing-release-tag) "ROW_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const ROW_CLICK_TRIGGER = "ROW_CLICK_TRIGGER"; + +// Warning: (ae-missing-release-tag) "RowClickContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface RowClickContext { + // (undocumented) + data: { + rowIndex: number; + table: Datatable; + columns?: string[]; + }; + // Warning: (ae-forgotten-export) The symbol "IEmbeddable" needs to be exported by the entry point index.d.ts + // + // (undocumented) + embeddable?: IEmbeddable; +} + +// Warning: (ae-missing-release-tag) "rowClickTrigger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'>; + // Warning: (ae-missing-release-tag) "SELECT_RANGE_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -170,6 +196,8 @@ export interface TriggerContextMapping { // // (undocumented) [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; + // (undocumented) + [ROW_CLICK_TRIGGER]: RowClickContext; // Warning: (ae-forgotten-export) The symbol "RangeSelectContext" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -234,14 +262,14 @@ export class UiActionsService { // // (undocumented) protected readonly actions: ActionRegistry; - readonly addTriggerAction: (triggerId: T, action: UiActionsActionDefinition | Action) => void; + readonly addTriggerAction: (triggerId: T, action: UiActionsActionDefinition | Action) => void; // (undocumented) - readonly attachAction: (triggerId: T, actionId: string) => void; + readonly attachAction: (triggerId: T, actionId: string) => void; readonly clear: () => void; // (undocumented) readonly detachAction: (triggerId: TriggerId, actionId: string) => void; // @deprecated (undocumented) - readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; + readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; // Warning: (ae-forgotten-export) The symbol "UiActionsExecutionService" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -252,11 +280,11 @@ export class UiActionsService { // Warning: (ae-forgotten-export) The symbol "TriggerContract" needs to be exported by the entry point index.d.ts // // (undocumented) - readonly getTrigger: (triggerId: T) => TriggerContract; + readonly getTrigger: (triggerId: T) => TriggerContract; // (undocumented) - readonly getTriggerActions: (triggerId: T) => Action[]; + readonly getTriggerActions: (triggerId: T) => Action[]; // (undocumented) - readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; + readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; // (undocumented) readonly hasAction: (actionId: string) => boolean; // Warning: (ae-forgotten-export) The symbol "ActionContext" needs to be exported by the entry point index.d.ts @@ -341,6 +369,10 @@ export const visualizeFieldTrigger: Trigger<'VISUALIZE_FIELD_TRIGGER'>; export const visualizeGeoFieldTrigger: Trigger<'VISUALIZE_GEO_FIELD_TRIGGER'>; +// Warnings were encountered during analysis: +// +// src/plugins/ui_actions/public/triggers/row_click_trigger.ts:45:5 - (ae-forgotten-export) The symbol "Datatable" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index 4f0ab52501a95..59616dcf3f38d 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -29,6 +29,7 @@ interface ExecuteActionTask { context: BaseContext; trigger: Trigger; defer: Defer; + alwaysShowPopup?: boolean; } export class UiActionsExecutionService { @@ -37,21 +38,25 @@ export class UiActionsExecutionService { constructor() {} - async execute({ - action, - context, - trigger, - }: { - action: Action; - context: BaseContext; - trigger: Trigger; - }): Promise { + async execute( + { + action, + context, + trigger, + }: { + action: Action; + context: BaseContext; + trigger: Trigger; + }, + alwaysShowPopup?: boolean + ): Promise { const shouldBatch = !(await action.shouldAutoExecute?.({ ...context, trigger })) ?? false; const task: ExecuteActionTask = { action, context, trigger, defer: createDefer(), + alwaysShowPopup: !!alwaysShowPopup, }; if (shouldBatch) { @@ -84,11 +89,23 @@ export class UiActionsExecutionService { setTimeout(() => { if (this.pendingTasks.size === 0) { const tasks = uniqBy(this.batchingQueue, (t) => t.action.id); - if (tasks.length === 1) { - this.executeSingleTask(tasks[0]); - } - if (tasks.length > 1) { - this.executeMultipleActions(tasks); + if (tasks.length > 0) { + let alwaysShowPopup = false; + for (const task of tasks) { + if (task.alwaysShowPopup) { + alwaysShowPopup = true; + break; + } + } + if (alwaysShowPopup) { + this.showActionPopupMenu(tasks); + } else { + if (tasks.length === 1) { + this.executeSingleTask(tasks[0]); + } else if (tasks.length > 1) { + this.showActionPopupMenu(tasks); + } + } } this.batchingQueue.splice(0, this.batchingQueue.length); @@ -108,7 +125,7 @@ export class UiActionsExecutionService { } } - private async executeMultipleActions(tasks: ExecuteActionTask[]) { + private async showActionPopupMenu(tasks: ExecuteActionTask[]) { const panels = await buildContextMenuForActions({ actions: tasks.map(({ action, context, trigger }) => ({ action, diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index af2510467ba87..51ba165ba730b 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -143,7 +143,32 @@ test('shows a context menu when more than one action is mapped to a trigger', as const start = doStart(); const context = {}; - await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + await start.getTrigger('MY-TRIGGER' as TriggerId)!.exec(context); + + jest.runAllTimers(); + + await waitFor(() => { + expect(executeFn).toBeCalledTimes(0); + expect(openContextMenu).toHaveBeenCalledTimes(1); + }); +}); + +test('shows a context menu when there is only one action mapped to a trigger and "alwaysShowPopup" is set', async () => { + const { setup, doStart } = uiActions; + const trigger: Trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action1 = createTestAction('test1', () => true); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action1); + + expect(openContextMenu).toHaveBeenCalledTimes(0); + + const start = doStart(); + const context = {}; + await start.getTrigger('MY-TRIGGER' as TriggerId)!.exec(context, true); jest.runAllTimers(); diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index b7039d287c6e2..ecbf4d1f7b988 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -22,6 +22,7 @@ export * from './trigger_contract'; export * from './trigger_internal'; export * from './select_range_trigger'; export * from './value_click_trigger'; +export * from './row_click_trigger'; export * from './apply_filter_trigger'; export * from './visualize_field_trigger'; export * from './visualize_geo_field_trigger'; diff --git a/src/plugins/ui_actions/public/triggers/row_click_trigger.ts b/src/plugins/ui_actions/public/triggers/row_click_trigger.ts new file mode 100644 index 0000000000000..87bca03f8c3ba --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/row_click_trigger.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { IEmbeddable } from '../../../embeddable/public'; +import { Trigger } from '.'; +import { Datatable } from '../../../expressions'; + +export const ROW_CLICK_TRIGGER = 'ROW_CLICK_TRIGGER'; + +export const rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'> = { + id: ROW_CLICK_TRIGGER, + title: i18n.translate('uiActions.triggers.rowClickTitle', { + defaultMessage: 'Table row click', + }), + description: i18n.translate('uiActions.triggers.rowClickkDescription', { + defaultMessage: 'A click on a table row', + }), +}; + +export interface RowClickContext { + embeddable?: IEmbeddable; + data: { + /** + * Row index, starting from 0, where user clicked. + */ + rowIndex: number; + + table: Datatable; + + /** + * Sorted list column IDs that were visible to the user. Useful when only + * a subset of datatable columns should be used. + */ + columns?: string[]; + }; +} diff --git a/src/plugins/ui_actions/public/triggers/trigger_contract.ts b/src/plugins/ui_actions/public/triggers/trigger_contract.ts index ba1c5a693f937..04a75cb3a53d0 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_contract.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_contract.ts @@ -49,7 +49,7 @@ export class TriggerContract { /** * Use this method to execute action attached to this trigger. */ - public readonly exec = async (context: TriggerContextMapping[T]) => { - await this.internal.execute(context); + public readonly exec = async (context: TriggerContextMapping[T], alwaysShowPopup?: boolean) => { + await this.internal.execute(context, alwaysShowPopup); }; } diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index c766b5c798ecb..fd43a020504c0 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -31,17 +31,20 @@ export class TriggerInternal { constructor(public readonly service: UiActionsService, public readonly trigger: Trigger) {} - public async execute(context: TriggerContextMapping[T]) { + public async execute(context: TriggerContextMapping[T], alwaysShowPopup?: boolean) { const triggerId = this.trigger.id; const actions = await this.service.getTriggerCompatibleActions!(triggerId, context); await Promise.all([ actions.map((action) => - this.service.executionService.execute({ - action, - context, - trigger: this.trigger, - }) + this.service.executionService.execute( + { + action, + context, + trigger: this.trigger, + }, + alwaysShowPopup + ) ), ]); } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 0be3c19fc1c4d..0266a755be926 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -22,10 +22,12 @@ import { TriggerInternal } from './triggers/trigger_internal'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, APPLY_FILTER_TRIGGER, VISUALIZE_FIELD_TRIGGER, VISUALIZE_GEO_FIELD_TRIGGER, DEFAULT_TRIGGER, + RowClickContext, } from './triggers'; import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; import type { ApplyGlobalFilterActionContext } from '../../data/public'; @@ -49,6 +51,7 @@ export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; [SELECT_RANGE_TRIGGER]: RangeSelectContext; [VALUE_CLICK_TRIGGER]: ValueClickContext; + [ROW_CLICK_TRIGGER]: RowClickContext; [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; [VISUALIZE_FIELD_TRIGGER]: VisualizeFieldContext; [VISUALIZE_GEO_FIELD_TRIGGER]: VisualizeFieldContext; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index 520ad281576cd..89e7a50ab79b0 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -74,20 +74,26 @@ export class VisEditor extends Component { this.props.eventEmitter.emit('dirtyStateChange', { isDirty: false, }); + + const extractedIndexPatterns = extractIndexPatterns(this.state.model); + if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) { + this.abortableFetchFields(extractedIndexPatterns).then((visFields) => { + this.setState({ + visFields, + extractedIndexPatterns, + }); + }); + } }, VIS_STATE_DEBOUNCE_DELAY); - debouncedFetchFields = debounce( - (extractedIndexPatterns) => { - if (this.abortControllerFetchFields) { - this.abortControllerFetchFields.abort(); - } - this.abortControllerFetchFields = new AbortController(); + abortableFetchFields = (extractedIndexPatterns) => { + if (this.abortControllerFetchFields) { + this.abortControllerFetchFields.abort(); + } + this.abortControllerFetchFields = new AbortController(); - return fetchFields(extractedIndexPatterns, this.abortControllerFetchFields.signal); - }, - VIS_STATE_DEBOUNCE_DELAY, - { leading: true } - ); + return fetchFields(extractedIndexPatterns, this.abortControllerFetchFields.signal); + }; handleChange = (partialModel) => { if (isEmpty(partialModel)) { @@ -105,16 +111,6 @@ export class VisEditor extends Component { dirty = false; } - const extractedIndexPatterns = extractIndexPatterns(nextModel); - if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) { - this.debouncedFetchFields(extractedIndexPatterns).then((visFields) => - this.setState({ - visFields, - extractedIndexPatterns, - }) - ); - } - this.setState({ dirty, model: nextModel, diff --git a/src/plugins/visualizations/public/embeddable/events.ts b/src/plugins/visualizations/public/embeddable/events.ts index 52cac59fbffaa..41e52c3ac1327 100644 --- a/src/plugins/visualizations/public/embeddable/events.ts +++ b/src/plugins/visualizations/public/embeddable/events.ts @@ -21,16 +21,19 @@ import { APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, -} from '../../../../plugins/ui_actions/public'; + ROW_CLICK_TRIGGER, +} from '../../../ui_actions/public'; export interface VisEventToTrigger { ['applyFilter']: typeof APPLY_FILTER_TRIGGER; ['brush']: typeof SELECT_RANGE_TRIGGER; ['filter']: typeof VALUE_CLICK_TRIGGER; + ['tableRowContextMenuClick']: typeof ROW_CLICK_TRIGGER; } export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = { applyFilter: APPLY_FILTER_TRIGGER, brush: SELECT_RANGE_TRIGGER, filter: VALUE_CLICK_TRIGGER, + tableRowContextMenuClick: ROW_CLICK_TRIGGER, }; diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx index 8520b84cc42ad..dcf885a817e91 100644 --- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx +++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx @@ -204,6 +204,7 @@ const VisGroup = ({ visType, onVisTypeSelected }: VisCardProps) => { target="_blank" color="text" className="visNewVisDialog__groupsCardLink" + external={false} > { + const warning = await testSubjects.exists('confirmModalTitleText'); + if (warning) { + await testSubjects.click( + ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + await this.expectExistsDashboardLandingPage(); } } diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json new file mode 100644 index 0000000000000..984b96a8bcba1 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_plugin_helpmenu", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_helpmenu"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/package.json b/test/plugin_functional/plugins/core_plugin_helpmenu/package.json new file mode 100644 index 0000000000000..bfb203d6a4d86 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/package.json @@ -0,0 +1,14 @@ +{ + "name": "core_plugin_helpmenu", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_helpmenu", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx b/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx new file mode 100644 index 0000000000000..d0b024f90c737 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountParameters } from 'kibana/public'; + +const App = ({ appName }: { appName: string }) => ( + + + + + +

Welcome to {appName}!

+
+
+
+ + + + +

{appName} home page section title

+
+
+
+ {appName} page content +
+
+
+); + +export const renderApp = (appName: string, { element }: AppMountParameters) => { + render(, element); + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts b/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts new file mode 100644 index 0000000000000..eb04224532c5a --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreHelpMenuPlugin, CoreHelpMenuPluginSetup, CoreHelpMenuPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreHelpMenuPlugin(); diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx new file mode 100644 index 0000000000000..db22038e602c8 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; + +export class CoreHelpMenuPlugin + implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'core_help_menu', + title: 'Help Menu Test App', + async mount(context, params) { + const [{ chrome, http }] = await core.getStartServices(); + + chrome.setHelpExtension({ + appName: 'HelpMenuTestApp', + links: [ + { + linkType: 'custom', + href: http.basePath.prepend('/app/management'), + content: 'Go to management', + 'data-test-subj': 'coreHelpMenuInternalLinkTest', + }, + ], + }); + + const { renderApp } = await import('./application'); + return renderApp('Help Menu Test App', params); + }, + }); + + return {}; + } + + public start() {} + public stop() {} +} + +export type CoreHelpMenuPluginSetup = ReturnType; +export type CoreHelpMenuPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json b/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json new file mode 100644 index 0000000000000..f9b0443e0a8bf --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts b/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts new file mode 100644 index 0000000000000..d82a15a0854ea --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts @@ -0,0 +1,67 @@ +/* + * 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 url from 'url'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +declare global { + interface Window { + _nonReloadedFlag?: boolean; + } +} + +const getPathWithHash = (absoluteUrl: string) => { + const parsed = url.parse(absoluteUrl); + return `${parsed.path}${parsed.hash ?? ''}`; +}; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + const setNonReloadedFlag = () => { + return browser.executeAsync(async (cb) => { + window._nonReloadedFlag = true; + cb(); + }); + }; + const wasReloaded = () => { + return browser.executeAsync(async (cb) => { + const reloaded = window._nonReloadedFlag !== true; + cb(reloaded); + }); + }; + + describe('chrome helpMenu links', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('core_help_menu'); + await setNonReloadedFlag(); + }); + + it('navigates to internal custom links without performing a full page refresh', async () => { + await testSubjects.click('helpMenuButton'); + await testSubjects.click('coreHelpMenuInternalLinkTest'); + + expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/management'); + expect(await wasReloaded()).to.eql(false); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 3d7cc751175c6..e53323d622d58 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -29,5 +29,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./application_leave_confirm')); loadTestFile(require.resolve('./application_status')); loadTestFile(require.resolve('./rendering')); + loadTestFile(require.resolve('./chrome_help_menu_links')); }); } diff --git a/x-pack/README.md b/x-pack/README.md index 73d8736124843..0210b00d8efc8 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -53,7 +53,7 @@ yarn test:mocha #### Running functional tests -For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html). +For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-tests.html#development-functional-tests). The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/security_api_integration/saml.config.ts)). diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 2165ba56428c9..c7d0153daec24 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -111,7 +111,9 @@ describe('Jira service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://siem-kibana.atlassian.net/', projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index b3c5bb4a84de5..742e68eccbb23 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -48,21 +48,22 @@ export const createExternalService = ( throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/issue`; - const capabilitiesUrl = `${url}/${CAPABILITIES_URL}`; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue`; + const capabilitiesUrl = `${urlWithoutTrailingSlash}/${CAPABILITIES_URL}`; const commentUrl = `${incidentUrl}/{issueId}/comment`; - const getIssueTypesOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; - const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; - const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; - const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; - const searchUrl = `${url}/${BASE_URL}/search`; + const getIssueTypesOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; + const getIssueTypeFieldsOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; + const getIssueTypesUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; + const getIssueTypeFieldsUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; + const searchUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/search`; const axiosInstance = axios.create({ auth: { username: email, password: apiToken }, }); const getIncidentViewURL = (key: string) => { - return `${url}/${VIEW_INCIDENT_URL}/${key}`; + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; }; const getCommentsURL = (issueId: string) => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index ecf246cb8fe3c..9362b0d4d2bad 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -81,7 +81,9 @@ describe('IBM Resilient service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' }, secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 8ec80be1e2b09..3e4873270ad7a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -34,7 +34,9 @@ describe('ServiceNow service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 57f7176e2353c..29614a4b951e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -33,14 +33,15 @@ export const createExternalService = ( throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${INCIDENT_URL}`; - const fieldsUrl = `${url}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`; const axiosInstance = axios.create({ auth: { username, password }, }); const getIncidentViewURL = (id: string) => { - return `${url}/${VIEW_INCIDENT_URL}${id}`; + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`; }; const getIncident = async (id: string) => { diff --git a/x-pack/plugins/alerts/common/alert.ts b/x-pack/plugins/alerts/common/alert.ts index 88f6090d20737..e0e73e978f775 100644 --- a/x-pack/plugins/alerts/common/alert.ts +++ b/x-pack/plugins/alerts/common/alert.ts @@ -5,6 +5,7 @@ */ import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server'; +import { AlertNotifyWhenType } from './alert_notify_when_type'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AlertTypeState = Record; @@ -68,6 +69,7 @@ export interface Alert { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; executionStatus: AlertExecutionStatus; diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts new file mode 100644 index 0000000000000..ad0b0430c6c1f --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.test.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 { validateNotifyWhenType } from './alert_notify_when_type'; + +test('validates valid notify when type', () => { + expect(validateNotifyWhenType('onActionGroupChange')).toBeUndefined(); + expect(validateNotifyWhenType('onActiveAlert')).toBeUndefined(); + expect(validateNotifyWhenType('onThrottleInterval')).toBeUndefined(); +}); +test('returns error string if input is not valid notify when type', () => { + expect(validateNotifyWhenType('randomString')).toEqual( + `string is not a valid AlertNotifyWhenType: randomString` + ); +}); diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.ts new file mode 100644 index 0000000000000..4ae4be0ac20ab --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const AlertNotifyWhenTypeValues = [ + 'onActionGroupChange', + 'onActiveAlert', + 'onThrottleInterval', +] as const; +export type AlertNotifyWhenType = typeof AlertNotifyWhenTypeValues[number]; + +export function validateNotifyWhenType(notifyWhen: string) { + if (AlertNotifyWhenTypeValues.includes(notifyWhen as AlertNotifyWhenType)) { + return; + } + return `string is not a valid AlertNotifyWhenType: ${notifyWhen}`; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 3e551facd98a0..cbdfec642fa74 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -14,6 +14,7 @@ export * from './alert_navigation'; export * from './alert_instance_summary'; export * from './builtin_action_groups'; export * from './disabled_action_groups'; +export * from './alert_notify_when_type'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts index e680f22afad8e..b428f6c1a9134 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts @@ -72,6 +72,114 @@ describe('isThrottled', () => { }); }); +describe('scheduledActionGroupOrSubgroupHasChanged()', () => { + test('should be false if no last scheduled and nothing scheduled', () => { + const alertInstance = new AlertInstance(); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group and subgroup does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from undefined to defined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from defined to undefined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be true if no last scheduled and has scheduled action', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('penguin'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('penguin', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does not change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); +}); + describe('getScheduledActionOptions()', () => { test('defaults to undefined', () => { const alertInstance = new AlertInstance(); diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index ba3a2961b96f7..8841f3115d547 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -70,6 +70,31 @@ export class AlertInstance< return false; } + scheduledActionGroupOrSubgroupHasChanged(): boolean { + if (!this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // it is considered a change when there are no previous scheduled actions + // and new scheduled actions + return true; + } + + if (this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // compare previous and new scheduled actions if both exist + return ( + !this.scheduledActionGroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) || + !this.scheduledActionSubgroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) + ); + } + + // no previous and no new scheduled actions + return false; + } + private scheduledActionGroupIsUnchanged( lastScheduledActions: NonNullable, scheduledExecutionOptions: ScheduledExecutionOptions diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index d697817be734b..b1696696b3044 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -29,8 +29,13 @@ import { AlertTaskState, AlertInstanceSummary, AlertExecutionStatusValues, + AlertNotifyWhenType, } from '../types'; -import { validateAlertTypeParams, alertExecutionStatusFromRaw } from '../lib'; +import { + validateAlertTypeParams, + alertExecutionStatusFromRaw, + getAlertNotifyWhenType, +} from '../lib'; import { GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult, InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, @@ -157,6 +162,7 @@ interface UpdateOptions { actions: NormalizedAlertAction[]; params: Record; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; }; } @@ -251,6 +257,8 @@ export class AlertsClient { const createTime = Date.now(); const { references, actions } = await this.denormalizeActions(data.actions); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); + const rawAlert: RawAlert = { ...data, ...this.apiKeyAsAlertAttributes(createdAPIKey, username), @@ -262,6 +270,7 @@ export class AlertsClient { params: validatedAlertTypeParams as RawAlert['params'], muteAll: false, mutedInstanceIds: [], + notifyWhen, executionStatus: { status: 'pending', lastExecutionDate: new Date().toISOString(), @@ -694,6 +703,7 @@ export class AlertsClient { ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); let updatedObject: SavedObject; const createAttributes = this.updateMeta({ @@ -702,6 +712,7 @@ export class AlertsClient { ...apiKeyAttributes, params: validatedAlertTypeParams as RawAlert['params'], actions, + notifyWhen, updatedBy: username, updatedAt: new Date().toISOString(), }); @@ -1326,7 +1337,7 @@ export class AlertsClient { private getPartialAlertFromRaw( id: string, - { createdAt, updatedAt, meta, scheduledTaskId, ...rawAlert }: Partial, + { createdAt, updatedAt, meta, notifyWhen, scheduledTaskId, ...rawAlert }: Partial, references: SavedObjectReference[] | undefined ): PartialAlert { // Not the prettiest code here, but if we want to use most of the @@ -1341,6 +1352,7 @@ export class AlertsClient { const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus); return { id, + notifyWhen, ...rawAlertWithoutExecutionStatus, // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index b943a21ba9bb6..4e273ee3a9e44 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -68,6 +68,7 @@ function getMockData(overwrites: Record = {}): CreateOptions['d consumer: 'bar', schedule: { interval: '10s' }, throttle: null, + notifyWhen: null, params: { bar: true, }, @@ -341,6 +342,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": null, "params": Object { "bar": true, }, @@ -389,6 +391,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -488,6 +491,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -587,6 +591,7 @@ describe('create()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -626,6 +631,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -662,6 +668,7 @@ describe('create()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -740,6 +747,426 @@ describe('create()', () => { expect(alertsClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name'); }); + test('should create alert with given notifyWhen value if notifyWhen is not null', async () => { + const data = getMockData({ notifyWhen: 'onActionGroupChange', throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onThrottleInterval if notifyWhen is null and throttle is set', async () => { + const data = getMockData({ throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onThrottleInterval", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onActiveAlert if notifyWhen is null and throttle is null', async () => { + const data = getMockData(); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); alertTypeRegistry.get.mockReturnValue({ @@ -1049,6 +1476,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], @@ -1172,6 +1600,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index 232d48e258256..ff64150dc2b79 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -85,6 +85,7 @@ describe('find()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -143,6 +144,7 @@ describe('find()', () => { "alertTypeId": "myType", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -234,6 +236,7 @@ describe('find()', () => { Object { "actions": Array [], "id": "1", + "notifyWhen": undefined, "schedule": undefined, "tags": Array [ "myTag", diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 32ac57459795e..e3e3630d379ea 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -72,6 +72,7 @@ describe('get()', () => { }, }, ], + notifyWhen: 'onActiveAlert', }, references: [ { @@ -96,6 +97,7 @@ describe('get()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts index cb878b11548b1..555c316038daa 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts @@ -80,6 +80,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject = { apiKey: null, apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index 15fb1e2ec0092..42cec57b555de 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -70,6 +70,7 @@ describe('update()', () => { scheduledTaskId: 'task-123', params: {}, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -144,6 +145,7 @@ describe('update()', () => { }, }, ], + notifyWhen: 'onActiveAlert', scheduledTaskId: 'task-123', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -185,6 +187,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -241,6 +244,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -295,6 +299,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -368,6 +373,7 @@ describe('update()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -418,6 +424,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -445,6 +452,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -479,6 +487,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -540,6 +549,7 @@ describe('update()', () => { params: { bar: true, }, + notifyWhen: 'onThrottleInterval', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), actions: [ @@ -583,6 +593,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -611,6 +622,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -645,6 +657,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -702,6 +715,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -830,6 +844,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -937,6 +952,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -998,6 +1014,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1118,6 +1135,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1149,6 +1167,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1185,6 +1204,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1220,6 +1240,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1273,6 +1294,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }); @@ -1296,6 +1318,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }) @@ -1339,6 +1362,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }); @@ -1368,6 +1392,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }) ).rejects.toThrow(); diff --git a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts index 60e733b49b041..aaa70a2594a5e 100644 --- a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts @@ -105,6 +105,7 @@ async function update(success: boolean) { tags: ['bar'], params: { bar: true }, throttle: '10s', + notifyWhen: null, actions: [], }, }); diff --git a/x-pack/plugins/alerts/server/config.test.ts b/x-pack/plugins/alerts/server/config.test.ts index bf3b30b5d2378..e4691ad6229a0 100644 --- a/x-pack/plugins/alerts/server/config.test.ts +++ b/x-pack/plugins/alerts/server/config.test.ts @@ -15,7 +15,7 @@ describe('config validation', () => { }, "invalidateApiKeysTask": Object { "interval": "5m", - "removalDelay": "5m", + "removalDelay": "1h", }, } `); diff --git a/x-pack/plugins/alerts/server/config.ts b/x-pack/plugins/alerts/server/config.ts index 41340c7dfe5fc..e53b99852c354 100644 --- a/x-pack/plugins/alerts/server/config.ts +++ b/x-pack/plugins/alerts/server/config.ts @@ -13,7 +13,7 @@ export const configSchema = schema.object({ }), invalidateApiKeysTask: schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), - removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), + removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), }), }); diff --git a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts index a53a162cc508d..d6357494546b0 100644 --- a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts @@ -648,6 +648,7 @@ const BaseAlert: SanitizedAlert = { tags: [], consumer: 'alert-consumer', throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], params: { bar: true }, diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts new file mode 100644 index 0000000000000..51eb1277a61c9 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.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. + */ + +import { getAlertNotifyWhenType } from './get_alert_notify_when_type'; + +test(`should return 'notifyWhen' value if value is set and throttle is null`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', null)).toEqual('onActionGroupChange'); +}); + +test(`should return 'notifyWhen' value if value is set and throttle is defined`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', '10m')).toEqual('onActionGroupChange'); +}); + +test(`should return 'onThrottleInterval' value if 'notifyWhen' is null and throttle is defined`, () => { + expect(getAlertNotifyWhenType(null, '10m')).toEqual('onThrottleInterval'); +}); + +test(`should return 'onActiveAlert' value if 'notifyWhen' is null and throttle is null`, () => { + expect(getAlertNotifyWhenType(null, null)).toEqual('onActiveAlert'); +}); diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts new file mode 100644 index 0000000000000..c871ba0c6e60a --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.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 { AlertNotifyWhenType } from '../types'; + +export function getAlertNotifyWhenType( + notifyWhen: AlertNotifyWhenType | null, + throttle: string | null +): AlertNotifyWhenType { + // We allow notifyWhen to be null for backwards compatibility. If it is null, determine its + // value based on whether the throttle is set to a value or null + return notifyWhen ? notifyWhen! : throttle ? 'onThrottleInterval' : 'onActiveAlert'; +} diff --git a/x-pack/plugins/alerts/server/lib/index.ts b/x-pack/plugins/alerts/server/lib/index.ts index 32047ae5cbfa8..d4662c02c0317 100644 --- a/x-pack/plugins/alerts/server/lib/index.ts +++ b/x-pack/plugins/alerts/server/lib/index.ts @@ -7,6 +7,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_duration'; export { LicenseState } from './license_state'; export { validateAlertTypeParams } from './validate_alert_type_params'; +export { getAlertNotifyWhenType } from './get_alert_notify_when_type'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; export { executionStatusFromState, diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index fee7901c4ea55..48fd2e12336a8 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -24,7 +24,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); @@ -73,7 +73,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); @@ -124,7 +124,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/alerts/server/routes/create.test.ts b/x-pack/plugins/alerts/server/routes/create.test.ts index 51c5d2525631d..90c075f129b8c 100644 --- a/x-pack/plugins/alerts/server/routes/create.test.ts +++ b/x-pack/plugins/alerts/server/routes/create.test.ts @@ -36,6 +36,7 @@ describe('createAlertRoute', () => { bar: true, }, throttle: '30s', + notifyWhen: 'onActionGroupChange', actions: [ { group: 'default', @@ -56,6 +57,7 @@ describe('createAlertRoute', () => { apiKey: '', apiKeyOwner: '', mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', createdAt, updatedAt, id: '123', @@ -110,6 +112,7 @@ describe('createAlertRoute', () => { "alertTypeId": "1", "consumer": "bar", "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/routes/create.ts b/x-pack/plugins/alerts/server/routes/create.ts index 91a81f6d84b71..f54aec8fe0cf0 100644 --- a/x-pack/plugins/alerts/server/routes/create.ts +++ b/x-pack/plugins/alerts/server/routes/create.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { Alert, BASE_ALERT_API_PATH } from '../types'; +import { Alert, AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../types'; export const bodySchema = schema.object({ name: schema.string(), @@ -38,6 +38,7 @@ export const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -61,7 +62,8 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const alert = req.body; - const alertRes: Alert = await alertsClient.create({ data: alert }); + const notifyWhen = alert?.notifyWhen ? (alert.notifyWhen as AlertNotifyWhenType) : null; + const alertRes: Alert = await alertsClient.create({ data: { ...alert, notifyWhen } }); return res.ok({ body: alertRes, }); diff --git a/x-pack/plugins/alerts/server/routes/get.test.ts b/x-pack/plugins/alerts/server/routes/get.test.ts index c60177e90b79d..51ac64bbef182 100644 --- a/x-pack/plugins/alerts/server/routes/get.test.ts +++ b/x-pack/plugins/alerts/server/routes/get.test.ts @@ -46,6 +46,7 @@ describe('getAlertRoute', () => { tags: ['foo'], enabled: true, muteAll: false, + notifyWhen: 'onActionGroupChange', createdBy: '', updatedBy: '', apiKey: '', diff --git a/x-pack/plugins/alerts/server/routes/update.test.ts b/x-pack/plugins/alerts/server/routes/update.test.ts index dedb08a9972c2..89619bd853707 100644 --- a/x-pack/plugins/alerts/server/routes/update.test.ts +++ b/x-pack/plugins/alerts/server/routes/update.test.ts @@ -10,6 +10,7 @@ import { mockLicenseState } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertNotifyWhenType } from '../../common'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -41,6 +42,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, }; it('updates an alert with proper parameters', async () => { @@ -78,6 +80,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange', }, }, ['ok'] @@ -100,6 +103,7 @@ describe('updateAlertRoute', () => { }, ], "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "otherField": false, }, diff --git a/x-pack/plugins/alerts/server/routes/update.ts b/x-pack/plugins/alerts/server/routes/update.ts index 9b2fe9a43810b..96b3156525f79 100644 --- a/x-pack/plugins/alerts/server/routes/update.ts +++ b/x-pack/plugins/alerts/server/routes/update.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../../common'; const paramSchema = schema.object({ id: schema.string(), @@ -39,6 +39,7 @@ const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -62,11 +63,19 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - const { name, actions, params, schedule, tags, throttle } = req.body; + const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; return res.ok({ body: await alertsClient.update({ id, - data: { name, actions, params, schedule, tags, throttle }, + data: { + name, + actions, + params, + schedule, + tags, + throttle, + notifyWhen: notifyWhen as AlertNotifyWhenType, + }, }), }); }) diff --git a/x-pack/plugins/alerts/server/saved_objects/mappings.json b/x-pack/plugins/alerts/server/saved_objects/mappings.json index f40a7d9075eed..f0c5c28ecaeaf 100644 --- a/x-pack/plugins/alerts/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerts/server/saved_objects/mappings.json @@ -74,6 +74,9 @@ "throttle": { "type": "keyword" }, + "notifyWhen": { + "type": "keyword" + }, "muteAll": { "type": "boolean" }, diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index a4cbc18e13b47..abbce7a009b99 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -277,6 +277,7 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.updated_at, + notifyWhen: 'onActiveAlert', }, }); }); @@ -289,6 +290,33 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is null', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({}); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is set', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({ throttle: '5m' }); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onThrottleInterval', }, }); }); diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.ts index d8ebced03c5a6..1b9c5dac23b88 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.ts @@ -37,15 +37,18 @@ export function getMigrations( ) ); - const migrationAlertUpdatedAtDate = encryptedSavedObjects.createMigration( - // migrate all documents in 7.11 in order to add the "updatedAt" field + const migrationAlertUpdatedAtAndNotifyWhen = encryptedSavedObjects.createMigration< + RawAlert, + RawAlert + >( + // migrate all documents in 7.11 in order to add the "updatedAt" and "notifyWhen" fields (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(setAlertUpdatedAtDate) + pipeMigrations(setAlertUpdatedAtDate, setNotifyWhen) ); return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtDate, '7.11.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), }; } @@ -79,6 +82,19 @@ const setAlertUpdatedAtDate = ( }; }; +const setNotifyWhen = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + const notifyWhen = doc.attributes.throttle ? 'onThrottleInterval' : 'onActiveAlert'; + return { + ...doc, + attributes: { + ...doc.attributes, + notifyWhen, + }, + }; +}; + const consumersToChange: Map = new Map( Object.entries({ alerting: 'alerts', diff --git a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts index cf0dd9d135e27..09236ec5e0ad1 100644 --- a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts @@ -27,6 +27,7 @@ const alert: SanitizedAlert = { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index d4ea74c008b49..d3d0a54417ee3 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -92,6 +92,7 @@ describe('Task Runner', () => { updatedAt: new Date('2019-02-12T21:01:22.479Z'), throttle: null, muteAll: false, + notifyWhen: 'onActiveAlert', enabled: true, alertTypeId: alertType.id, apiKey: '', @@ -533,6 +534,188 @@ describe('Task Runner', () => { ); }); + test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert instance state does not change', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { date: '1970-01-01T00:00:00.000Z', group: 'default' }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + }, + ], + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "event": Object { + "action": "execute", + "outcome": "success", + }, + "kibana": Object { + "alerting": Object { + "status": "active", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "alert executed: test:1: 'alert-name'", + }, + ], + ] + `); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { lastScheduledActions: { group: 'newGroup', date: new Date().toISOString() } }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices + .alertInstanceFactory('1') + .scheduleActionsWithSubGroup('default', 'subgroup1'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + group: 'default', + subgroup: 'newSubgroup', + date: new Date().toISOString(), + }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + test('includes the apiKey in the request used to initialize the actionsClient', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 6bc6271dd6d5c..2073528f2c75e 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -171,7 +171,16 @@ export class TaskRunner { spaceId: string, event: Event ): Promise { - const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; + const { + throttle, + notifyWhen, + muteAll, + mutedInstanceIds, + name, + tags, + createdBy, + updatedBy, + } = alert; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, @@ -257,24 +266,39 @@ export class TaskRunner { alertLabel, }); + const instancesToExecute = + notifyWhen === 'onActionGroupChange' + ? Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const shouldExecuteAction = alertInstance.scheduledActionGroupOrSubgroupHasChanged(); + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is active but action group has not changed` + ); + } + return shouldExecuteAction; + } + ) + : Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const throttled = alertInstance.isThrottled(throttle); + const muted = mutedInstanceIdsSet.has(alertInstanceName); + const shouldExecuteAction = !throttled && !muted; + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ + muted ? 'muted' : 'throttled' + }` + ); + } + return shouldExecuteAction; + } + ); + await Promise.all( - Object.entries(instancesWithScheduledActions) - .filter(([alertInstanceName, alertInstance]: [string, AlertInstance]) => { - const throttled = alertInstance.isThrottled(throttle); - const muted = mutedInstanceIdsSet.has(alertInstanceName); - const shouldExecuteAction = !throttled && !muted; - if (!shouldExecuteAction) { - this.logger.debug( - `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ - muted ? 'muted' : 'throttled' - }` - ); - } - return shouldExecuteAction; - }) - .map(([id, alertInstance]: [string, AlertInstance]) => - this.executeAlertInstance(id, alertInstance, executionHandler) - ) + instancesToExecute.map(([id, alertInstance]: [string, AlertInstance]) => + this.executeAlertInstance(id, alertInstance, executionHandler) + ) ); } else { this.logger.debug(`no scheduling of actions for alert ${alertLabel}: alert is muted.`); diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 8898123506755..a5aee8dbf3b60 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -28,6 +28,7 @@ import { AlertExecutionStatuses, AlertExecutionStatusErrorReasons, AlertsHealth, + AlertNotifyWhenType, } from '../common'; export type WithoutQueryAndParams = Pick>; @@ -152,6 +153,7 @@ export interface RawAlert extends SavedObjectAttributes { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; meta?: AlertMeta; @@ -162,6 +164,7 @@ export type AlertInfoParams = Pick< RawAlert, | 'params' | 'throttle' + | 'notifyWhen' | 'muteAll' | 'mutedInstanceIds' | 'name' diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index e978b6d55251b..90e98e64814a1 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -110,7 +110,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'critical', value: 'critical' }, { text: 'off', value: 'off' }, ], - includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs'], + includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs', 'go'], }, // Recording @@ -235,7 +235,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'Sometimes it is necessary to sanitize, i.e., remove, sensitive data sent to Elastic APM. This config accepts a list of wildcard patterns of field names which should be sanitized. These apply to HTTP headers (including cookies) and `application/x-www-form-urlencoded` data (POST form fields). The query string and the captured request body (such as `application/json` data) will not get sanitized.', } ), - includeAgents: ['java', 'python', 'go'], + includeAgents: ['java', 'python', 'go', 'dotnet', 'nodejs'], }, // Ignore transactions based on URLs @@ -254,6 +254,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Used to restrict requests to certain URLs from being instrumented. This config accepts a comma-separated list of wildcard patterns of URL paths that should be ignored. When an incoming HTTP request is detected, its request path will be tested against each element in this list. For example, adding `/home/index` to this list would match and remove instrumentation from `http://localhost/home/index` as well as `http://whatever.com/home/index?value1=123`', } ), - includeAgents: ['java'], + includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby'], }, ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index abe353ab8f3a3..88cf3e288abf1 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -45,6 +45,7 @@ describe('filterByAgent', () => { expect(getSettingKeysForAgent('go')).toEqual([ 'capture_body', 'capture_headers', + 'log_level', 'recording', 'sanitize_field_names', 'span_frames_min_duration', @@ -102,6 +103,8 @@ describe('filterByAgent', () => { expect(getSettingKeysForAgent('nodejs')).toEqual([ 'capture_body', 'log_level', + 'sanitize_field_names', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -117,6 +120,7 @@ describe('filterByAgent', () => { 'recording', 'sanitize_field_names', 'span_frames_min_duration', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -128,8 +132,10 @@ describe('filterByAgent', () => { 'capture_headers', 'log_level', 'recording', + 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -144,6 +150,7 @@ describe('filterByAgent', () => { 'log_level', 'recording', 'span_frames_min_duration', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); diff --git a/x-pack/plugins/apm/common/latency_aggregation_types.ts b/x-pack/plugins/apm/common/latency_aggregation_types.ts new file mode 100644 index 0000000000000..6a9e561142429 --- /dev/null +++ b/x-pack/plugins/apm/common/latency_aggregation_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; + +export enum LatencyAggregationType { + avg = 'avg', + p99 = 'p99', + p95 = 'p95', +} + +export const latencyAggregationTypeRt = t.union([ + t.literal('avg'), + t.literal('p95'), + t.literal('p99'), +]); diff --git a/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md b/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md index 467090fb3c91b..88e434d07d38f 100644 --- a/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md +++ b/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md @@ -1,6 +1,6 @@ ### Updating functional tests archives -Some of our API tests use an archive generated by the [`esarchiver`](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html) script. Updating the main archive (`apm_8.0.0`) is a scripted process, where a 30m snapshot is downloaded from a cluster running the [APM Integration Testing server](https://github.com/elastic/apm-integration-testing). The script will copy the generated archives into the `fixtures/es_archiver` folders of our test suites (currently `basic` and `trial`). It will also generate a file that contains metadata about the archive, that can be imported to get the time range of the snapshot. +Some of our API tests use an archive generated by the [`esarchiver`](https://www.elastic.co/guide/en/kibana/current/development-tests.html#development-functional-tests) script. Updating the main archive (`apm_8.0.0`) is a scripted process, where a 30m snapshot is downloaded from a cluster running the [APM Integration Testing server](https://github.com/elastic/apm-integration-testing). The script will copy the generated archives into the `fixtures/es_archiver` folders of our test suites (currently `basic` and `trial`). It will also generate a file that contains metadata about the archive, that can be imported to get the time range of the snapshot. Usage: `node x-pack/plugins/apm/scripts/create-functional-tests-archive --es-url=https://admin:changeme@localhost:9200 --kibana-url=https://localhost:5601` diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index 0ecda7a113de7..152186a8a738a 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,3 +1,3 @@ module.exports = { - "__version": "5.4.0" + "__version": "6.0.1" } diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts index 342f3e0aa5267..e558d1ef9c0bc 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts @@ -6,13 +6,13 @@ import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; /** The default time in ms to wait for a Cypress command to complete */ Given(`a user clicks the page load breakdown filter`, () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); const breakDownBtn = cy.get( '[data-test-subj=pldBreakdownFilter]', DEFAULT_TIMEOUT @@ -27,7 +27,7 @@ When(`the user selected the breakdown`, () => { }); Then(`breakdown series should appear in chart`, () => { - cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.exist'); cy.get('[data-cy=pageLoadDist]').within(() => { cy.get('div.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts index 0b26c6de66f4b..d8d8c7c3a62e9 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts @@ -5,6 +5,7 @@ */ import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; /** * Verifies the behavior of the client metrics component @@ -17,15 +18,14 @@ export function verifyClientMetrics( ) { const clientMetricsSelector = '[data-cy=client-metrics] .euiStat__title'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); if (checkTitleStatus) { cy.get('.euiStat__title', DEFAULT_TIMEOUT).should('be.visible'); - cy.get('.euiSelect-isLoading').should('not.be.visible'); + cy.get('.euiSelect-isLoading').should('not.exist'); } - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(clientMetricsSelector).eq(0).should('have.text', metrics[0]); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 452d8b719b3cb..5207ea39c959f 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -7,6 +7,7 @@ import { Given, Then } from 'cypress-cucumber-preprocessor/steps'; import { loginAndWaitForPage } from '../../../integration/helpers'; import { verifyClientMetrics } from './client_metrics_helper'; +import { waitForLoadingToFinish } from './utils'; /** The default time in ms to wait for a Cypress command to complete */ export const DEFAULT_TIMEOUT = { timeout: 60 * 1000 }; @@ -36,9 +37,9 @@ Then(`should display percentile for page load chart`, () => { cy.get('.euiLoadingChart', DEFAULT_TIMEOUT).should('be.visible'); - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(pMarkers).eq(0).should('have.text', '50th'); @@ -52,21 +53,19 @@ Then(`should display percentile for page load chart`, () => { Then(`should display chart legend`, () => { const chartLegend = 'div.echLegendItem__label'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiLoadingChart').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiLoadingChart').should('not.exist'); cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); }); Then(`should display tooltip on hover`, () => { - cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.exist'); const pMarkers = '[data-cy=percentile-markers] span.euiToolTipAnchor'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiLoadingChart').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiLoadingChart').should('not.exist'); const marker = cy.get(pMarkers, DEFAULT_TIMEOUT).eq(0); marker.invoke('show'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts index 88287286c66c5..9aeddad686385 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts @@ -7,11 +7,11 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; import { verifyClientMetrics } from './client_metrics_helper'; +import { waitForLoadingToFinish } from './utils'; When(/^the user filters by "([^"]*)"$/, (filterName) => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(`#local-filter-${filterName}`).click(); cy.get(`#local-filter-popover-${filterName}`, DEFAULT_TIMEOUT).within(() => { @@ -51,9 +51,8 @@ When(/^the user filters by "([^"]*)"$/, (filterName) => { }); Then(/^it filters the client metrics "([^"]*)"$/, (filterName) => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); const data = filterName === 'os' diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts index 9e10e2fd59914..bc53de0bac6a7 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts @@ -9,8 +9,8 @@ import { DEFAULT_TIMEOUT } from './csm_dashboard'; import { getDataTestSubj } from './utils'; Then(`it displays list of relevant js errors`, () => { - cy.get('.euiBasicTable-loading').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get('.euiBasicTable-loading').should('not.exist'); + cy.get('.euiStat__title-isLoading').should('not.exist'); getDataTestSubj('uxJsErrorsTotal').should('have.text', 'Total errors112'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts index 44802bbce6208..80b90422366d5 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts @@ -6,11 +6,10 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { verifyClientMetrics } from './client_metrics_helper'; -import { getDataTestSubj } from './utils'; +import { getDataTestSubj, waitForLoadingToFinish } from './utils'; When('the user changes the selected percentile', () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); getDataTestSubj('uxPercentileSelect').select('95'); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts index 609d0d18f5bc8..5c0e8c6238238 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts @@ -7,10 +7,10 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { verifyClientMetrics } from './client_metrics_helper'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; When('the user changes the selected service name', () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get(`[data-cy=serviceNameFilter]`, DEFAULT_TIMEOUT).select('client'); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts index 3dc98625baf85..cc9dc177d57a0 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts @@ -6,18 +6,18 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; When(`a user clicks inside url search field`, () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get('span[data-cy=csmUrlFilter]', DEFAULT_TIMEOUT).within(() => { cy.get('input.euiFieldSearch').click(); }); }); Then(`it displays top pages in the suggestion popover`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => { const listOfUrls = cy.get('li.euiSelectableListItem'); @@ -38,17 +38,17 @@ Then(`it displays top pages in the suggestion popover`, () => { }); When(`a user enters a query in url search field`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('[data-cy=csmUrlFilter]').within(() => { cy.get('input.euiSelectableSearch').type('cus'); }); - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); }); Then(`it should filter results based on query`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => { const listOfUrls = cy.get('li.euiSelectableListItem'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts index 87b3a1d70d073..0819a27ff16cb 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts @@ -6,9 +6,12 @@ import { DEFAULT_TIMEOUT } from './csm_dashboard'; +export function waitForLoadingToFinish() { + cy.get('[data-test-subj=globalLoadingIndicator-hidden]', DEFAULT_TIMEOUT); +} + export function getDataTestSubj(dataTestSubj: string) { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); return cy.get(`[data-test-subj=${dataTestSubj}]`, DEFAULT_TIMEOUT); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 309cde4dd9f65..8ab09eccd9bdb 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -7,10 +7,9 @@ import { Axis, Chart, - ElementClickListener, - GeometryValue, HistogramBarSeries, Position, + ProjectionClickListener, RectAnnotation, ScaleType, Settings, @@ -24,11 +23,11 @@ import d3 from 'd3'; import { isEmpty } from 'lodash'; import React from 'react'; import { ValuesType } from 'utility-types'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { useTheme } from '../../../../../../observability/public'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import type { IUrlParams } from '../../../../context/url_params_context/types'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { unit } from '../../../../style/variables'; import { ChartContainer } from '../../../shared/charts/chart_container'; import { EmptyMessage } from '../../../shared/EmptyMessage'; @@ -145,10 +144,9 @@ export function TransactionDistribution({ }, }; - const onBarClick: ElementClickListener = (elements) => { - const chartPoint = elements[0][0] as GeometryValue; + const onBarClick: ProjectionClickListener = ({ x }) => { const clickedBucket = distribution?.buckets.find((bucket) => { - return bucket.key === chartPoint.x; + return bucket.key === x; }); if (clickedBucket) { onBucketClick(clickedBucket); @@ -194,10 +192,11 @@ export function TransactionDistribution({ {selectedBucket && ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 1f6a9276b5d27..1a86e7baac83f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -15,12 +15,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; +import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { SearchBar } from '../../shared/search_bar'; -import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; +import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart'; import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table'; @@ -43,99 +45,96 @@ export function ServiceOverview({ useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); return ( - - - - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.latencyChartTitle', - { - defaultMessage: 'Latency', - } - )} -

-
-
-
- - - - - - - - - - - - - - - {!isRumAgentName(agentName) && ( + + + + + + + + + + + + + + + + + + + + + + + + + {!isRumAgentName(agentName) && ( + + + + )} + + + + + + + + + - + + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle', + { + defaultMessage: 'Instances latency distribution', + } + )} +

+
+
+
+ + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.instancesTableTitle', + { + defaultMessage: 'Instances', + } + )} +

+
+
- )} - - - - - -
-
- - - - - - - - - - - - - - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle', - { - defaultMessage: 'Instances latency distribution', - } - )} -

-
-
-
- - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesTableTitle', - { - defaultMessage: 'Instances', - } - )} -

-
-
-
-
-
-
-
-
+
+
+
+
+
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index e4260a2533d36..886c95cde7248 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -15,6 +15,8 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { EuiToolTip } from '@elastic/eui'; import { ValuesType } from 'utility-types'; +import { useLatencyAggregationType } from '../../../../hooks/use_latency_Aggregation_type'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { asDuration, asPercent, @@ -64,9 +66,39 @@ const StyledTransactionDetailLink = styled(TransactionDetailLink)` ${truncate('100%')} `; -export function ServiceOverviewTransactionsTable(props: Props) { - const { serviceName } = props; +function getLatencyAggregationTypeLabel( + latencyAggregationType?: LatencyAggregationType +) { + switch (latencyAggregationType) { + case 'p95': { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', + { + defaultMessage: 'Latency (95th)', + } + ); + } + case 'p99': { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', + { + defaultMessage: 'Latency (99th)', + } + ); + } + default: { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', + { + defaultMessage: 'Latency (avg.)', + } + ); + } + } +} +export function ServiceOverviewTransactionsTable({ serviceName }: Props) { + const latencyAggregationType = useLatencyAggregationType(); const { uiFilters, urlParams: { start, end }, @@ -94,7 +126,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { }, status, } = useFetcher(() => { - if (!start || !end) { + if (!start || !end || !latencyAggregationType) { return; } @@ -112,6 +144,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { pageIndex: tableOptions.pageIndex, sortField: tableOptions.sort.field, sortDirection: tableOptions.sort.direction, + latencyAggregationType, }, }, }).then((response) => { @@ -135,6 +168,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { tableOptions.pageIndex, tableOptions.sort.field, tableOptions.sort.direction, + latencyAggregationType, ]); const { @@ -170,12 +204,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { }, { field: 'latency', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency', - { - defaultMessage: 'Latency', - } - ), + name: getLatencyAggregationTypeLabel(latencyAggregationType), width: px(unit * 10), render: (_, { latency }) => { return ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 98046193e3807..111dd5d00a978 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -37,7 +37,7 @@ export const PERSISTENT_APM_PARAMS: Array = [ */ export function useAPMHref( path: string, - persistentFilters: Array = PERSISTENT_APM_PARAMS + persistentFilters: Array = [] ) { const { urlParams } = useUrlParams(); const { basePath } = useApmPluginContext().core.http; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx index 92ff1b8a68ac0..959f60bfa6439 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx @@ -15,6 +15,7 @@ const persistedFilters: Array = [ 'containerId', 'podName', 'serviceVersion', + 'latencyAggregationType', ]; export function useTransactionOverviewHref(serviceName: string) { diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx index 78e409bb4558c..1f74f1f9890cf 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx @@ -9,19 +9,34 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; +import { APMQueryParams } from '../url_helpers'; import { APMLink, APMLinkExtendProps, useAPMHref } from './APMLink'; interface ServiceOverviewLinkProps extends APMLinkExtendProps { serviceName: string; } +const persistedFilters: Array = [ + 'latencyAggregationType', +]; + export function useServiceOverviewHref(serviceName: string) { - return useAPMHref(`/services/${serviceName}/overview`); + return useAPMHref(`/services/${serviceName}/overview`, persistedFilters); } export function ServiceOverviewLink({ serviceName, ...rest }: ServiceOverviewLinkProps) { - return ; + const { urlParams } = useUrlParams(); + + return ( + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts index 9da26b3fcefac..aa3881b81cc3f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -6,6 +6,7 @@ import { History } from 'history'; import { parse, stringify } from 'query-string'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { url } from '../../../../../../../src/plugins/kibana_utils/public'; import { LocalUIFilterName } from '../../../../common/ui_filter'; @@ -84,6 +85,7 @@ export type APMQueryParams = { refreshInterval?: string | number; searchTerm?: string; percentile?: 50 | 75 | 90 | 95 | 99; + latencyAggregationType?: LatencyAggregationType; } & { [key in LocalUIFilterName]?: string }; // forces every value of T[K] to be type: string diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx new file mode 100644 index 0000000000000..be7c6babe8e00 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; +import { getDurationFormatter } from '../../../../../common/utils/formatters'; +import { useLicenseContext } from '../../../../context/license/use_license_context'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; +import { TimeseriesChart } from '../../../shared/charts/timeseries_chart'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../../../shared/charts/transaction_charts/helper'; +import { MLHeader } from '../../../shared/charts/transaction_charts/ml_header'; +import * as urlHelpers from '../../../shared/Links/url_helpers'; + +interface Props { + height?: number; +} + +const options: Array<{ value: LatencyAggregationType; text: string }> = [ + { value: LatencyAggregationType.avg, text: 'Average' }, + { value: LatencyAggregationType.p95, text: '95th percentile' }, + { value: LatencyAggregationType.p99, text: '99th percentile' }, +]; + +export function LatencyChart({ height }: Props) { + const history = useHistory(); + const { urlParams } = useUrlParams(); + const { latencyAggregationType } = urlParams; + const license = useLicenseContext(); + + const { + latencyChartsData, + latencyChartsStatus, + } = useTransactionLatencyChartsFetcher(); + + const { latencyTimeseries, anomalyTimeseries, mlJobId } = latencyChartsData; + + const latencyMaxY = getMaxY(latencyTimeseries); + const latencyFormatter = getDurationFormatter(latencyMaxY); + + return ( + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.latencyChartTitle', + { + defaultMessage: 'Latency', + } + )} +

+
+
+ + { + urlHelpers.push(history, { + query: { + latencyAggregationType: nextOption.target.value, + }, + }); + }} + /> + +
+
+ + + +
+
+ + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts index 850e5d9a16112..e92ecd2aeefd8 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts @@ -10,7 +10,7 @@ import { getMaxY, } from './helper'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; import { getDurationFormatter, toMicroseconds, @@ -51,7 +51,7 @@ describe('transaction chart helper', () => { it('returns zero for invalid y coordinate', () => { const timeSeries = ([ { data: [{ x: 1 }, { x: 2 }, { x: 3, y: -1 }] }, - ] as unknown) as TimeSeries[]; + ] as unknown) as Array>; expect(getMaxY(timeSeries)).toEqual(0); }); it('returns the max y coordinate', () => { @@ -63,7 +63,7 @@ describe('transaction chart helper', () => { { x: 3, y: 1 }, ], }, - ] as unknown) as TimeSeries[]; + ] as unknown) as Array>; expect(getMaxY(timeSeries)).toEqual(10); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx index db245792982c3..4d2a60c494178 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten } from 'lodash'; -import { TimeFormatter } from '../../../../../common/utils/formatters'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { TimeFormatter } from '../../../../../common/utils/formatters'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; export function getResponseTimeTickFormatter(formatter: TimeFormatter) { return (t: number) => { @@ -24,12 +23,11 @@ export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { }; } -export function getMaxY(timeSeries: TimeSeries[]) { - const coordinates = flatten( - timeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); +export function getMaxY(timeSeries?: Array>) { + if (timeSeries) { + const coordinates = timeSeries.flatMap((serie) => serie.data); + const numbers = coordinates.map((c) => (c.y ? c.y : 0)); + return Math.max(...numbers, 0); + } + return 0; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index f43019a5101d0..0ea0ee3e5a456 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -6,7 +6,6 @@ import { EuiFlexGrid, - EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, @@ -14,44 +13,28 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - TRANSACTION_PAGE_LOAD, - TRANSACTION_REQUEST, - TRANSACTION_ROUTE_CHANGE, -} from '../../../../../common/transaction_types'; +import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { asTransactionRate } from '../../../../../common/utils/formatters'; import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context'; import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context'; -import { LicenseContext } from '../../../../context/license/license_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; import { useTransactionThroughputChartsFetcher } from '../../../../hooks/use_transaction_throughput_chart_fetcher'; +import { LatencyChart } from '../latency_chart'; import { TimeseriesChart } from '../timeseries_chart'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; -import { getResponseTimeTickFormatter } from './helper'; -import { MLHeader } from './ml_header'; -import { useFormatter } from './use_formatter'; export function TransactionCharts() { const { urlParams } = useUrlParams(); const { transactionType } = urlParams; - const { - latencyChartsData, - latencyChartsStatus, - } = useTransactionLatencyChartsFetcher(); - const { throughputChartsData, throughputChartsStatus, } = useTransactionThroughputChartsFetcher(); - const { latencyTimeseries, anomalyTimeseries, mlJobId } = latencyChartsData; const { throughputTimeseries } = throughputChartsData; - const { formatter, toggleSerie } = useFormatter(latencyTimeseries); - return ( <> @@ -59,35 +42,7 @@ export function TransactionCharts() { - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - { - if (serie) { - toggleSerie(serie); - } - }} - /> + @@ -137,29 +92,3 @@ function tpmLabel(type?: string) { } ); } - -function responseTimeLabel(type?: string) { - switch (type) { - case TRANSACTION_PAGE_LOAD: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.pageLoadTimesLabel', - { - defaultMessage: 'Page load times', - } - ); - case TRANSACTION_ROUTE_CHANGE: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.routeChangeTimesLabel', - { - defaultMessage: 'Route change times', - } - ); - default: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.transactionDurationLabel', - { - defaultMessage: 'Transaction duration', - } - ); - } -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx deleted file mode 100644 index 958a5db6b66c9..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx +++ /dev/null @@ -1,83 +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 { SeriesIdentifier } from '@elastic/charts'; -import { renderHook } from '@testing-library/react-hooks'; -import { act } from 'react-test-renderer'; -import { toMicroseconds } from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; -import { useFormatter } from './use_formatter'; - -describe('useFormatter', () => { - const timeSeries = ([ - { - title: 'avg', - data: [ - { x: 1, y: toMicroseconds(11, 'minutes') }, - { x: 2, y: toMicroseconds(1, 'minutes') }, - { x: 3, y: toMicroseconds(60, 'seconds') }, - ], - }, - { - title: '95th percentile', - data: [ - { x: 1, y: toMicroseconds(120, 'seconds') }, - { x: 2, y: toMicroseconds(1, 'minutes') }, - { x: 3, y: toMicroseconds(60, 'seconds') }, - ], - }, - { - title: '99th percentile', - data: [ - { x: 1, y: toMicroseconds(60, 'seconds') }, - { x: 2, y: toMicroseconds(5, 'minutes') }, - { x: 3, y: toMicroseconds(100, 'seconds') }, - ], - }, - ] as unknown) as TimeSeries[]; - - it('returns new formatter when disabled series state changes', () => { - const { result } = renderHook(() => useFormatter(timeSeries)); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('120 s'); - }); - - it('falls back to the first formatter when disabled series is empty', () => { - const { result } = renderHook(() => useFormatter(timeSeries)); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('120 s'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts deleted file mode 100644 index 1475ec2934e95..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts +++ /dev/null @@ -1,50 +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 { SeriesIdentifier } from '@elastic/charts'; -import { omit } from 'lodash'; -import { useState } from 'react'; -import { - getDurationFormatter, - TimeFormatter, -} from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; -import { getMaxY } from './helper'; - -export const useFormatter = ( - series?: TimeSeries[] -): { - formatter: TimeFormatter; - toggleSerie: (disabledSerie: SeriesIdentifier) => void; -} => { - const [disabledSeries, setDisabledSeries] = useState< - Record - >({}); - - const visibleSeries = series?.filter( - (serie) => disabledSeries[serie.title] === undefined - ); - - const maxY = getMaxY(visibleSeries || series || []); - const formatter = getDurationFormatter(maxY); - - const toggleSerie = ({ specId }: SeriesIdentifier) => { - if (disabledSeries[specId] !== undefined) { - setDisabledSeries((prevState) => { - return omit(prevState, specId); - }); - } else { - setDisabledSeries((prevState) => { - return { ...prevState, [specId]: 0 }; - }); - } - }; - - return { - formatter, - toggleSerie, - }; -}; diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index ccc106cc00c9e..ee0ea7f601f62 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -5,19 +5,19 @@ */ import { Location } from 'history'; -import { IUrlParams } from './types'; +import { pickKeys } from '../../../common/utils/pick_keys'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { toQuery } from '../../components/shared/Links/url_helpers'; import { - removeUndefinedProps, - getStart, getEnd, + getStart, + removeUndefinedProps, toBoolean, toNumber, toString, } from './helpers'; -import { toQuery } from '../../components/shared/Links/url_helpers'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; -import { pickKeys } from '../../../common/utils/pick_keys'; +import { IUrlParams } from './types'; type TimeUrlParams = Pick< IUrlParams, @@ -48,6 +48,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { environment, searchTerm, percentile, + latencyAggregationType, } = query; const localUIFilters = pickKeys(query, ...localUIFilterNames); @@ -77,6 +78,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { transactionType, searchTerm: toString(searchTerm), percentile: toNumber(percentile), + latencyAggregationType, // ui filters environment, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 68ef73e7b7bc6..d792c93b7d0dc 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -28,4 +28,5 @@ export type IUrlParams = { pageSize?: number; searchTerm?: string; percentile?: number; + latencyAggregationType?: string; } & Partial>; diff --git a/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts new file mode 100644 index 0000000000000..901877ca67460 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; +import { UIFilters } from '../../typings/ui_filters'; +import { IUrlParams } from '../context/url_params_context/types'; +import * as urlParams from '../context/url_params_context/use_url_params'; +import { useLatencyAggregationType } from './use_latency_Aggregation_type'; + +describe('useLatencyAggregationType', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + it('returns avg when no value was given', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: undefined } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.avg); + }); + + it('returns avg when no value does not match any of the availabe options', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: 'invalid_type' } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.avg); + }); + + it('returns the value in the url', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: 'p95' } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.p95); + }); +}); diff --git a/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts new file mode 100644 index 0000000000000..72d07c9e4c22c --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; +import { useUrlParams } from '../context/url_params_context/use_url_params'; + +export function useLatencyAggregationType(): LatencyAggregationType { + const { + urlParams: { latencyAggregationType }, + } = useUrlParams(); + + if (!latencyAggregationType) { + return LatencyAggregationType.avg; + } + + if (latencyAggregationType in LatencyAggregationType) { + return latencyAggregationType as LatencyAggregationType; + } + + return LatencyAggregationType.avg; +} diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 2434ec9c977ed..7b1e7b06ac283 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -8,20 +8,30 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; +import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; import { getLatencyChartSelector } from '../selectors/latency_chart_selectors'; import { useTheme } from './use_theme'; +import { useLatencyAggregationType } from './use_latency_Aggregation_type'; export function useTransactionLatencyChartsFetcher() { const { serviceName } = useParams<{ serviceName?: string }>(); + const { transactionType } = useApmServiceContext(); + const latencyAggregationType = useLatencyAggregationType(); const theme = useTheme(); const { - urlParams: { transactionType, start, end, transactionName }, + urlParams: { start, end, transactionName }, uiFilters, } = useUrlParams(); const { data, error, status } = useFetcher( (callApmApi) => { - if (serviceName && start && end) { + if ( + serviceName && + start && + end && + transactionType && + latencyAggregationType + ) { return callApmApi({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', @@ -33,17 +43,33 @@ export function useTransactionLatencyChartsFetcher() { transactionType, transactionName, uiFilters: JSON.stringify(uiFilters), + latencyAggregationType, }, }, }); } }, - [serviceName, start, end, transactionName, transactionType, uiFilters] + [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + latencyAggregationType, + ] ); const memoizedData = useMemo( - () => getLatencyChartSelector({ latencyChart: data, theme }), - [data, theme] + () => + getLatencyChartSelector({ + latencyChart: data, + theme, + latencyAggregationType, + }), + // It should only update when the data has changed + // eslint-disable-next-line react-hooks/exhaustive-deps + [data] ); return { diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts index 4684742bf4d8b..40157aff3c129 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts @@ -5,6 +5,7 @@ */ import { EuiTheme } from '../../../xpack_legacy/common'; +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; import { getLatencyChartSelector, LatencyChartsResponse, @@ -21,11 +22,7 @@ const theme = { const latencyChartData = { overallAvgDuration: 1, - latencyTimeseries: { - avg: [{ x: 1, y: 10 }], - p95: [{ x: 2, y: 5 }], - p99: [{ x: 3, y: 8 }], - }, + latencyTimeseries: [{ x: 1, y: 10 }], anomalyTimeseries: { jobId: '1', anomalyBoundaries: [{ x: 1, y: 2 }], @@ -43,32 +40,60 @@ describe('getLatencyChartSelector', () => { anomalyTimeseries: undefined, }); }); - it('returns latency time series', () => { + + it('returns average timeseries', () => { const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; const latencyTimeseries = getLatencyChartSelector({ latencyChart: latencyWithouAnomaly as LatencyChartsResponse, theme, + latencyAggregationType: LatencyAggregationType.avg, }); expect(latencyTimeseries).toEqual({ latencyTimeseries: [ { - title: 'Avg.', + title: 'Average', data: [{ x: 1, y: 10 }], legendValue: '1 μs', type: 'linemark', color: 'blue', }, + ], + }); + }); + + it('returns 95th percentile timeseries', () => { + const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; + const latencyTimeseries = getLatencyChartSelector({ + latencyChart: latencyWithouAnomaly as LatencyChartsResponse, + theme, + latencyAggregationType: LatencyAggregationType.p95, + }); + expect(latencyTimeseries).toEqual({ + latencyTimeseries: [ { title: '95th percentile', + data: [{ x: 1, y: 10 }], titleShort: '95th', - data: [{ x: 2, y: 5 }], type: 'linemark', color: 'red', }, + ], + }); + }); + + it('returns 99th percentile timeseries', () => { + const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; + const latencyTimeseries = getLatencyChartSelector({ + latencyChart: latencyWithouAnomaly as LatencyChartsResponse, + theme, + latencyAggregationType: LatencyAggregationType.p99, + }); + expect(latencyTimeseries).toEqual({ + latencyTimeseries: [ { title: '99th percentile', + data: [{ x: 1, y: 10 }], titleShort: '99th', - data: [{ x: 3, y: 8 }], type: 'linemark', color: 'black', }, @@ -82,27 +107,14 @@ describe('getLatencyChartSelector', () => { const latencyTimeseries = getLatencyChartSelector({ latencyChart: latencyChartData, theme, + latencyAggregationType: LatencyAggregationType.p99, }); expect(latencyTimeseries).toEqual({ latencyTimeseries: [ - { - title: 'Avg.', - data: [{ x: 1, y: 10 }], - legendValue: '1 μs', - type: 'linemark', - color: 'blue', - }, - { - title: '95th percentile', - titleShort: '95th', - data: [{ x: 2, y: 5 }], - type: 'linemark', - color: 'red', - }, { title: '99th percentile', titleShort: '99th', - data: [{ x: 3, y: 8 }], + data: [{ x: 1, y: 10 }], type: 'linemark', color: 'black', }, diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index 73b855e12d96e..dee92bbffd27a 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { rgba } from 'polished'; import { EuiTheme } from '../../../observability/public'; +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; import { asDuration } from '../../common/utils/formatters'; import { Coordinate, @@ -17,7 +18,7 @@ import { APIReturnType } from '../services/rest/createCallApmApi'; export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>; interface LatencyChart { - latencyTimeseries: TimeSeries[]; + latencyTimeseries: Array>; mlJobId?: string; anomalyTimeseries?: { bounderies: TimeSeries; @@ -28,11 +29,13 @@ interface LatencyChart { export function getLatencyChartSelector({ latencyChart, theme, + latencyAggregationType, }: { latencyChart?: LatencyChartsResponse; theme: EuiTheme; + latencyAggregationType?: LatencyAggregationType; }): LatencyChart { - if (!latencyChart) { + if (!latencyChart?.latencyTimeseries || !latencyAggregationType) { return { latencyTimeseries: [], mlJobId: undefined, @@ -40,7 +43,11 @@ export function getLatencyChartSelector({ }; } return { - latencyTimeseries: getLatencyTimeseries({ latencyChart, theme }), + latencyTimeseries: getLatencyTimeseries({ + latencyChart, + theme, + latencyAggregationType, + }), mlJobId: latencyChart.anomalyTimeseries?.jobId, anomalyTimeseries: getAnnomalyTimeseries({ anomalyTimeseries: latencyChart.anomalyTimeseries, @@ -52,53 +59,60 @@ export function getLatencyChartSelector({ function getLatencyTimeseries({ latencyChart, theme, + latencyAggregationType, }: { latencyChart: LatencyChartsResponse; theme: EuiTheme; + latencyAggregationType: LatencyAggregationType; }) { const { overallAvgDuration } = latencyChart; - const { avg, p95, p99 } = latencyChart.latencyTimeseries; + const { latencyTimeseries } = latencyChart; - const series = [ - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.averageLabel', + switch (latencyAggregationType) { + case 'avg': { + return [ { - defaultMessage: 'Avg.', - } - ), - data: avg, - legendValue: asDuration(overallAvgDuration), - type: 'linemark', - color: theme.eui.euiColorVis1, - }, - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.95thPercentileLabel', + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.averageLabel', + { defaultMessage: 'Average' } + ), + data: latencyTimeseries, + legendValue: asDuration(overallAvgDuration), + type: 'linemark', + color: theme.eui.euiColorVis1, + }, + ]; + } + case 'p95': { + return [ { - defaultMessage: '95th percentile', - } - ), - titleShort: '95th', - data: p95, - type: 'linemark', - color: theme.eui.euiColorVis5, - }, - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.99thPercentileLabel', + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.95thPercentileLabel', + { defaultMessage: '95th percentile' } + ), + titleShort: '95th', + data: latencyTimeseries, + type: 'linemark', + color: theme.eui.euiColorVis5, + }, + ]; + } + case 'p99': { + return [ { - defaultMessage: '99th percentile', - } - ), - titleShort: '99th', - data: p99, - type: 'linemark', - color: theme.eui.euiColorVis7, - }, - ]; - - return series; + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.99thPercentileLabel', + { defaultMessage: '99th percentile' } + ), + titleShort: '99th', + data: latencyTimeseries, + type: 'linemark', + color: theme.eui.euiColorVis7, + }, + ]; + } + } + return []; } function getAnnomalyTimeseries({ diff --git a/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts b/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts new file mode 100644 index 0000000000000..bc7390a8dccfa --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; + +export function getLatencyAggregation( + latencyAggregationType: LatencyAggregationType, + field: string +) { + return { + latency: { + ...(latencyAggregationType === LatencyAggregationType.avg + ? { avg: { field } } + : { + percentiles: { + field, + percents: [ + latencyAggregationType === LatencyAggregationType.p95 ? 95 : 99, + ], + }, + }), + }, + }; +} + +export function getLatencyValue({ + latencyAggregationType, + aggregation, +}: { + latencyAggregationType: LatencyAggregationType; + aggregation: + | { value: number | null } + | { values: Record }; +}) { + if ('value' in aggregation) { + return aggregation.value; + } + if ('values' in aggregation) { + if (latencyAggregationType === LatencyAggregationType.p95) { + return aggregation.values['95.0']; + } + + if (latencyAggregationType === LatencyAggregationType.p99) { + return aggregation.values['99.0']; + } + } + + return null; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts index 73b91429f5101..dd199b6b2c43d 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { EventOutcome } from '../../../../common/event_outcome'; import { rangeFilter } from '../../../../common/utils/range_filter'; @@ -21,6 +22,7 @@ import { } from '../../helpers/aggregated_transactions'; import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { getBucketSize } from '../../helpers/get_bucket_size'; +import { getLatencyAggregation } from '../../helpers/latency_aggregation_type'; export type TransactionGroupTimeseriesData = PromiseReturnType< typeof getTimeseriesDataForTransactionGroups @@ -36,6 +38,7 @@ export async function getTimeseriesDataForTransactionGroups({ searchAggregatedTransactions, size, numBuckets, + latencyAggregationType, }: { apmEventClient: APMEventClient; start: number; @@ -46,9 +49,14 @@ export async function getTimeseriesDataForTransactionGroups({ searchAggregatedTransactions: boolean; size: number; numBuckets: number; + latencyAggregationType: LatencyAggregationType; }) { const { intervalString } = getBucketSize({ start, end, numBuckets }); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + const timeseriesResponse = await apmEventClient.search({ apm: { events: [ @@ -92,35 +100,11 @@ export async function getTimeseriesDataForTransactionGroups({ }, }, aggs: { - avg_latency: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, + ...getLatencyAggregation(latencyAggregationType, field), + transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, - }, - }, - aggs: { - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + aggs: { transaction_count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts index 5d3d7014ba8f8..244c840d19026 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts @@ -5,6 +5,7 @@ */ import { orderBy } from 'lodash'; import { ValuesType } from 'utility-types'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { EventOutcome } from '../../../../common/event_outcome'; import { ESFilter } from '../../../../../../typings/elasticsearch'; @@ -19,6 +20,10 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../../helpers/latency_aggregation_type'; export type ServiceOverviewTransactionGroupSortField = | 'latency' @@ -41,6 +46,7 @@ export async function getTransactionGroupsForPage({ sortDirection, pageIndex, size, + latencyAggregationType, }: { apmEventClient: APMEventClient; searchAggregatedTransactions: boolean; @@ -52,7 +58,12 @@ export async function getTransactionGroupsForPage({ sortDirection: 'asc' | 'desc'; pageIndex: number; size: number; + latencyAggregationType: LatencyAggregationType; }) { + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + const response = await apmEventClient.search({ apm: { events: [ @@ -77,40 +88,14 @@ export async function getTransactionGroupsForPage({ terms: { field: TRANSACTION_NAME, size: 500, - order: { - _count: 'desc', - }, + order: { _count: 'desc' }, }, aggs: { - avg_latency: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, + ...getLatencyAggregation(latencyAggregationType, field), + transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, - }, - }, - aggs: { - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + aggs: { transaction_count: { value_count: { field } } }, }, }, }, @@ -128,7 +113,10 @@ export async function getTransactionGroupsForPage({ return { name: bucket.key as string, - latency: bucket.avg_latency.value, + latency: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), throughput: bucket.transaction_count.value, errorRate, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts index 88fd189712e07..a619e51b8e89e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getTimeseriesDataForTransactionGroups } from './get_timeseries_data_for_transaction_groups'; import { @@ -21,6 +22,7 @@ export async function getServiceTransactionGroups({ sortDirection, sortField, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; setup: Setup & SetupTimeRange; @@ -30,6 +32,7 @@ export async function getServiceTransactionGroups({ sortDirection: 'asc' | 'desc'; sortField: ServiceOverviewTransactionGroupSortField; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const { apmEventClient, start, end, esFilter } = setup; @@ -48,6 +51,7 @@ export async function getServiceTransactionGroups({ sortDirection, size, searchAggregatedTransactions, + latencyAggregationType, }); const transactionNames = transactionGroups.map((group) => group.name); @@ -62,6 +66,7 @@ export async function getServiceTransactionGroups({ serviceName, size, transactionNames, + latencyAggregationType, }); return { @@ -70,6 +75,7 @@ export async function getServiceTransactionGroups({ timeseriesData, start, end, + latencyAggregationType, }), totalTransactionGroups, isAggregationAccurate, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts index 5f53bfa18c468..6d9fc2fd3d579 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; +import { getLatencyValue } from '../../helpers/latency_aggregation_type'; import { TransactionGroupTimeseriesData } from './get_timeseries_data_for_transaction_groups'; @@ -20,11 +22,13 @@ export function mergeTransactionGroupData({ end, transactionGroups, timeseriesData, + latencyAggregationType, }: { start: number; end: number; transactionGroups: TransactionGroupWithoutTimeseriesData[]; timeseriesData: TransactionGroupTimeseriesData; + latencyAggregationType: LatencyAggregationType; }) { const deltaAsMinutes = (end - start) / 1000 / 60; @@ -53,7 +57,10 @@ export function mergeTransactionGroupData({ ...acc.latency, timeseries: acc.latency.timeseries.concat({ x: point.key, - y: point.avg_latency.value, + y: getLatencyValue({ + latencyAggregationType, + aggregation: point.latency, + }), }), }, throughput: { diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index 27dd7c0f6970b..a4b9bf8dfc6a8 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -32,7 +32,7 @@ export async function getAnomalySeries({ setup: Setup & SetupTimeRange; logger: Logger; }) { - const timeseriesDates = latencyTimeseries?.avg?.map(({ x }) => x); + const timeseriesDates = latencyTimeseries?.map(({ x }) => x); /* * don't fetch: diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index 35e14fcc4624b..72464d0f8c2fa 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -10,6 +10,7 @@ import { TRANSACTION_NAME, TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { rangeFilter } from '../../../../common/utils/range_filter'; import { getDocumentTypeFilterForAggregatedTransactions, @@ -18,8 +19,10 @@ import { } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; -import { convertLatencyBucketsToCoordinates } from './transform'; - +import { + getLatencyAggregation, + getLatencyValue, +} from '../../helpers/latency_aggregation_type'; export type LatencyChartsSearchResponse = PromiseReturnType< typeof searchLatency >; @@ -30,12 +33,14 @@ async function searchLatency({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; transactionType: string | undefined; transactionName: string | undefined; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const { start, end, apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); @@ -57,7 +62,7 @@ async function searchLatency({ filter.push({ term: { [TRANSACTION_TYPE]: transactionType } }); } - const field = getTransactionDurationFieldForAggregatedTransactions( + const transactionDurationField = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions ); @@ -80,18 +85,12 @@ async function searchLatency({ min_doc_count: 0, extended_bounds: { min: start, max: end }, }, - aggs: { - avg: { avg: { field } }, - pct: { - percentiles: { - field, - percents: [95, 99], - hdr: { number_of_significant_value_digits: 2 }, - }, - }, - }, + aggs: getLatencyAggregation( + latencyAggregationType, + transactionDurationField + ), }, - overall_avg_duration: { avg: { field } }, + overall_avg_duration: { avg: { field: transactionDurationField } }, }, }, }; @@ -105,12 +104,14 @@ export async function getLatencyTimeseries({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; transactionType: string | undefined; transactionName: string | undefined; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const response = await searchLatency({ serviceName, @@ -118,20 +119,26 @@ export async function getLatencyTimeseries({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }); if (!response.aggregations) { - return { - latencyTimeseries: { avg: [], p95: [], p99: [] }, - overallAvgDuration: null, - }; + return { latencyTimeseries: [], overallAvgDuration: null }; } return { overallAvgDuration: response.aggregations.overall_avg_duration.value || null, - latencyTimeseries: convertLatencyBucketsToCoordinates( - response.aggregations.latencyTimeseries.buckets + latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( + (bucket) => { + return { + x: bucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + }; + } ), }; } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts deleted file mode 100644 index f4d914afc9483..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts +++ /dev/null @@ -1,31 +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 { isNumber } from 'lodash'; -import { LatencyChartsSearchResponse } from '.'; -import { Coordinate } from '../../../../typings/timeseries'; - -type LatencyBuckets = Required['aggregations']['latencyTimeseries']['buckets']; - -export function convertLatencyBucketsToCoordinates( - latencyBuckets: LatencyBuckets = [] -) { - return latencyBuckets.reduce( - (acc, bucket) => { - const { '95.0': p95, '99.0': p99 } = bucket.pct.values; - - acc.avg.push({ x: bucket.key, y: bucket.avg.value }); - acc.p95.push({ x: bucket.key, y: isNumber(p95) ? p95 : null }); - acc.p99.push({ x: bucket.key, y: isNumber(p99) ? p99 : null }); - return acc; - }, - { - avg: [] as Coordinate[], - p95: [] as Coordinate[], - p99: [] as Coordinate[], - } - ); -} diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index 9b7a0981a4fed..8621de72adcbb 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -19,6 +19,10 @@ import { getTransactionGroupList } from '../lib/transaction_groups'; import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; import { getLatencyTimeseries } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; +import { + LatencyAggregationType, + latencyAggregationTypeRt, +} from '../../common/latency_aggregation_types'; /** * Returns a list of transactions grouped by name @@ -78,6 +82,7 @@ export const transactionGroupsOverviewRoute = createRoute({ t.literal('errorRate'), t.literal('impact'), ]), + latencyAggregationType: latencyAggregationTypeRt, }), ]), }), @@ -93,7 +98,14 @@ export const transactionGroupsOverviewRoute = createRoute({ const { path: { serviceName }, - query: { size, numBuckets, pageIndex, sortDirection, sortField }, + query: { + size, + numBuckets, + pageIndex, + sortDirection, + sortField, + latencyAggregationType, + }, } = context.params; return getServiceTransactionGroups({ @@ -105,6 +117,7 @@ export const transactionGroupsOverviewRoute = createRoute({ sortDirection, sortField, numBuckets, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, }); }, }); @@ -117,9 +130,12 @@ export const transactionLatencyChatsRoute = createRoute({ }), query: t.intersection([ t.partial({ - transactionType: t.string, transactionName: t.string, }), + t.type({ + transactionType: t.string, + latencyAggregationType: latencyAggregationTypeRt, + }), uiFiltersRt, rangeRt, ]), @@ -129,7 +145,11 @@ export const transactionLatencyChatsRoute = createRoute({ const setup = await setupRequest(context, request); const logger = context.logger; const { serviceName } = context.params.path; - const { transactionType, transactionName } = context.params.query; + const { + transactionType, + transactionName, + latencyAggregationType, + } = context.params.query; if (!setup.uiFilters.environment) { throw Boom.badRequest( @@ -152,7 +172,10 @@ export const transactionLatencyChatsRoute = createRoute({ const { latencyTimeseries, overallAvgDuration, - } = await getLatencyTimeseries(options); + } = await getLatencyTimeseries({ + ...options, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, + }); const anomalyTimeseries = await getAnomalySeries({ ...options, diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index b7d25a32818f8..fb2e1d5465d5d 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -1420,6 +1420,18 @@ export const ComponentStrings = { workpadName, }, }), + getPDFFullPageLayoutHelpText: () => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.FullPageLayoutHelpText', { + defaultMessage: 'Remove borders and footer logo', + }), + getPDFFullPageLayoutLabel: () => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.FullPageLayoutLabel', { + defaultMessage: 'Full page layout', + }), + getPDFPanelAdvancedOptionsLabel: () => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelAdvancedOptionsLabel', { + defaultMessage: 'Advanced options', + }), getPDFPanelCopyAriaLabel: () => i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelCopyAriaLabel', { defaultMessage: @@ -1462,6 +1474,10 @@ export const ComponentStrings = { PDF, }, }), + getPDFPanelOptionsLabel: () => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelOptionsLabel', { + defaultMessage: 'Options', + }), getShareableZipErrorTitle: (workpadName: string) => i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { defaultMessage: diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot index 5c0f03dabd59b..b9b90be74a075 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot @@ -17,8 +17,78 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu/PDFPanel default 1`] = `
+
+ Options +
+
+
+
+
+ + + Full page layout + +
+
+ Remove borders and footer logo +
+
+
-
-
-

- Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher. -

-
-
- + +
+
diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/share_menu.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/share_menu.stories.storyshot index 2f56046b2b768..8472343660b32 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/share_menu.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/share_menu.stories.storyshot @@ -11,6 +11,7 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu default 1`] = ` + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss new file mode 100644 index 0000000000000..15509b98d465b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss @@ -0,0 +1,25 @@ +.appSearchResultField { + font-size: $euiFontSizeS; + line-height: $euiLineHeight; + display: grid; + grid-template-columns: 0.85fr $euiSizeXL 1fr; + grid-gap: $euiSizeXS; + + &__separator { + text-align: center; + + &:after { + content: '=>'; + color: $euiColorDarkShade; + } + } + + &__value { + padding-left: $euiSize; + overflow: hidden; + + @include euiBreakpoint('xs') { + padding-left: 0; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx new file mode 100644 index 0000000000000..921e2324d3918 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx @@ -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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { ResultField } from './result_field'; + +describe('ResultField', () => { + it('renders', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('ResultFieldValue').exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx new file mode 100644 index 0000000000000..bc6329aa4fa4a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ResultFieldValue } from '.'; +import { FieldType, Raw, Snippet } from './types'; + +import './result_field.scss'; + +interface Props { + field: string; + raw?: Raw; + snippet?: Snippet; + type?: FieldType; +} + +export const ResultField: React.FC = ({ field, raw, snippet, type }) => { + return ( +
+
{field}
+
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss index 13a771d24adc9..996124a725aab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss @@ -11,6 +11,7 @@ font-family: $euiCodeFontFamily; } + &--geolocation, &--location { color: $euiColorSuccessText; font-family: $euiCodeFontFamily; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx similarity index 96% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx index 9ee0f1e0ba043..8e4b264017c0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx @@ -8,7 +8,7 @@ import React from 'react'; import classNames from 'classnames'; -import { Raw, Snippet } from '../../types'; +import { FieldType, Raw, Snippet } from './types'; import './result_field_value.scss'; @@ -40,7 +40,7 @@ const isFieldValueEmpty = (type?: string, raw?: Raw, snippet?: Snippet) => { interface Props { raw?: Raw; snippet?: Snippet; - type?: string; + type?: FieldType; className?: string; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss new file mode 100644 index 0000000000000..73372d7c4aca0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss @@ -0,0 +1,28 @@ +.appSearchResultHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $euiSizeS; + + @include euiBreakpoint('xs') { + flex-direction: column; + } + + &__column { + display: flex; + flex-wrap: wrap; + + @include euiBreakpoint('xs') { + flex-direction: column; + } + + & + &, + .appSearchResultHeaderItem + .appSearchResultHeaderItem { + margin-left: $euiSizeL; + + @include euiBreakpoint('xs') { + margin-left: 0; + } + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx new file mode 100644 index 0000000000000..95b77a0aed7bb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ResultHeader } from './result_header'; + +describe('ResultHeader', () => { + const resultMeta = { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }; + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(false); + }); + + it('always renders an id', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); + }); + + describe('score', () => { + it('renders score if showScore is true ', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultScore"]').prop('value')).toEqual(100); + }); + + it('does not render score if showScore is false', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultScore"]').exists()).toBe(false); + }); + }); + + describe('engine', () => { + it('renders engine name if the ids dont match, which means it is a meta engine', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ResultEngine"]').prop('value')).toBe('my-engine'); + }); + + it('does not render an engine name if the ids match, which means it is not a meta engine', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ResultEngine"]').exists()).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx new file mode 100644 index 0000000000000..9b83014d041dd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx @@ -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 React from 'react'; + +import { ResultHeaderItem } from './result_header_item'; +import { ResultMeta } from './types'; + +import './result_header.scss'; + +interface Props { + showScore: boolean; + resultMeta: ResultMeta; +} + +export const ResultHeader: React.FC = ({ showScore, resultMeta }) => { + const showEngineLabel: boolean = resultMeta.id !== resultMeta.scopedId; + + return ( +
+ {showScore && ( +
+ +
+ )} + +
+ {showEngineLabel && ( + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss new file mode 100644 index 0000000000000..f1e9343530af3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss @@ -0,0 +1,16 @@ +.appSearchResultHeaderItem { + display: flex; + + &__key, + &__value { + line-height: $euiLineHeight; + font-size: $euiFontSizeXS; + } + + &__key { + text-transform: uppercase; + font-weight: $euiFontWeightLight; + color: $euiColorDarkShade; + margin-right: $euiSizeXS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx new file mode 100644 index 0000000000000..b4368f83b1833 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { mount } from 'enzyme'; + +import { ResultHeaderItem } from './result_header_item'; + +describe('ResultHeaderItem', () => { + it('renders', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual('id'); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('001'); + }); + + it('will truncate long field names', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual( + 'a-really-really-really-really-…' + ); + }); + + it('will truncate long values', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + 'a-really-really-really-really-…' + ); + }); + + it('will truncate long values from the beginning if the type is "id"', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + '…lly-really-really-really-value' + ); + }); + + it('will round any numeric values that are passed in to 2 decimals, regardless of the explicit "type" passed', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('5.19'); + }); + + it('if the value passed in is undefined, it will render "-"', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('-'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx new file mode 100644 index 0000000000000..d67b3f86b0aa7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.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'; + +import './result_header_item.scss'; + +import { TruncatedContent } from '../../../shared/truncate'; + +interface Props { + field: string; + value?: string | number; + type: 'id' | 'score' | 'string'; +} + +const MAX_CHARACTER_LENGTH = 30; + +export const ResultHeaderItem: React.FC = ({ field, type, value }) => { + let formattedValue = '-'; + if (typeof value === 'string') { + formattedValue = value; + } else if (typeof value === 'number') { + formattedValue = parseFloat((value as number).toFixed(2)).toString(); + } + + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts new file mode 100644 index 0000000000000..7db710eeb213a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InternalSchemaTypes, SchemaTypes } from '../../../shared/types'; + +export type FieldType = InternalSchemaTypes | SchemaTypes; + +export type Raw = string | string[] | number | number[]; +export type Snippet = string; +export interface FieldValue { + raw?: Raw; + snippet?: Snippet; +} + +export interface ResultMeta { + id: string; + scopedId: string; + score?: number; + engine: string; +} + +// A search result item +export type Result = { + id: { + raw: string; + }; + _meta: ResultMeta; +} & { + // this should be a FieldType object, but there's no good way to do that in TS: https://github.com/microsoft/TypeScript/issues/17867 + // You'll need to cast it to FieldValue whenever you use it. + [key: string]: object; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 743cf63fb4bc3..769230ccffd22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -26,6 +26,7 @@ import { ROLE_MAPPINGS_PATH, ENGINES_PATH, ENGINE_PATH, + LIBRARY_PATH, } from './routes'; import { SetupGuide } from './components/setup_guide'; @@ -35,6 +36,7 @@ import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { Credentials, CREDENTIALS_TITLE } from './components/credentials'; import { ROLE_MAPPINGS_TITLE } from './components/role_mappings'; +import { Library } from './components/library'; export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -66,6 +68,11 @@ export const AppSearchConfigured: React.FC = (props) => { + {process.env.NODE_ENV === 'development' && ( + + + + )} } />} readOnlyMode={readOnlyMode}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index a7220b89e4410..ade3c9a917410 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -12,6 +12,7 @@ export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; +export const LIBRARY_PATH = '/library'; export const SETTINGS_PATH = '/settings/account'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts index 9af1ff0293fae..7c22a45757a93 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts @@ -7,5 +7,3 @@ export * from '../../../common/types/app_search'; export { Role, RoleTypes, AbilityTypes } from './utils/role'; export { Engine } from './components/engine/types'; -export type Raw = string | string[] | number | number[]; -export type Snippet = string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index c1737142e482e..f5833a0ac9f8e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -7,7 +7,8 @@ import { ADD, UPDATE } from './constants/operations'; export type SchemaTypes = 'text' | 'number' | 'geolocation' | 'date'; - +// Certain API endpoints will use these internal type names, which map to the external names above +export type InternalSchemaTypes = 'string' | 'float' | 'location' | 'date'; export interface Schema { [key: string]: SchemaTypes; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 96868fa8cfc3b..f518c606d6959 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -207,6 +207,7 @@ export type ElasticsearchAssetTypeToParts = Record< export interface RegistryDataStream { type: string; + hidden?: boolean; dataset: string; title: string; release: string; @@ -319,7 +320,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: object; + data_stream: { hidden?: boolean }; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index 222554e97eb91..fecfcf145ca99 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -20,6 +20,7 @@ import { PackageNotFoundError, AgentPolicyNameExistsError, PackageUnsupportedMediaTypeError, + ConcurrentInstallOperationError, } from './index'; type IngestErrorHandler = ( @@ -69,7 +70,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof PackageUnsupportedMediaTypeError) { return 415; // Unsupported Media Type } - + if (error instanceof ConcurrentInstallOperationError) { + return 409; // Conflict + } return 400; // Bad Request }; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index fad4eef66215d..700750761def4 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -30,3 +30,4 @@ export class PackageInvalidArchiveError extends IngestManagerError {} export class PackageCacheError extends IngestManagerError {} export class PackageOperationNotSupportedError extends IngestManagerError {} export class FleetAdminUserInvalidError extends IngestManagerError {} +export class ConcurrentInstallOperationError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 9e2c71ead5b74..4a897d80acd6d 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -34,6 +34,7 @@ export const createPackagePolicyServiceMock = () => { getByIDs: jest.fn(), list: jest.fn(), update: jest.fn(), + runExternalCallbacks: jest.fn(), } as jest.Mocked; }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 0b58c4aab9d0b..4a3412954d50c 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -52,7 +52,12 @@ import { registerSettingsRoutes, registerAppRoutes, } from './routes'; -import { EsAssetReference, FleetConfigType, NewPackagePolicy } from '../common'; +import { + EsAssetReference, + FleetConfigType, + NewPackagePolicy, + UpdatePackagePolicy, +} from '../common'; import { appContextService, licenseService, @@ -119,14 +124,23 @@ const allSavedObjectTypes = [ /** * Callbacks supported by the Fleet plugin */ -export type ExternalCallback = [ - 'packagePolicyCreate', - ( - newPackagePolicy: NewPackagePolicy, - context: RequestHandlerContext, - request: KibanaRequest - ) => Promise -]; +export type ExternalCallback = + | [ + 'packagePolicyCreate', + ( + newPackagePolicy: NewPackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ) => Promise + ] + | [ + 'packagePolicyUpdate', + ( + newPackagePolicy: UpdatePackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ) => Promise + ]; export type ExternalCallbacksStorage = Map>; @@ -302,8 +316,8 @@ export class FleetPlugin getFullAgentPolicy: agentPolicyService.getFullAgentPolicy, }, packagePolicyService, - registerExternalCallback: (...args: ExternalCallback) => { - return appContextService.addExternalCallback(...args); + registerExternalCallback: (type: ExternalCallback[0], callback: ExternalCallback[1]) => { + return appContextService.addExternalCallback(type, callback); }, }; } diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index f5d2606dd7e0c..9ccf60dc80a5f 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -17,7 +17,6 @@ import { BulkInstallPackageInfo, BulkInstallPackagesResponse, IBulkInstallPackageHTTPError, - ASSETS_SAVED_OBJECT_TYPE, } from '../../../common'; import { GetCategoriesRequestSchema, @@ -48,7 +47,7 @@ import { defaultIngestErrorHandler, ingestErrorToResponseOptions } from '../../e import { splitPkgKey } from '../../services/epm/registry'; import { licenseService } from '../../services'; import { getArchiveEntry } from '../../services/epm/archive/cache'; -import { PackageAsset, assetPathToObjectId } from '../../services/epm/archive/save_to_es'; +import { getAsset } from '../../services/epm/archive/storage'; export const getCategoriesHandler: RequestHandler< undefined, @@ -113,45 +112,50 @@ export const getFileHandler: RequestHandler( - ASSETS_SAVED_OBJECT_TYPE, - assetPathToObjectId(archiveKey) - ); + const assetPath = `${pkgName}-${pkgVersion}/${filePath}`; + const fileBuffer = getArchiveEntry(assetPath); + // only pull local installation if we don't have it cached + const storedAsset = !fileBuffer && (await getAsset({ savedObjectsClient, path: assetPath })); - if (!archiveEntry && !assetSavedObject) { + // error, if neither is available + if (!fileBuffer && !storedAsset) { return response.custom({ body: `installed package file not found: ${filePath}`, statusCode: 404, }); } - const headerContentType = - assetSavedObject.attributes.media_type || mime.contentType(path.extname(archiveKey)); - if (!headerContentType) { + // if storedAsset is not available, fileBuffer *must* be + // b/c we error if we don't have at least one, and storedAsset is the least likely + const { buffer, contentType } = storedAsset + ? { + contentType: storedAsset.media_type, + buffer: storedAsset.data_utf8 + ? Buffer.from(storedAsset.data_utf8, 'utf8') + : Buffer.from(storedAsset.data_base64, 'base64'), + } + : { + contentType: mime.contentType(path.extname(assetPath)), + buffer: fileBuffer, + }; + + if (!contentType) { return response.custom({ body: `unknown content type for file: ${filePath}`, statusCode: 400, }); } - const { data_base64: base64, data_utf8: utf8 } = assetSavedObject.attributes; - // if we have a local Buffer, use that - // else, create one from the saved object (try utf8 first) - const responseBody = - archiveEntry || utf8 ? Buffer.from(utf8, 'utf8') : Buffer.from(base64, 'base64'); - return response.custom({ - body: responseBody, + body: buffer, statusCode: 200, headers: { 'cache-control': 'max-age=10, public', - 'content-type': headerContentType, + 'content-type': contentType, }, }); } else { - const registryResponse = await getFile(`/package/${pkgName}/${pkgVersion}/${filePath}`); + const registryResponse = await getFile(pkgName, pkgVersion, filePath); const headersToProxy: KnownHeaders[] = ['content-type', 'cache-control']; const proxiedHeaders = headersToProxy.reduce((headers, knownHeader) => { const value = registryResponse.headers.get(knownHeader); diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index fee74e39c833a..f9fd6047baa77 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -5,7 +5,7 @@ */ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; -import { IRouter, KibanaRequest, Logger, RequestHandler, RouteConfig } from 'kibana/server'; +import { IRouter, KibanaRequest, RequestHandler, RouteConfig } from 'kibana/server'; import { registerRoutes } from './index'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; import { xpackMocks } from '../../../../../mocks'; @@ -48,6 +48,9 @@ jest.mock('../../services/package_policy', (): { getByIDs: jest.fn(), list: jest.fn(), update: jest.fn(), + runExternalCallbacks: jest.fn((callbackType, newPackagePolicy, context, request) => + Promise.resolve(newPackagePolicy) + ), }, }; }); @@ -164,50 +167,26 @@ describe('When calling package policy', () => { afterEach(() => (callbackCallingOrder.length = 0)); - it('should call external callbacks in expected order', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackCallingOrder).toEqual(['one', 'two']); - }); - - it('should feed package policy returned by last callback', async () => { + it('should create with data from callback', async () => { const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackOne).toHaveBeenCalledWith( - { - policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', - description: '', - enabled: true, - inputs: [], - name: 'endpoint-1', - namespace: 'default', - output_id: '', - package: { - name: 'endpoint', - title: 'Elastic Endpoint', - version: '0.5.0', - }, - }, - context, - request - ); - expect(callbackTwo).toHaveBeenCalledWith( - { + packagePolicyServiceMock.runExternalCallbacks.mockImplementationOnce(() => + Promise.resolve({ policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', description: '', enabled: true, inputs: [ { - type: 'endpoint', - enabled: true, - streams: [], config: { one: { value: 'inserted by callbackOne', }, + two: { + value: 'inserted by callbackTwo', + }, }, + enabled: true, + streams: [], + type: 'endpoint', }, ], name: 'endpoint-1', @@ -218,14 +197,8 @@ describe('When calling package policy', () => { title: 'Elastic Endpoint', version: '0.5.0', }, - }, - context, - request + }) ); - }); - - it('should create with data from callback', async () => { - const request = getCreateKibanaRequest(); await routeHandler(context, request, response); expect(response.ok).toHaveBeenCalled(); expect(packagePolicyServiceMock.create.mock.calls[0][2]).toEqual({ @@ -257,91 +230,6 @@ describe('When calling package policy', () => { }, }); }); - - describe('and a callback throws an exception', () => { - const callbackThree: ExternalCallback[1] = jest.fn(async (ds) => { - callbackCallingOrder.push('three'); - throw new Error('callbackThree threw error on purpose'); - }); - - const callbackFour: ExternalCallback[1] = jest.fn(async (ds) => { - callbackCallingOrder.push('four'); - return { - ...ds, - inputs: [ - { - ...ds.inputs[0], - config: { - ...ds.inputs[0].config, - four: { - value: 'inserted by callbackFour', - }, - }, - }, - ], - }; - }); - - beforeEach(() => { - appContextService.addExternalCallback('packagePolicyCreate', callbackThree); - appContextService.addExternalCallback('packagePolicyCreate', callbackFour); - }); - - it('should skip over callback exceptions and still execute other callbacks', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackCallingOrder).toEqual(['one', 'two', 'three', 'four']); - }); - - it('should log errors', async () => { - const errorLogger = (appContextService.getLogger() as jest.Mocked).error; - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(errorLogger.mock.calls).toEqual([ - ['An external registered [packagePolicyCreate] callback failed when executed'], - [new Error('callbackThree threw error on purpose')], - ]); - }); - - it('should create package policy with last successful returned package policy', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(packagePolicyServiceMock.create.mock.calls[0][2]).toEqual({ - policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', - description: '', - enabled: true, - inputs: [ - { - config: { - one: { - value: 'inserted by callbackOne', - }, - two: { - value: 'inserted by callbackTwo', - }, - four: { - value: 'inserted by callbackFour', - }, - }, - enabled: true, - streams: [], - type: 'endpoint', - }, - ], - name: 'endpoint-1', - namespace: 'default', - output_id: '', - package: { - name: 'endpoint', - title: 'Elastic Endpoint', - version: '0.5.0', - }, - }); - }); - }); }); }); }); diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index b154aa2a2782f..be14970de3e0f 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -13,7 +13,6 @@ import { CreatePackagePolicyRequestSchema, UpdatePackagePolicyRequestSchema, DeletePackagePoliciesRequestSchema, - NewPackagePolicy, } from '../../types'; import { CreatePackagePolicyResponse, DeletePackagePoliciesResponse } from '../../../common'; import { defaultIngestErrorHandler } from '../../errors'; @@ -77,31 +76,14 @@ export const createPackagePolicyHandler: RequestHandler< const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - const logger = appContextService.getLogger(); let newData = { ...request.body }; try { - // If we have external callbacks, then process those now before creating the actual package policy - const externalCallbacks = appContextService.getExternalCallbacks('packagePolicyCreate'); - if (externalCallbacks && externalCallbacks.size > 0) { - let updatedNewData: NewPackagePolicy = newData; - - for (const callback of externalCallbacks) { - try { - // ensure that the returned value by the callback passes schema validation - updatedNewData = CreatePackagePolicyRequestSchema.body.validate( - await callback(updatedNewData, context, request) - ); - } catch (error) { - // Log the error, but keep going and process the other callbacks - logger.error( - 'An external registered [packagePolicyCreate] callback failed when executed' - ); - logger.error(error); - } - } - - newData = updatedNewData; - } + newData = await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newData, + context, + request + ); // Create package policy const packagePolicy = await packagePolicyService.create(soClient, callCluster, newData, { @@ -112,6 +94,12 @@ export const createPackagePolicyHandler: RequestHandler< body, }); } catch (error) { + if (error.statusCode) { + return response.customError({ + statusCode: error.statusCode, + body: { message: error.message }, + }); + } return defaultIngestErrorHandler({ error, response }); } }; @@ -123,16 +111,23 @@ export const updatePackagePolicyHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - try { - const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); + const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); - if (!packagePolicy) { - throw Boom.notFound('Package policy not found'); - } + if (!packagePolicy) { + throw Boom.notFound('Package policy not found'); + } + + let newData = { ...request.body }; + const pkg = newData.package || packagePolicy.package; + const inputs = newData.inputs || packagePolicy.inputs; - const newData = { ...request.body }; - const pkg = newData.package || packagePolicy.package; - const inputs = newData.inputs || packagePolicy.inputs; + try { + newData = await packagePolicyService.runExternalCallbacks( + 'packagePolicyUpdate', + newData, + context, + request + ); const updatedPackagePolicy = await packagePolicyService.update( soClient, diff --git a/x-pack/plugins/fleet/server/routes/security.ts b/x-pack/plugins/fleet/server/routes/security.ts index c2348c313e583..ec89668111860 100644 --- a/x-pack/plugins/fleet/server/routes/security.ts +++ b/x-pack/plugins/fleet/server/routes/security.ts @@ -14,7 +14,12 @@ export function enforceSuperUser( const security = appContextService.getSecurity(); const user = security.authc.getCurrentUser(req); if (!user) { - return res.unauthorized(); + return res.forbidden({ + body: { + message: + 'Access to Fleet API require the superuser role, and for stack security features to be enabled.', + }, + }); } const userRoles = user.roles || []; diff --git a/x-pack/plugins/fleet/server/services/epm/archive/save_to_es.ts b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts similarity index 90% rename from x-pack/plugins/fleet/server/services/epm/archive/save_to_es.ts rename to x-pack/plugins/fleet/server/services/epm/archive/storage.ts index 308747804c986..f7323279e0e7c 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/save_to_es.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts @@ -121,3 +121,20 @@ export async function archiveEntryToBulkCreateObject(opts: { attributes: doc, }; } + +export async function getAsset(opts: { + savedObjectsClient: SavedObjectsClientContract; + path: string; +}) { + const { savedObjectsClient, path } = opts; + const assetSavedObject = await savedObjectsClient.get( + ASSETS_SAVED_OBJECT_TYPE, + assetPathToObjectId(path) + ); + const storedAsset = assetSavedObject?.attributes; + if (!storedAsset) { + return; + } + + return storedAsset; +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 199026da30c11..944f742e54546 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -314,6 +314,7 @@ export async function installTemplate({ pipelineName, packageName, composedOfTemplates, + hidden: dataStream.hidden, }); // TODO: Check return values for errors diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index cc1aa79c7491c..bdff7e0fb3bc6 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -60,6 +60,31 @@ test('adds empty composed_of correctly', () => { expect(template.composed_of).toStrictEqual(composedOfTemplates); }); +test('adds hidden field correctly', () => { + const templateWithHiddenName = 'logs-nginx-access-abcd'; + + const templateWithHidden = getTemplate({ + type: 'logs', + templateName: templateWithHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + hidden: true, + }); + expect(templateWithHidden.data_stream.hidden).toEqual(true); + + const templateWithoutHiddenName = 'logs-nginx-access-efgh'; + + const templateWithoutHidden = getTemplate({ + type: 'logs', + templateName: templateWithoutHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + }); + expect(templateWithoutHidden.data_stream.hidden).toEqual(undefined); +}); + test('tests loading base.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 8d33180d6262d..d80d54d098db7 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -45,6 +45,7 @@ export function getTemplate({ pipelineName, packageName, composedOfTemplates, + hidden, }: { type: string; templateName: string; @@ -52,8 +53,16 @@ export function getTemplate({ pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; + hidden?: boolean; }): IndexTemplate { - const template = getBaseTemplate(type, templateName, mappings, packageName, composedOfTemplates); + const template = getBaseTemplate( + type, + templateName, + mappings, + packageName, + composedOfTemplates, + hidden + ); if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } @@ -253,7 +262,8 @@ function getBaseTemplate( templateName: string, mappings: IndexTemplateMappings, packageName: string, - composedOfTemplates: string[] + composedOfTemplates: string[], + hidden?: boolean ): IndexTemplate { // Meta information to identify Ingest Manager's managed templates and indices const _meta = { @@ -324,7 +334,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: {}, + data_stream: { hidden }, composed_of: composedOfTemplates, _meta, }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 7b84ecc259a5f..5e6ecad9b72f1 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -29,7 +29,8 @@ import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; import { deleteKibanaSavedObjectsAssets } from './remove'; import { installTransform } from '../elasticsearch/transform/install'; import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; -import { saveArchiveEntries } from '../archive/save_to_es'; +import { saveArchiveEntries } from '../archive/storage'; +import { ConcurrentInstallOperationError } from '../../../errors'; // this is only exported for testing // use a leading underscore to indicate it's not the supported path @@ -53,163 +54,176 @@ export async function _installPackage({ installSource: InstallSource; }): Promise { const { name: pkgName, version: pkgVersion } = packageInfo; - // if some installation already exists - if (installedPkg) { - // if the installation is currently running, don't try to install - // instead, only return already installed assets - if ( - installedPkg.attributes.install_status === 'installing' && - Date.now() - Date.parse(installedPkg.attributes.install_started_at) < - MAX_TIME_COMPLETE_INSTALL - ) { - let assets: AssetReference[] = []; - assets = assets.concat(installedPkg.attributes.installed_es); - assets = assets.concat(installedPkg.attributes.installed_kibana); - return assets; + try { + // if some installation already exists + if (installedPkg) { + // if the installation is currently running, don't try to install + // instead, only return already installed assets + if ( + installedPkg.attributes.install_status === 'installing' && + Date.now() - Date.parse(installedPkg.attributes.install_started_at) < + MAX_TIME_COMPLETE_INSTALL + ) { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting.` + ); + } else { + // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL + // (it might be stuck) update the saved object and proceed + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + install_version: pkgVersion, + install_status: 'installing', + install_started_at: new Date().toISOString(), + install_source: installSource, + }); + } } else { - // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL - // (it might be stuck) update the saved object and proceed - await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { - install_version: pkgVersion, - install_status: 'installing', - install_started_at: new Date().toISOString(), - install_source: installSource, + await createInstallation({ + savedObjectsClient, + packageInfo, + installSource, }); } - } else { - await createInstallation({ - savedObjectsClient, - packageInfo, - installSource, - }); - } - // kick off `installIndexPatterns` & `installKibanaAssets` as early as possible because they're the longest running operations - // we don't `await` here because we don't want to delay starting the many other `install*` functions - // however, without an `await` or a `.catch` we haven't defined how to handle a promise rejection - // we define it many lines and potentially seconds of wall clock time later in - // `await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]);` - // if we encounter an error before we there, we'll have an "unhandled rejection" which causes its own problems - // the program will log something like this _and exit/crash_ - // Unhandled Promise rejection detected: - // RegistryResponseError or some other error - // Terminating process... - // server crashed with status code 1 - // - // add a `.catch` to prevent the "unhandled rejection" case - // in that `.catch`, set something that indicates a failure - // check for that failure later and act accordingly (throw, ignore, return) - let installIndexPatternError; - const installIndexPatternPromise = installIndexPatterns( - savedObjectsClient, - pkgName, - pkgVersion, - installSource - ).catch((reason) => (installIndexPatternError = reason)); - const kibanaAssets = await getKibanaAssets(paths); - if (installedPkg) - await deleteKibanaSavedObjectsAssets( + // kick off `installIndexPatterns` & `installKibanaAssets` as early as possible because they're the longest running operations + // we don't `await` here because we don't want to delay starting the many other `install*` functions + // however, without an `await` or a `.catch` we haven't defined how to handle a promise rejection + // we define it many lines and potentially seconds of wall clock time later in + // `await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]);` + // if we encounter an error before we there, we'll have an "unhandled rejection" which causes its own problems + // the program will log something like this _and exit/crash_ + // Unhandled Promise rejection detected: + // RegistryResponseError or some other error + // Terminating process... + // server crashed with status code 1 + // + // add a `.catch` to prevent the "unhandled rejection" case + // in that `.catch`, set something that indicates a failure + // check for that failure later and act accordingly (throw, ignore, return) + let installIndexPatternError; + const installIndexPatternPromise = installIndexPatterns( savedObjectsClient, - installedPkg.attributes.installed_kibana + pkgName, + pkgVersion, + installSource + ).catch((reason) => (installIndexPatternError = reason)); + const kibanaAssets = await getKibanaAssets(paths); + if (installedPkg) + await deleteKibanaSavedObjectsAssets( + savedObjectsClient, + installedPkg.attributes.installed_kibana + ); + // save new kibana refs before installing the assets + const installedKibanaAssetsRefs = await saveKibanaAssetsRefs( + savedObjectsClient, + pkgName, + kibanaAssets ); - // save new kibana refs before installing the assets - const installedKibanaAssetsRefs = await saveKibanaAssetsRefs( - savedObjectsClient, - pkgName, - kibanaAssets - ); - let installKibanaAssetsError; - const installKibanaAssetsPromise = installKibanaAssets({ - savedObjectsClient, - pkgName, - kibanaAssets, - }).catch((reason) => (installKibanaAssetsError = reason)); - - // the rest of the installation must happen in sequential order - // currently only the base package has an ILM policy - // at some point ILM policies can be installed/modified - // per data stream and we should then save them - await installILMPolicy(paths, callCluster); - - // installs versionized pipelines without removing currently installed ones - const installedPipelines = await installPipelines( - packageInfo, - paths, - callCluster, - savedObjectsClient - ); - // install or update the templates referencing the newly installed pipelines - const installedTemplates = await installTemplates( - packageInfo, - callCluster, - paths, - savedObjectsClient - ); - - // update current backing indices of each data stream - await updateCurrentWriteIndices(callCluster, installedTemplates); + let installKibanaAssetsError; + const installKibanaAssetsPromise = installKibanaAssets({ + savedObjectsClient, + pkgName, + kibanaAssets, + }).catch((reason) => (installKibanaAssetsError = reason)); - const installedTransforms = await installTransform( - packageInfo, - paths, - callCluster, - savedObjectsClient - ); + // the rest of the installation must happen in sequential order + // currently only the base package has an ILM policy + // at some point ILM policies can be installed/modified + // per data stream and we should then save them + await installILMPolicy(paths, callCluster); - // if this is an update or retrying an update, delete the previous version's pipelines - if ((installType === 'update' || installType === 'reupdate') && installedPkg) { - await deletePreviousPipelines( + // installs versionized pipelines without removing currently installed ones + const installedPipelines = await installPipelines( + packageInfo, + paths, callCluster, - savedObjectsClient, - pkgName, - installedPkg.attributes.version + savedObjectsClient ); - } - // pipelines from a different version may have installed during a failed update - if (installType === 'rollback' && installedPkg) { - await deletePreviousPipelines( + // install or update the templates referencing the newly installed pipelines + const installedTemplates = await installTemplates( + packageInfo, callCluster, - savedObjectsClient, - pkgName, - installedPkg.attributes.install_version + paths, + savedObjectsClient ); - } - const installedTemplateRefs = installedTemplates.map((template) => ({ - id: template.templateName, - type: ElasticsearchAssetType.indexTemplate, - })); - // make sure the assets are installed (or didn't error) - if (installIndexPatternError) throw installIndexPatternError; - if (installKibanaAssetsError) throw installKibanaAssetsError; - await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]); + // update current backing indices of each data stream + await updateCurrentWriteIndices(callCluster, installedTemplates); - const packageAssetResults = await saveArchiveEntries({ - savedObjectsClient, - paths, - packageInfo, - installSource, - }); - const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( - (result) => ({ - id: result.id, - type: ASSETS_SAVED_OBJECT_TYPE, - }) - ); + const installedTransforms = await installTransform( + packageInfo, + paths, + callCluster, + savedObjectsClient + ); - // update to newly installed version when all assets are successfully installed - if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); + // if this is an update or retrying an update, delete the previous version's pipelines + if ((installType === 'update' || installType === 'reupdate') && installedPkg) { + await deletePreviousPipelines( + callCluster, + savedObjectsClient, + pkgName, + installedPkg.attributes.version + ); + } + // pipelines from a different version may have installed during a failed update + if (installType === 'rollback' && installedPkg) { + await deletePreviousPipelines( + callCluster, + savedObjectsClient, + pkgName, + installedPkg.attributes.install_version + ); + } + const installedTemplateRefs = installedTemplates.map((template) => ({ + id: template.templateName, + type: ElasticsearchAssetType.indexTemplate, + })); - await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { - install_version: pkgVersion, - install_status: 'installed', - package_assets: packageAssetRefs, - }); + // make sure the assets are installed (or didn't error) + if (installIndexPatternError) throw installIndexPatternError; + if (installKibanaAssetsError) throw installKibanaAssetsError; + await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]); - return [ - ...installedKibanaAssetsRefs, - ...installedPipelines, - ...installedTemplateRefs, - ...installedTransforms, - ]; + const packageAssetResults = await saveArchiveEntries({ + savedObjectsClient, + paths, + packageInfo, + installSource, + }); + const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( + (result) => ({ + id: result.id, + type: ASSETS_SAVED_OBJECT_TYPE, + }) + ); + + // update to newly installed version when all assets are successfully installed + if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); + + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + install_version: pkgVersion, + install_status: 'installed', + package_assets: packageAssetRefs, + }); + + return [ + ...installedKibanaAssetsRefs, + ...installedPipelines, + ...installedTemplateRefs, + ...installedTransforms, + ]; + } catch (err) { + if (savedObjectsClient.errors.isConflictError(err)) { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting. Original error: ${err.message}` + ); + } else { + throw err; + } + } } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 0d7006ca41d2b..01aaf111fef84 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -18,7 +18,7 @@ import * as Registry from '../registry'; import { createInstallableFrom, isRequiredPackage } from './index'; import { getArchivePackage } from '../archive'; -export { fetchFile as getFile, SearchParams } from '../registry'; +export { getFile, SearchParams } from '../registry'; function nameAsTitle(name: string) { return name.charAt(0).toUpperCase() + name.substr(1).toLowerCase(); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 6e0d574d311cc..63bf1ed53fb97 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -23,7 +23,7 @@ import { deleteTransforms } from '../elasticsearch/transform/remove'; import { packagePolicyService, appContextService } from '../..'; import { splitPkgKey } from '../registry'; import { deletePackageCache } from '../archive'; -import { removeArchiveEntries } from '../archive/save_to_es'; +import { removeArchiveEntries } from '../archive/storage'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index d8368f2a46d19..90f9afe2350ea 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -110,6 +110,15 @@ export async function fetchInfo(pkgName: string, pkgVersion: string): Promise { + const filePath = `/package/${pkgName}/${pkgVersion}/${relPath}`; + return fetchFile(filePath); +} + export async function fetchFile(filePath: string): Promise { const registryUrl = getRegistryUrl(); return getResponse(`${registryUrl}${filePath}`); diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 30a980ab07f70..c6bfb53812c70 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -9,6 +9,12 @@ import { createPackagePolicyMock } from '../../common/mocks'; import { packagePolicyService } from './package_policy'; import { PackageInfo, PackagePolicySOAttributes } from '../types'; import { SavedObjectsUpdateResponse } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; +import { KibanaRequest } from 'kibana/server'; +import { xpackMocks } from '../../../../mocks'; +import { ExternalCallback } from '..'; +import { appContextService } from './app_context'; +import { createAppContextStartContractMock } from '../mocks'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { @@ -318,4 +324,180 @@ describe('Package policy service', () => { ).rejects.toThrow('Saved object [abc/123] conflict'); }); }); + + describe('runExternalCallbacks', () => { + let context: ReturnType; + let request: KibanaRequest; + + const newPackagePolicy = { + policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }; + + const callbackCallingOrder: string[] = []; + + // Callback one adds an input that includes a `config` property + const callbackOne: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('one'); + return { + ...ds, + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + one: { + value: 'inserted by callbackOne', + }, + }, + }, + ], + }; + }); + + // Callback two adds an additional `input[0].config` property + const callbackTwo: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('two'); + return { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + two: { + value: 'inserted by callbackTwo', + }, + }, + }, + ], + }; + }); + + beforeEach(() => { + context = xpackMocks.createRequestHandlerContext(); + request = httpServerMock.createKibanaRequest(); + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + appContextService.stop(); + jest.clearAllMocks(); + callbackCallingOrder.length = 0; + }); + + it('should call external callbacks in expected order', async () => { + const callbackA: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('a'); + return ds; + }); + + const callbackB: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('b'); + return ds; + }); + + appContextService.addExternalCallback('packagePolicyCreate', callbackA); + appContextService.addExternalCallback('packagePolicyCreate', callbackB); + + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + expect(callbackCallingOrder).toEqual(['a', 'b']); + }); + + it('should feed package policy returned by last callback', async () => { + appContextService.addExternalCallback('packagePolicyCreate', callbackOne); + appContextService.addExternalCallback('packagePolicyCreate', callbackTwo); + + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + + expect((callbackOne as jest.Mock).mock.calls[0][0].inputs).toHaveLength(0); + expect((callbackTwo as jest.Mock).mock.calls[0][0].inputs).toHaveLength(1); + expect((callbackTwo as jest.Mock).mock.calls[0][0].inputs[0].config.one.value).toEqual( + 'inserted by callbackOne' + ); + }); + + describe('with a callback that throws an exception', () => { + const callbackThree: ExternalCallback[1] = jest.fn(async () => { + callbackCallingOrder.push('three'); + throw new Error('callbackThree threw error on purpose'); + }); + + const callbackFour: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('four'); + return { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + four: { + value: 'inserted by callbackFour', + }, + }, + }, + ], + }; + }); + + beforeEach(() => { + appContextService.addExternalCallback('packagePolicyCreate', callbackOne); + appContextService.addExternalCallback('packagePolicyCreate', callbackTwo); + appContextService.addExternalCallback('packagePolicyCreate', callbackThree); + appContextService.addExternalCallback('packagePolicyCreate', callbackFour); + }); + + it('should fail to execute remaining callbacks after a callback exception', async () => { + try { + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + } catch (e) { + // expecting an error + } + + expect(callbackCallingOrder).toEqual(['one', 'two', 'three']); + expect((callbackOne as jest.Mock).mock.calls.length).toBe(1); + expect((callbackTwo as jest.Mock).mock.calls.length).toBe(1); + expect((callbackThree as jest.Mock).mock.calls.length).toBe(1); + expect((callbackFour as jest.Mock).mock.calls.length).toBe(0); + }); + + it('should fail to return the package policy', async () => { + expect( + packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ) + ).rejects.toThrow('callbackThree threw error on purpose'); + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 7b8952bdea2cd..5a6b48b952836 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { KibanaRequest, RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server'; import uuid from 'uuid'; import { AuthenticatedUser } from '../../../security/server'; import { @@ -25,6 +25,8 @@ import { PackagePolicySOAttributes, RegistryPackage, CallESAsCurrentUser, + NewPackagePolicySchema, + UpdatePackagePolicySchema, } from '../types'; import { agentPolicyService } from './agent_policy'; import { outputService } from './output'; @@ -33,6 +35,8 @@ import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/p import { getAssetsData } from './epm/packages/assets'; import { compileTemplate } from './epm/agent/agent'; import { normalizeKuery } from './saved_object'; +import { appContextService } from '.'; +import { ExternalCallback } from '..'; const SAVED_OBJECT_TYPE = PACKAGE_POLICY_SAVED_OBJECT_TYPE; @@ -391,6 +395,32 @@ class PackagePolicyService { return Promise.all(inputsPromises); } + + public async runExternalCallbacks( + externalCallbackType: ExternalCallback[0], + newPackagePolicy: NewPackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ): Promise { + let newData = newPackagePolicy; + + const externalCallbacks = appContextService.getExternalCallbacks(externalCallbackType); + if (externalCallbacks && externalCallbacks.size > 0) { + let updatedNewData: NewPackagePolicy = newData; + + for (const callback of externalCallbacks) { + const result = await callback(updatedNewData, context, request); + if (externalCallbackType === 'packagePolicyCreate') { + updatedNewData = NewPackagePolicySchema.validate(result); + } else if (externalCallbackType === 'packagePolicyUpdate') { + updatedNewData = UpdatePackagePolicySchema.validate(result); + } + } + + newData = updatedNewData; + } + return newData; + } } function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 8ce7e835dd858..abb33d109742c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -197,6 +197,10 @@ export const setup = async (arg?: { appServicesContext: Partial exists('freezeSwitch'); + const setReadonly = (phase: Phases) => async (value: boolean) => { + await createFormToggleAction(`${phase}-readonlySwitch`)(value); + }; + const createSearchableSnapshotActions = (phase: Phases) => { const fieldSelector = `searchableSnapshotField-${phase}`; const licenseCalloutSelector = `${fieldSelector}.searchableSnapshotDisabledDueToLicense`; @@ -239,6 +243,7 @@ export const setup = async (arg?: { appServicesContext: Partial', () => { await actions.hot.setForcemergeSegmentsCount('123'); await actions.hot.setBestCompression(true); await actions.hot.setShrink('2'); + await actions.hot.setReadonly(true); await actions.hot.setIndexPriority('123'); await actions.savePolicy(); @@ -141,6 +142,7 @@ describe('', () => { "index_codec": "best_compression", "max_num_segments": 123, }, + "readonly": Object {}, "rollover": Object { "max_age": "123h", "max_docs": 123, @@ -259,6 +261,7 @@ describe('', () => { await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('123'); await actions.warm.setBestCompression(true); + await actions.warm.setReadonly(true); await actions.warm.setIndexPriority('123'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; @@ -292,6 +295,7 @@ describe('', () => { "index_codec": "best_compression", "max_num_segments": 123, }, + "readonly": Object {}, "set_priority": Object { "priority": 123, }, diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 1c28262a54305..4f7782a51b278 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -65,7 +65,9 @@ export interface SerializedHotPhase extends SerializedPhase { max_docs?: number; }; forcemerge?: ForcemergeAction; + readonly?: {}; shrink?: ShrinkAction; + set_priority?: { priority: number | null; }; @@ -81,6 +83,7 @@ export interface SerializedWarmPhase extends SerializedPhase { allocate?: AllocateAction; shrink?: ShrinkAction; forcemerge?: ForcemergeAction; + readonly?: {}; set_priority?: { priority: number | null; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a4fb03bbd1ca6..77f36a237c0c4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -37,6 +37,7 @@ import { SetPriorityInputField, SearchableSnapshotField, useRolloverPath, + ReadonlyField, ShrinkField, } from '../shared_fields'; @@ -238,6 +239,7 @@ export const HotPhase: FunctionComponent = () => { {} {license.canUseSearchableSnapshot() && } + )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts index 503cd65da655b..e56b0b21491f3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts @@ -19,3 +19,5 @@ export { SnapshotPoliciesField } from './snapshot_policies_field'; export { ShrinkField } from './shrink_field'; export { SearchableSnapshotField } from './searchable_snapshot_field'; + +export { ReadonlyField } from './readonly_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx new file mode 100644 index 0000000000000..16f78cc904295 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTextColor } from '@elastic/eui'; +import { LearnMoreLink } from '../../learn_more_link'; +import { ToggleFieldWithDescribedFormRow } from '../../described_form_row'; + +interface Props { + phase: 'hot' | 'warm'; +} + +export const ReadonlyField: React.FunctionComponent = ({ phase }) => { + return ( + + + + } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + switchProps={{ + 'data-test-subj': `${phase}-readonlySwitch`, + path: `_meta.${phase}.readonlyEnabled`, + }} + > +
+ + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index d572e7a2ed341..36a39eb7c110f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -27,6 +27,7 @@ import { SetPriorityInputField, DataTierAllocationField, ShrinkField, + ReadonlyField, } from '../shared_fields'; const i18nTexts = { @@ -173,6 +174,9 @@ export const WarmPhase: FunctionComponent = () => { {!isUsingSearchableSnapshotInHotPhase && } {!isUsingSearchableSnapshotInHotPhase && } + + + {/* Data tier allocation section */} { hot: { useRollover: Boolean(hot?.actions?.rollover), bestCompression: hot?.actions?.forcemerge?.index_codec === 'best_compression', + readonlyEnabled: Boolean(hot?.actions?.readonly), }, warm: { enabled: Boolean(warm), warmPhaseOnRollover: warm === undefined ? true : Boolean(warm.min_age === '0ms'), bestCompression: warm?.actions?.forcemerge?.index_codec === 'best_compression', dataTierAllocationType: determineDataTierAllocationType(warm?.actions), + readonlyEnabled: Boolean(warm?.actions?.readonly), }, cold: { enabled: Boolean(cold), diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts index 518a205b12303..b494e87b0bf6f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts @@ -44,6 +44,7 @@ const originalPolicy: SerializedPolicy = { index_codec: 'best_compression', max_num_segments: 22, }, + readonly: {}, set_priority: { priority: 1, }, @@ -63,6 +64,7 @@ const originalPolicy: SerializedPolicy = { some: 'value', }, }, + readonly: {}, set_priority: { priority: 10, }, @@ -170,6 +172,22 @@ describe('deserializer and serializer', () => { expect(result.phases.warm!.actions.forcemerge).toBeUndefined(); }); + it('removes the readonly action if it is disabled in hot', () => { + formInternal._meta.hot.readonlyEnabled = false; + + const result = serializer(formInternal); + + expect(result.phases.hot!.actions.readonly).toBeUndefined(); + }); + + it('removes the readonly action if it is disabled in warm', () => { + formInternal._meta.warm.readonlyEnabled = false; + + const result = serializer(formInternal); + + expect(result.phases.warm!.actions.readonly).toBeUndefined(); + }); + it('removes set priority if it is disabled in the form', () => { delete formInternal.phases.hot!.actions.set_priority; delete formInternal.phases.warm!.actions.set_priority; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 73a868c392f32..a292a888e78c4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -48,6 +48,10 @@ export const schema: FormSchema = { label: i18nTexts.editPolicy.bestCompressionFieldLabel, helpText: i18nTexts.editPolicy.bestCompressionFieldHelpText, }, + readonlyEnabled: { + defaultValue: false, + label: i18nTexts.editPolicy.readonlyEnabledFieldLabel, + }, }, warm: { enabled: { @@ -76,6 +80,10 @@ export const schema: FormSchema = { allocationNodeAttribute: { label: i18nTexts.editPolicy.allocationNodeAttributeFieldLabel, }, + readonlyEnabled: { + defaultValue: false, + label: i18nTexts.editPolicy.readonlyEnabledFieldLabel, + }, }, cold: { enabled: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index 91e175d49de25..75935f149534e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -68,9 +68,16 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( if (_meta.hot.bestCompression && hotPhaseActions.forcemerge) { hotPhaseActions.forcemerge.index_codec = 'best_compression'; } + + if (_meta.hot.readonlyEnabled) { + hotPhaseActions.readonly = hotPhaseActions.readonly ?? {}; + } else { + delete hotPhaseActions.readonly; + } } else { delete hotPhaseActions.rollover; delete hotPhaseActions.forcemerge; + delete hotPhaseActions.readonly; } if (!updatedPolicy.phases.hot!.actions?.set_priority) { @@ -117,6 +124,12 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( warmPhase.actions.forcemerge!.index_codec = 'best_compression'; } + if (_meta.warm.readonlyEnabled) { + warmPhase.actions.readonly = warmPhase.actions.readonly ?? {}; + } else { + delete warmPhase.actions.readonly; + } + if (!updatedPolicy.phases.warm?.actions?.set_priority) { delete warmPhase.actions.set_priority; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 75bd3c3e217af..f30a40fdd2bb9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -31,6 +31,9 @@ export const i18nTexts = { forceMergeEnabledFieldLabel: i18n.translate('xpack.indexLifecycleMgmt.forcemerge.enableLabel', { defaultMessage: 'Force merge data', }), + readonlyEnabledFieldLabel: i18n.translate('xpack.indexLifecycleMgmt.readonlyFieldLabel', { + defaultMessage: 'Make index read only', + }), maxNumSegmentsFieldLabel: i18n.translate( 'xpack.indexLifecycleMgmt.forceMerge.numberOfSegmentsLabel', { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 7d512936290af..f04acea0bbf0a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -25,11 +25,13 @@ interface HotPhaseMetaFields extends ForcemergeFields { useRollover: boolean; maxStorageSizeUnit?: string; maxAgeUnit?: string; + readonlyEnabled: boolean; } interface WarmPhaseMetaFields extends DataAllocationMetaFields, MinAgeField, ForcemergeFields { enabled: boolean; warmPhaseOnRollover: boolean; + readonlyEnabled: boolean; } interface ColdPhaseMetaFields extends DataAllocationMetaFields, MinAgeField { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 9c92af30097a2..0fa1ddeee820d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -19,7 +19,7 @@ export interface DataStreamsTabTestBed extends TestBed { goToDataStreamsList: () => void; clickEmptyPromptIndexTemplateLink: () => void; clickIncludeStatsSwitch: () => void; - clickIncludeManagedSwitch: () => void; + toggleViewFilterAt: (index: number) => void; clickReloadButton: () => void; clickNameAt: (index: number) => void; clickIndicesAt: (index: number) => void; @@ -82,9 +82,16 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { find } = testBed; - find('includeManagedSwitch').simulate('click'); + const toggleViewFilterAt = (index: number) => { + const { find, component } = testBed; + act(() => { + find('viewButton').simulate('click'); + }); + component.update(); + act(() => { + find('filterItem').at(index).simulate('click'); + }); + component.update(); }; const clickReloadButton = () => { @@ -197,7 +204,7 @@ export const setup = async (overridingDependencies: any = {}): Promise): DataSt privileges: { delete_index: true, }, + hidden: false, ...dataStream, }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 91502621d50c5..93899dece3308 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -19,6 +19,8 @@ import { createNonDataStreamIndex, } from './data_streams_tab.helpers'; +const nonBreakingSpace = ' '; + describe('Data Streams tab', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; @@ -82,6 +84,25 @@ describe('Data Streams tab', () => { // Assert against the text because the href won't be available, due to dependency upon our core mock. expect(findEmptyPromptIndexTemplateLink().text()).toBe('Fleet'); }); + + test('when hidden data streams are filtered by default, the table is rendered empty', async () => { + const hiddenDataStream = createDataStreamPayload({ + name: 'hidden-data-stream', + hidden: true, + }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); + + testBed = await setup({ + plugins: {}, + }); + + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + + testBed.component.update(); + expect(testBed.find('dataStreamTable').text()).toContain('No data streams found'); + }); }); describe('when there are data streams', () => { @@ -397,7 +418,6 @@ describe('Data Streams tab', () => { }); describe('managed data streams', () => { - const nonBreakingSpace = ' '; beforeEach(async () => { const managedDataStream = createDataStreamPayload({ name: 'managed-data-stream', @@ -429,8 +449,8 @@ describe('Data Streams tab', () => { ]); }); - test('turning off "Include managed" switch hides managed data streams', async () => { - const { exists, actions, component, table } = testBed; + test('turning off "managed" filter hides managed data streams', async () => { + const { actions, table } = testBed; let { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ @@ -438,15 +458,40 @@ describe('Data Streams tab', () => { ['', 'non-managed-data-stream', 'green', '1', 'Delete'], ]); - expect(exists('includeManagedSwitch')).toBe(true); + actions.toggleViewFilterAt(0); + + ({ tableCellsValues } = table.getMetaData('dataStreamTable')); + expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); + }); + }); + + describe('hidden data streams', () => { + beforeEach(async () => { + const hiddenDataStream = createDataStreamPayload({ + name: 'hidden-data-stream', + hidden: true, + }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); + testBed = await setup({ + history: createMemoryHistory(), + }); await act(async () => { - actions.clickIncludeManagedSwitch(); + testBed.actions.goToDataStreamsList(); }); - component.update(); + testBed.component.update(); + }); - ({ tableCellsValues } = table.getMetaData('dataStreamTable')); - expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); + test('show hidden data streams when filter is toggled', () => { + const { table, actions } = testBed; + + actions.toggleViewFilterAt(1); + + const { tableCellsValues } = table.getMetaData('dataStreamTable'); + + expect(tableCellsValues).toEqual([ + ['', `hidden-data-stream${nonBreakingSpace}Hidden`, 'green', '1', 'Delete'], + ]); }); }); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index fe7db99c98db1..333cb4b97f2aa 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -19,6 +19,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS maximum_timestamp: maxTimeStamp, _meta, privileges, + hidden, } = dataStreamFromEs; return { @@ -39,6 +40,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS maxTimeStamp, _meta, privileges, + hidden, }; } diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index fdfe6278eb985..fca10f85ab63c 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -38,6 +38,7 @@ export interface DataStreamFromEs { store_size?: string; maximum_timestamp?: number; privileges: PrivilegesFromEs; + hidden: boolean; } export interface DataStreamIndexFromEs { @@ -59,6 +60,7 @@ export interface DataStream { maxTimeStamp?: number; _meta?: Meta; privileges: Privileges; + hidden: boolean; } export interface DataStreamIndex { diff --git a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx index ca5297e399339..93791f8a58224 100644 --- a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx +++ b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx @@ -6,10 +6,34 @@ import { DataStream } from '../../../common'; -export const isManagedByIngestManager = (dataStream: DataStream): boolean => { +export const isFleetManaged = (dataStream: DataStream): boolean => { + // TODO check if the wording will change to 'fleet' return Boolean(dataStream._meta?.managed && dataStream._meta?.managed_by === 'ingest-manager'); }; -export const filterDataStreams = (dataStreams: DataStream[]): DataStream[] => { - return dataStreams.filter((dataStream: DataStream) => !isManagedByIngestManager(dataStream)); +export const filterDataStreams = ( + dataStreams: DataStream[], + visibleTypes: string[] +): DataStream[] => { + return dataStreams.filter((dataStream: DataStream) => { + // include all data streams that are neither hidden nor managed + if (!dataStream.hidden && !isFleetManaged(dataStream)) { + return true; + } + if (dataStream.hidden && visibleTypes.includes('hidden')) { + return true; + } + return isFleetManaged(dataStream) && visibleTypes.includes('managed'); + }); +}; + +export const isSelectedDataStreamHidden = ( + dataStreams: DataStream[], + selectedDataStreamName?: string +): boolean => { + return ( + !!selectedDataStreamName && + !!dataStreams.find((dataStream: DataStream) => dataStream.name === selectedDataStreamName) + ?.hidden + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx similarity index 100% rename from x-pack/plugins/index_management/public/application/sections/home/template_list/components/filter_list_button.tsx rename to x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/components/index.ts new file mode 100644 index 0000000000000..3df506583b65a --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FilterListButton, Filters } from './filter_list_button'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx new file mode 100644 index 0000000000000..e86dfe7f28585 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBadge, EuiBadgeGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataStream } from '../../../../../common'; +import { isFleetManaged } from '../../../lib/data_streams'; + +interface Props { + dataStream: DataStream; +} + +export const DataStreamsBadges: React.FunctionComponent = ({ dataStream }) => { + const badges = []; + if (isFleetManaged(dataStream)) { + badges.push( + + + + ); + } + if (dataStream.hidden) { + badges.push( + + + + ); + } + return badges.length > 0 ? ( + <> +   + {badges} + + ) : null; +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index ec47b2c062aa9..33fd1b3f18716 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -32,6 +32,7 @@ import { useUrlGenerator } from '../../../../services/use_url_generator'; import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; +import { DataStreamsBadges } from '../data_stream_badges'; interface DetailsListProps { details: Array<{ @@ -269,6 +270,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({

{dataStreamName} + {dataStream && }

diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index f43b9799082a0..64d874c76afb3 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -32,8 +32,10 @@ import { documentationService } from '../../../services/documentation'; import { Section } from '../home'; import { DataStreamTable } from './data_stream_table'; import { DataStreamDetailPanel } from './data_stream_detail_panel'; -import { filterDataStreams } from '../../../lib/data_streams'; +import { filterDataStreams, isSelectedDataStreamHidden } from '../../../lib/data_streams'; +import { FilterListButton, Filters } from '../components'; +export type DataStreamFilterName = 'managed' | 'hidden'; interface MatchParams { dataStreamName?: string; } @@ -45,7 +47,7 @@ export const DataStreamList: React.FunctionComponent { - const { isDeepLink } = extractQueryParams(search); + const { isDeepLink, includeHidden } = extractQueryParams(search); const decodedDataStreamName = attemptToURIDecode(dataStreamName); const { @@ -54,11 +56,111 @@ export const DataStreamList: React.FunctionComponent>({ + managed: { + name: i18n.translate('xpack.idxMgmt.dataStreamList.viewManagedLabel', { + defaultMessage: 'Fleet-managed data streams', + }), + checked: 'on', + }, + hidden: { + name: i18n.translate('xpack.idxMgmt.dataStreamList.viewHiddenLabel', { + defaultMessage: 'Hidden data streams', + }), + checked: includeHidden ? 'on' : 'off', + }, + }); + + const activateHiddenFilter = (shouldBeActive: boolean) => { + if (shouldBeActive && filters.hidden.checked === 'off') { + setFilters({ + ...filters, + hidden: { + ...filters.hidden, + checked: 'on', + }, + }); + } + }; + + const filteredDataStreams = useMemo(() => { + if (!dataStreams) { + // If dataStreams are not fetched, return empty array. + return []; + } + + const visibleTypes = Object.entries(filters) + .filter(([name, _filter]) => _filter.checked === 'on') + .map(([name]) => name); + + return filterDataStreams(dataStreams, visibleTypes); + }, [dataStreams, filters]); + + const renderHeader = () => { + return ( + + + + + {i18n.translate('xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + + + + + setIsIncludeStatsChecked(e.target.checked)} + data-test-subj="includeStatsSwitch" + /> + + + + + + + + + filters={filters} onChange={setFilters} /> + + + ); + }; + let content; if (isLoading) { @@ -150,94 +252,10 @@ export const DataStreamList: React.FunctionComponent ); } else if (Array.isArray(dataStreams) && dataStreams.length > 0) { - const filteredDataStreams = isIncludeManagedChecked - ? dataStreams - : filterDataStreams(dataStreams); + activateHiddenFilter(isSelectedDataStreamHidden(dataStreams, decodedDataStreamName)); content = ( <> - - - - - {i18n.translate('xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText', { - defaultMessage: 'Learn more.', - })} - - ), - }} - /> - - - - - - - setIsIncludeStatsChecked(e.target.checked)} - data-test-subj="includeStatsSwitch" - /> - - - - - - - - - - - setIsIncludeManagedChecked(e.target.checked)} - data-test-subj="includeManagedSwitch" - /> - - - - - - - - - + {renderHeader()} = ({ > {name} - {isManagedByIngestManager(dataStream) ? ( - -   - - - - - - - ) : null} + ); }, diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts index 3954ce04ca0b5..cccdcaf9389bd 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './filter_list_button'; - export * from './template_type_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx index 266003c5f8949..6edabbb2867a2 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx @@ -36,7 +36,7 @@ import { getIsLegacyFromQueryParams } from '../../../lib/index_templates'; import { TemplateTable } from './template_table'; import { TemplateDetails } from './template_details'; import { LegacyTemplateTable } from './legacy_templates/template_table'; -import { FilterListButton, Filters } from './components'; +import { FilterListButton, Filters } from '../components'; import { attemptToURIDecode } from '../../../../shared_imports'; type FilterName = 'managed' | 'cloudManaged' | 'system'; diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index d19383d892cbd..4124d8e897b5b 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -65,12 +65,24 @@ const enhanceDataStreams = ({ }); }; +const getDataStreams = (client: ElasticsearchClient, name = '*') => { + // TODO update when elasticsearch client has update requestParams for 'indices.getDataStream' + return client.transport.request({ + path: `/_data_stream/${encodeURIComponent(name)}`, + method: 'GET', + querystring: { + expand_wildcards: 'all', + }, + }); +}; + const getDataStreamsStats = (client: ElasticsearchClient, name = '*') => { return client.transport.request({ path: `/_data_stream/${encodeURIComponent(name)}/_stats`, method: 'GET', querystring: { human: true, + expand_wildcards: 'all', }, }); }; @@ -107,7 +119,7 @@ export function registerGetAllRoute({ try { let { body: { data_streams: dataStreams }, - } = await asCurrentUser.indices.getDataStream(); + } = await getDataStreams(asCurrentUser); let dataStreamsStats; let dataStreamsPrivileges; @@ -165,7 +177,7 @@ export function registerGetOneRoute({ body: { data_streams: dataStreamsStats }, }, ] = await Promise.all([ - asCurrentUser.indices.getDataStream({ name }), + getDataStreams(asCurrentUser, name), getDataStreamsStats(asCurrentUser, name), ]); diff --git a/x-pack/plugins/infra/.storybook/main.js b/x-pack/plugins/infra/.storybook/main.js index 1818aa44a9399..95e8ab8535a4f 100644 --- a/x-pack/plugins/infra/.storybook/main.js +++ b/x-pack/plugins/infra/.storybook/main.js @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -module.exports = require('@kbn/storybook').defaultConfig; +const defaultConfig = require('@kbn/storybook').defaultConfig; + +module.exports = { + ...defaultConfig, + stories: ['../**/*.stories.mdx', ...defaultConfig.stories], +}; diff --git a/x-pack/plugins/infra/common/http_api/overview_api.ts b/x-pack/plugins/infra/common/http_api/overview_api.ts new file mode 100644 index 0000000000000..612fa42773896 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/overview_api.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const OverviewMetricTypeRT = rt.keyof({ + percent: null, + number: null, + bytesPerSecond: null, +}); + +export const OverviewMetricRT = rt.type({ + type: rt.string, + value: rt.number, +}); + +export const OverviewResponseRT = rt.type({ + stats: rt.type({ + hosts: OverviewMetricRT, + cpu: OverviewMetricRT, + memory: OverviewMetricRT, + }), +}); + +export const OverviewRequestRT = rt.type({ + sourceId: rt.string, + timerange: rt.type({ + from: rt.number, + to: rt.number, + }), +}); + +export type OverviewMetricType = rt.TypeOf; +export type OverviewMetric = rt.TypeOf; +export type OverviewResponse = rt.TypeOf; +export type OverviewRequest = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/search_strategies/common/errors.ts b/x-pack/plugins/infra/common/search_strategies/common/errors.ts index 4f7954c09c48b..3a08564f34941 100644 --- a/x-pack/plugins/infra/common/search_strategies/common/errors.ts +++ b/x-pack/plugins/infra/common/search_strategies/common/errors.ts @@ -6,12 +6,22 @@ import * as rt from 'io-ts'; -const genericErrorRT = rt.type({ +const abortedRequestSearchStrategyErrorRT = rt.type({ + type: rt.literal('aborted'), +}); + +export type AbortedRequestSearchStrategyError = rt.TypeOf< + typeof abortedRequestSearchStrategyErrorRT +>; + +const genericSearchStrategyErrorRT = rt.type({ type: rt.literal('generic'), message: rt.string, }); -const shardFailureErrorRT = rt.type({ +export type GenericSearchStrategyError = rt.TypeOf; + +const shardFailureSearchStrategyErrorRT = rt.type({ type: rt.literal('shardFailure'), shardInfo: rt.type({ shard: rt.number, @@ -21,6 +31,12 @@ const shardFailureErrorRT = rt.type({ message: rt.string, }); -export const searchStrategyErrorRT = rt.union([genericErrorRT, shardFailureErrorRT]); +export type ShardFailureSearchStrategyError = rt.TypeOf; + +export const searchStrategyErrorRT = rt.union([ + abortedRequestSearchStrategyErrorRT, + genericSearchStrategyErrorRT, + shardFailureSearchStrategyErrorRT, +]); export type SearchStrategyError = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap index d71e1feb575e4..db931905b25db 100644 --- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap +++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap @@ -3,204 +3,18 @@ exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = ` Object { "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)", - "series": Object { - "inboundTraffic": Object { - "coordinates": Array [ - Object { - "x": 1593630455000, - "y": 0, - }, - Object { - "x": 1593630755000, - "y": 3.5, - }, - Object { - "x": 1593631055000, - "y": 3.5, - }, - Object { - "x": 1593631355000, - "y": 8.5, - }, - Object { - "x": 1593631655000, - "y": 3.5, - }, - Object { - "x": 1593631955000, - "y": 2.5, - }, - Object { - "x": 1593632255000, - "y": 1.5, - }, - Object { - "x": 1593632555000, - "y": 1.5, - }, - Object { - "x": 1593632855000, - "y": 3.5, - }, - Object { - "x": 1593633155000, - "y": 2.5, - }, - Object { - "x": 1593633455000, - "y": 1.5, - }, - Object { - "x": 1593633755000, - "y": 1.5, - }, - Object { - "x": 1593634055000, - "y": 2.5, - }, - Object { - "x": 1593634355000, - "y": 0, - }, - Object { - "x": 1593634655000, - "y": 10.5, - }, - Object { - "x": 1593634955000, - "y": 5.5, - }, - Object { - "x": 1593635255000, - "y": 13.5, - }, - Object { - "x": 1593635555000, - "y": 9.5, - }, - Object { - "x": 1593635855000, - "y": 7.5, - }, - Object { - "x": 1593636155000, - "y": 3, - }, - Object { - "x": 1593636455000, - "y": 3.5, - }, - ], - }, - "outboundTraffic": Object { - "coordinates": Array [ - Object { - "x": 1593630455000, - "y": 0, - }, - Object { - "x": 1593630755000, - "y": 4, - }, - Object { - "x": 1593631055000, - "y": 4, - }, - Object { - "x": 1593631355000, - "y": 9, - }, - Object { - "x": 1593631655000, - "y": 4, - }, - Object { - "x": 1593631955000, - "y": 2.5, - }, - Object { - "x": 1593632255000, - "y": 2, - }, - Object { - "x": 1593632555000, - "y": 2, - }, - Object { - "x": 1593632855000, - "y": 4, - }, - Object { - "x": 1593633155000, - "y": 3, - }, - Object { - "x": 1593633455000, - "y": 2, - }, - Object { - "x": 1593633755000, - "y": 2, - }, - Object { - "x": 1593634055000, - "y": 2.5, - }, - Object { - "x": 1593634355000, - "y": 1, - }, - Object { - "x": 1593634655000, - "y": 11, - }, - Object { - "x": 1593634955000, - "y": 6, - }, - Object { - "x": 1593635255000, - "y": 14, - }, - Object { - "x": 1593635555000, - "y": 10, - }, - Object { - "x": 1593635855000, - "y": 8, - }, - Object { - "x": 1593636155000, - "y": 3, - }, - Object { - "x": 1593636455000, - "y": 4, - }, - ], - }, - }, "stats": Object { "cpu": Object { "type": "percent", - "value": 0.0015, + "value": 0.10691011235955057, }, "hosts": Object { "type": "number", - "value": 2, - }, - "inboundTraffic": Object { - "type": "bytesPerSecond", - "value": 3.5, + "value": 1, }, "memory": Object { "type": "percent", - "value": 0.0015, - }, - "outboundTraffic": Object { - "type": "bytesPerSecond", - "value": 3, + "value": 0.5389775280898876, }, }, } diff --git a/x-pack/plugins/infra/public/components/centered_flyout_body.tsx b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx new file mode 100644 index 0000000000000..ec762610f36c4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx @@ -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 { EuiFlyoutBody } from '@elastic/eui'; +import { euiStyled } from '../../../observability/public'; + +export const CenteredEuiFlyoutBody = euiStyled(EuiFlyoutBody)` + & .euiFlyoutBody__overflow { + display: flex; + flex-direction: column; + } + + & .euiFlyoutBody__overflowContent { + align-items: center; + align-self: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + overflow: hidden; + } +`; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx new file mode 100644 index 0000000000000..4e46e5fdd3f45 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchErrorCallout } from './data_search_error_callout'; + +export default { + title: 'infra/dataSearch/DataSearchErrorCallout', + decorators: [ + (wrappedStory) => ( + +
{wrappedStory()}
+
+ ), + ], + parameters: { + layout: 'padded', + }, + argTypes: { + errors: { + control: { + type: 'object', + }, + }, + }, +} as Meta; + +type DataSearchErrorCalloutProps = PropsOf; + +const DataSearchErrorCalloutTemplate: Story = (args) => ( + +); + +const commonArgs = { + title: 'Failed to load data', + errors: [ + { + type: 'generic' as const, + message: 'A generic error message', + }, + { + type: 'shardFailure' as const, + shardInfo: { + index: 'filebeat-7.9.3-2020.12.01-000003', + node: 'a45hJUm3Tba4U8MkvkCU_g', + shard: 0, + }, + message: 'No mapping found for [@timestamp] in order to sort on', + }, + ], +}; + +export const ErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCallout.args = { + ...commonArgs, +}; + +export const ErrorCalloutWithRetry = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCalloutWithRetry.args = { + ...commonArgs, +}; +ErrorCalloutWithRetry.argTypes = { + onRetry: { action: 'retrying' }, +}; + +export const AbortedErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +AbortedErrorCallout.args = { + ...commonArgs, + errors: [ + { + type: 'aborted', + }, + ], +}; +AbortedErrorCallout.argTypes = { + onRetry: { action: 'retrying' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx new file mode 100644 index 0000000000000..a0ed3bed95078 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { + AbortedRequestSearchStrategyError, + GenericSearchStrategyError, + SearchStrategyError, + ShardFailureSearchStrategyError, +} from '../../common/search_strategies/common/errors'; + +export const DataSearchErrorCallout: React.FC<{ + title: React.ReactNode; + errors: SearchStrategyError[]; + onRetry?: () => void; +}> = ({ errors, onRetry, title }) => { + const calloutColor = errors.some((error) => error.type !== 'aborted') ? 'danger' : 'warning'; + + return ( + + {errors?.map((error, errorIndex) => ( + + ))} + {onRetry ? ( + + + + ) : null} + + ); +}; + +const DataSearchErrorMessage: React.FC<{ error: SearchStrategyError }> = ({ error }) => { + if (error.type === 'aborted') { + return ; + } else if (error.type === 'shardFailure') { + return ; + } else { + return ; + } +}; + +const AbortedRequestErrorMessage: React.FC<{ + error?: AbortedRequestSearchStrategyError; +}> = ({}) => ( + +); + +const GenericErrorMessage: React.FC<{ error: GenericSearchStrategyError }> = ({ error }) => ( +

{error.message ?? `${error}`}

+); + +const ShardFailureErrorMessage: React.FC<{ error: ShardFailureSearchStrategyError }> = ({ + error, +}) => ( + +); diff --git a/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx new file mode 100644 index 0000000000000..d5293a7282305 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchProgress } from './data_search_progress'; + +export default { + title: 'infra/dataSearch/DataSearchProgress', + decorators: [ + (wrappedStory) => ( + +
{wrappedStory()}
+
+ ), + ], + parameters: { + layout: 'padded', + }, +} as Meta; + +type DataSearchProgressProps = PropsOf; + +const DataSearchProgressTemplate: Story = (args) => ( + +); + +export const UndeterminedProgress = DataSearchProgressTemplate.bind({}); + +export const DeterminedProgress = DataSearchProgressTemplate.bind({}); + +DeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; + +export const CancelableDeterminedProgress = DataSearchProgressTemplate.bind({}); + +CancelableDeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; +CancelableDeterminedProgress.argTypes = { + onCancel: { action: 'canceled' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_progress.tsx b/x-pack/plugins/infra/public/components/data_search_progress.tsx new file mode 100644 index 0000000000000..bf699ac976232 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +export const DataSearchProgress: React.FC<{ + label?: React.ReactNode; + maxValue?: number; + onCancel?: () => void; + value?: number; +}> = ({ label, maxValue, onCancel, value }) => { + const valueText = useMemo( + () => + Number.isFinite(maxValue) && Number.isFinite(value) ? `${value} / ${maxValue}` : undefined, + [value, maxValue] + ); + + return ( + + + + + {onCancel ? ( + + + + ) : null} + + ); +}; + +const cancelButtonLabel = i18n.translate('xpack.infra.dataSearch.cancelButtonLabel', { + defaultMessage: 'Cancel request', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx new file mode 100644 index 0000000000000..44e9902e0413f --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { + LogEntry, + LogEntryField, +} from '../../../../common/search_strategies/log_entries/log_entry'; +import { TimeKey } from '../../../../common/time'; +import { FieldValue } from '../log_text_stream/field_value'; + +export const LogEntryFieldsTable: React.FC<{ + logEntry: LogEntry; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; +}> = ({ logEntry, onSetFieldFilter }) => { + const createSetFilterHandler = useMemo( + () => + onSetFieldFilter + ? (field: LogEntryField) => () => { + onSetFieldFilter?.(`${field.field}:"${field.value}"`, logEntry.id, logEntry.key); + } + : undefined, + [logEntry, onSetFieldFilter] + ); + + const columns = useMemo>>( + () => [ + { + field: 'field', + name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { + defaultMessage: 'Field', + }), + sortable: true, + }, + { + actions: [ + { + type: 'icon', + icon: 'filter', + name: setFilterButtonLabel, + description: setFilterButtonDescription, + available: () => !!createSetFilterHandler, + onClick: (item) => createSetFilterHandler?.(item)(), + }, + ], + }, + { + field: 'value', + name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { + defaultMessage: 'Value', + }), + render: (_name: string, item: LogEntryField) => ( + + ), + }, + ], + [createSetFilterHandler] + ); + + return ( + + columns={columns} + items={logEntry.fields} + search={searchOptions} + sorting={initialSortingOptions} + /> + ); +}; + +const emptyHighlightTerms: string[] = []; + +const initialSortingOptions = { + sort: { + field: 'field', + direction: 'asc' as const, + }, +}; + +const searchOptions = { + box: { + incremental: true, + schema: true, + }, +}; + +const setFilterButtonLabel = i18n.translate('xpack.infra.logFlyout.filterAriaLabel', { + defaultMessage: 'Filter', +}); + +const setFilterButtonDescription = i18n.translate('xpack.infra.logFlyout.setFilterTooltip', { + defaultMessage: 'View event with filter', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index bc0f6dc97017a..5684d4068f3be 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -5,132 +5,60 @@ */ import { - EuiBasicTableColumn, - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, - EuiInMemoryTable, EuiSpacer, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import React, { useCallback, useMemo } from 'react'; -import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryField, -} from '../../../../common/search_strategies/log_entries/log_entry'; +import React, { useEffect } from 'react'; import { TimeKey } from '../../../../common/time'; -import { InfraLoadingPanel } from '../../loading'; -import { FieldValue } from '../log_text_stream/field_value'; +import { useLogEntry } from '../../../containers/logs/log_entry'; +import { CenteredEuiFlyoutBody } from '../../centered_flyout_body'; +import { DataSearchErrorCallout } from '../../data_search_error_callout'; +import { DataSearchProgress } from '../../data_search_progress'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; +import { LogEntryFieldsTable } from './log_entry_fields_table'; export interface LogEntryFlyoutProps { - flyoutError: string | null; - flyoutItem: LogEntry | null; - setFlyoutVisibility: (visible: boolean) => void; - setFilter: (filter: string, flyoutItemId: string, timeKey?: TimeKey) => void; - loading: boolean; + logEntryId: string | null | undefined; + onCloseFlyout: () => void; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; + sourceId: string | null | undefined; } -const emptyHighlightTerms: string[] = []; - -const initialSortingOptions = { - sort: { - field: 'field', - direction: 'asc' as const, - }, -}; - -const searchOptions = { - box: { - incremental: true, - schema: true, - }, -}; - export const LogEntryFlyout = ({ - flyoutError, - flyoutItem, - loading, - setFlyoutVisibility, - setFilter, + logEntryId, + onCloseFlyout, + onSetFieldFilter, + sourceId, }: LogEntryFlyoutProps) => { - const createFilterHandler = useCallback( - (field: LogEntryField) => () => { - if (!flyoutItem) { - return; - } - - const filter = `${field.field}:"${field.value}"`; - const timestampMoment = moment(flyoutItem.key.time); - let target; + const { + cancelRequest: cancelLogEntryRequest, + errors: logEntryErrors, + fetchLogEntry, + isRequestRunning, + loaded: logEntryRequestProgress, + logEntry, + total: logEntryRequestTotal, + } = useLogEntry({ + sourceId, + logEntryId, + }); - if (timestampMoment.isValid()) { - target = { - time: timestampMoment.valueOf(), - tiebreaker: flyoutItem.key.tiebreaker, - }; - } - - setFilter(filter, flyoutItem.id, target); - }, - [flyoutItem, setFilter] - ); - - const closeFlyout = useCallback(() => setFlyoutVisibility(false), [setFlyoutVisibility]); - - const columns = useMemo>>( - () => [ - { - field: 'field', - name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { - defaultMessage: 'Field', - }), - sortable: true, - }, - { - field: 'value', - name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { - defaultMessage: 'Value', - }), - render: (_name: string, item: LogEntryField) => ( - - - - - - - ), - }, - ], - [createFilterHandler] - ); + useEffect(() => { + if (sourceId && logEntryId) { + fetchLogEntry(); + } + }, [fetchLogEntry, sourceId, logEntryId]); return ( - + @@ -140,12 +68,12 @@ export const LogEntryFlyout = ({ defaultMessage="Details for log entry {logEntryId}" id="xpack.infra.logFlyout.flyoutTitle" values={{ - logEntryId: flyoutItem ? {flyoutItem.id} : '', + logEntryId: logEntryId ? {logEntryId} : '', }} /> - {flyoutItem ? ( + {logEntry ? ( <> @@ -153,7 +81,7 @@ export const LogEntryFlyout = ({ id="xpack.infra.logFlyout.flyoutSubTitle" defaultMessage="From index {indexName}" values={{ - indexName: {flyoutItem.index}, + indexName: {logEntry.index}, }} /> @@ -161,40 +89,54 @@ export const LogEntryFlyout = ({ ) : null} - {flyoutItem !== null ? : null} + {logEntry ? : null} - - {loading ? ( - - +
+ +
+ + ) : logEntry ? ( + 0 ? ( + + ) : undefined + } + > + + + ) : ( + +
+ - - ) : flyoutItem ? ( - - columns={columns} - items={flyoutItem.fields} - search={searchOptions} - sorting={initialSortingOptions} - /> - ) : ( - {flyoutError} - )} - +
+
+ )}
); }; -export const InfraFlyoutLoadingPanel = euiStyled.div` - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -`; +const loadingProgressMessage = i18n.translate('xpack.infra.logFlyout.loadingMessage', { + defaultMessage: 'Searching log entry in shards', +}); + +const loadingErrorCalloutTitle = i18n.translate('xpack.infra.logFlyout.loadingErrorCalloutTitle', { + defaultMessage: 'Error while searching the log entry', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index ab0f0ac78529e..3c86ce3e32526 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -51,8 +51,7 @@ interface ScrollableLogTextStreamViewProps { }) => any; loadNewerItems: () => void; reloadItems: () => void; - setFlyoutItem?: (id: string) => void; - setFlyoutVisibility?: (visible: boolean) => void; + onOpenLogEntryFlyout?: (logEntryId?: string) => void; setContextEntry?: (entry: LogEntry) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; @@ -143,15 +142,14 @@ export class ScrollableLogTextStreamView extends React.PureComponent< lastLoadedTime, updateDateRange, startLiveStreaming, - setFlyoutItem, - setFlyoutVisibility, + onOpenLogEntryFlyout, setContextEntry, } = this.props; const hideScrollbar = this.props.hideScrollbar ?? true; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; - const hasFlyoutAction = !!(setFlyoutItem && setFlyoutVisibility); + const hasFlyoutAction = !!onOpenLogEntryFlyout; const hasContextAction = !!setContextEntry; return ( @@ -305,12 +303,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } private handleOpenFlyout = (id: string) => { - const { setFlyoutItem, setFlyoutVisibility } = this.props; - - if (setFlyoutItem && setFlyoutVisibility) { - setFlyoutItem(id); - setFlyoutVisibility(true); - } + this.props.onOpenLogEntryFlyout?.(id); }; private handleOpenViewLogInContext = (entry: LogEntry) => { diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts deleted file mode 100644 index 764de1d34a3bf..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts +++ /dev/null @@ -1,31 +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 { ISearchStart } from '../../../../../../../../src/plugins/data/public'; -import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { - LogEntry, - LogEntrySearchRequestParams, - logEntrySearchRequestParamsRT, - logEntrySearchResponsePayloadRT, - LOG_ENTRY_SEARCH_STRATEGY, -} from '../../../../../common/search_strategies/log_entries/log_entry'; - -export { LogEntry }; - -export const fetchLogEntry = async ( - requestArgs: LogEntrySearchRequestParams, - search: ISearchStart -) => { - const response = await search - .search( - { params: logEntrySearchRequestParamsRT.encode(requestArgs) }, - { strategy: LOG_ENTRY_SEARCH_STRATEGY } - ) - .toPromise(); - - return decodeOrThrow(logEntrySearchResponsePayloadRT)(response.rawResponse); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entry.ts new file mode 100644 index 0000000000000..af8618b8be565 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entry.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + logEntrySearchRequestParamsRT, + logEntrySearchResponsePayloadRT, + LOG_ENTRY_SEARCH_STRATEGY, +} from '../../../common/search_strategies/log_entries/log_entry'; +import { useDataSearch, useLatestPartialDataSearchResponse } from '../../utils/data_search'; + +export const useLogEntry = ({ + sourceId, + logEntryId, +}: { + sourceId: string | null | undefined; + logEntryId: string | null | undefined; +}) => { + const { search: fetchLogEntry, requests$: logEntrySearchRequests$ } = useDataSearch({ + getRequest: useCallback(() => { + return !!logEntryId && !!sourceId + ? { + request: { + params: logEntrySearchRequestParamsRT.encode({ sourceId, logEntryId }), + }, + options: { strategy: LOG_ENTRY_SEARCH_STRATEGY }, + } + : null; + }, [sourceId, logEntryId]), + }); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, + } = useLatestPartialDataSearchResponse( + logEntrySearchRequests$, + null, + decodeLogEntrySearchResponse + ); + + return { + cancelRequest, + errors: latestResponseErrors, + fetchLogEntry, + isRequestRunning, + isResponsePartial, + loaded, + logEntry: latestResponseData ?? null, + total, + }; +}; + +const decodeLogEntrySearchResponse = decodeOrThrow(logEntrySearchResponsePayloadRT); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 121f0e6b651dc..7f35af5800518 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -6,12 +6,8 @@ import createContainer from 'constate'; import { isString } from 'lodash'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import React, { useCallback, useState } from 'react'; import { UrlStateContainer } from '../../utils/url_state'; -import { useTrackedPromise } from '../../utils/use_tracked_promise'; -import { fetchLogEntry } from './log_entries/api/fetch_log_entry'; -import { useLogSourceContext } from './log_source'; export enum FlyoutVisibility { hidden = 'hidden', @@ -25,97 +21,78 @@ export interface FlyoutOptionsUrlState { } export const useLogFlyout = () => { - const { services } = useKibanaContextForPlugin(); - const { sourceId } = useLogSourceContext(); - const [flyoutVisible, setFlyoutVisibility] = useState(false); - const [flyoutId, setFlyoutId] = useState(null); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [logEntryId, setLogEntryId] = useState(null); const [surroundingLogsId, setSurroundingLogsId] = useState(null); - const [loadFlyoutItemRequest, loadFlyoutItem] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async () => { - if (!flyoutId) { - throw new Error('Failed to load log entry: Id not specified.'); - } - return await fetchLogEntry({ sourceId, logEntryId: flyoutId }, services.data.search); - }, - }, - [sourceId, flyoutId] - ); - - const isLoading = useMemo(() => { - return loadFlyoutItemRequest.state === 'pending'; - }, [loadFlyoutItemRequest.state]); - - useEffect(() => { - if (flyoutId) { - loadFlyoutItem(); + const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + const openFlyout = useCallback((newLogEntryId?: string) => { + if (newLogEntryId) { + setLogEntryId(newLogEntryId); } - }, [loadFlyoutItem, flyoutId]); + setIsFlyoutOpen(true); + }, []); return { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + closeFlyout, + openFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - isLoading, - flyoutItem: - loadFlyoutItemRequest.state === 'resolved' ? loadFlyoutItemRequest.value.data : null, - flyoutError: - loadFlyoutItemRequest.state === 'rejected' ? `${loadFlyoutItemRequest.value}` : null, }; }; export const LogFlyout = createContainer(useLogFlyout); +export const [LogEntryFlyoutProvider, useLogEntryFlyoutContext] = LogFlyout; export const WithFlyoutOptionsUrlState = () => { const { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + openFlyout, + closeFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - } = useContext(LogFlyout.Context); + } = useLogEntryFlyoutContext(); return ( { if (newUrlState && newUrlState.flyoutId) { - setFlyoutId(newUrlState.flyoutId); + setLogEntryId(newUrlState.flyoutId); } if (newUrlState && newUrlState.surroundingLogsId) { setSurroundingLogsId(newUrlState.surroundingLogsId); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} onInitialize={(initialUrlState) => { if (initialUrlState && initialUrlState.flyoutId) { - setFlyoutId(initialUrlState.flyoutId); + setLogEntryId(initialUrlState.flyoutId); } if (initialUrlState && initialUrlState.surroundingLogsId) { setSurroundingLogsId(initialUrlState.surroundingLogsId); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} /> diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 135b4ea9a5335..c22e09750eac3 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -9,7 +9,7 @@ import { createMetricsHasData, createMetricsFetchData } from './metrics_overview import { CoreStart } from 'kibana/public'; import { InfraClientStartDeps, InfraClientStartExports } from './types'; import moment from 'moment'; -import { FAKE_SNAPSHOT_RESPONSE } from './test_utils'; +import { FAKE_OVERVIEW_RESPONSE } from './test_utils'; function setup() { const core = coreMock.createStart(); @@ -51,7 +51,7 @@ describe('Metrics UI Observability Homepage Functions', () => { describe('createMetricsFetchData()', () => { it('should just work', async () => { const { core, mockedGetStartServices } = setup(); - core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE); + core.http.post.mockResolvedValue(FAKE_OVERVIEW_RESPONSE); const fetchData = createMetricsFetchData(mockedGetStartServices); const endTime = moment('2020-07-02T13:25:11.629Z'); const startTime = endTime.clone().subtract(1, 'h'); @@ -68,20 +68,12 @@ describe('Metrics UI Observability Homepage Functions', () => { bucketSize, }); expect(core.http.post).toHaveBeenCalledTimes(1); - expect(core.http.post).toHaveBeenCalledWith('/api/metrics/snapshot', { + expect(core.http.post).toHaveBeenCalledWith('/api/metrics/overview', { body: JSON.stringify({ sourceId: 'default', - metrics: [{ type: 'cpu' }, { type: 'memory' }, { type: 'rx' }, { type: 'tx' }], - groupBy: [], - nodeType: 'host', - includeTimeseries: true, - overrideCompositeSize: 5, timerange: { from: startTime.valueOf(), to: endTime.valueOf(), - interval: '300s', - forceInterval: true, - ignoreLookback: true, }, }), }); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 79e0850635138..97aebb03e113e 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -4,15 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isFinite, isNumber, sum } from 'lodash'; import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public'; -import { - SnapshotMetricInput, - SnapshotNode, - SnapshotNodeResponse, - SnapshotRequest, -} from '../common/http_api/snapshot_api'; -import { SnapshotMetricType } from '../common/inventory_models/types'; +import { OverviewRequest, OverviewResponse } from '../common/http_api/overview_api'; import { InfraClientCoreSetup } from './types'; export const createMetricsHasData = ( @@ -26,110 +19,39 @@ export const createMetricsHasData = ( return results.hasData; }; -export const average = (values: number[]) => (values.length ? sum(values) / values.length : 0); - -export const combineNodesBy = ( - metric: SnapshotMetricType, - nodes: SnapshotNode[], - combinator: (values: number[]) => number -) => { - const values = nodes.reduce((acc, node) => { - const snapshotMetric = node.metrics.find((m) => m.name === metric); - if (snapshotMetric?.value != null && isFinite(snapshotMetric.value)) { - acc.push(snapshotMetric.value); - } - return acc; - }, [] as number[]); - return combinator(values); -}; - -interface CombinedRow { - values: number[]; - timestamp: number; -} - -export const combineNodeTimeseriesBy = ( - metric: SnapshotMetricType, - nodes: SnapshotNode[], - combinator: (values: number[]) => number -) => { - const combinedTimeseries = nodes.reduce((acc, node) => { - const snapshotMetric = node.metrics.find((m) => m.name === metric); - if (snapshotMetric && snapshotMetric.timeseries) { - snapshotMetric.timeseries.rows.forEach((row) => { - const combinedRow = acc.find((r) => r.timestamp === row.timestamp); - if (combinedRow) { - combinedRow.values.push(isNumber(row.metric_0) ? row.metric_0 : 0); - } else { - acc.push({ - timestamp: row.timestamp, - values: [isNumber(row.metric_0) ? row.metric_0 : 0], - }); - } - }); - } - return acc; - }, [] as CombinedRow[]); - return combinedTimeseries.map((row) => ({ x: row.timestamp, y: combinator(row.values) })); -}; - export const createMetricsFetchData = ( getStartServices: InfraClientCoreSetup['getStartServices'] -) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => { +) => async ({ absoluteTime }: FetchDataParams): Promise => { const [coreServices] = await getStartServices(); const { http } = coreServices; const { start, end } = absoluteTime; - const snapshotRequest: SnapshotRequest = { + const overviewRequest: OverviewRequest = { sourceId: 'default', - metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[], - groupBy: [], - nodeType: 'host', - includeTimeseries: true, - overrideCompositeSize: 5, timerange: { from: start, to: end, - interval: bucketSize, - forceInterval: true, - ignoreLookback: true, }, }; - const results = await http.post('/api/metrics/snapshot', { - body: JSON.stringify(snapshotRequest), + const results = await http.post('/api/metrics/overview', { + body: JSON.stringify(overviewRequest), }); return { appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`, stats: { hosts: { type: 'number', - value: results.nodes.length, + value: results.stats.hosts.value, }, cpu: { type: 'percent', - value: combineNodesBy('cpu', results.nodes, average), + value: results.stats.cpu.value, }, memory: { type: 'percent', - value: combineNodesBy('memory', results.nodes, average), - }, - inboundTraffic: { - type: 'bytesPerSecond', - value: combineNodesBy('rx', results.nodes, average), - }, - outboundTraffic: { - type: 'bytesPerSecond', - value: combineNodesBy('tx', results.nodes, average), - }, - }, - series: { - inboundTraffic: { - coordinates: combineNodeTimeseriesBy('rx', results.nodes, average), - }, - outboundTraffic: { - coordinates: combineNodeTimeseriesBy('tx', results.nodes, average), + value: results.stats.memory.value, }, }, }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index bb0c9196fb0cc..c4a464a4cffad 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -7,21 +7,25 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; -import { encode, RisonValue } from 'rison-node'; import { stringify } from 'query-string'; -import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { encode, RisonValue } from 'rison-node'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; +import { TimeKey } from '../../../../common/time'; import { CategoryJobNoticesSection, LogAnalysisJobProblemIndicator, } from '../../../components/logging/log_analysis_job_status'; import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { LogEntryFlyout } from '../../../components/logging/log_entry_flyout'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis/log_analysis_capabilities'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { useLogEntryFlyoutContext } from '../../../containers/logs/log_flyout'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useInterval } from '../../../hooks/use_interval'; import { AnomaliesResults } from './sections/anomalies'; @@ -31,9 +35,6 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; -import { LogEntryFlyout, LogEntryFlyoutProps } from '../../../components/logging/log_entry_flyout'; -import { LogFlyout } from '../../../containers/logs/log_flyout'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -77,6 +78,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { setAutoRefresh, } = useLogAnalysisResultsUrlState(); + const { + closeFlyout: closeLogEntryFlyout, + isFlyoutOpen: isLogEntryFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); + const [queryTimeRange, setQueryTimeRange] = useState<{ value: TimeRange; lastChangedTime: number; @@ -85,8 +92,8 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { lastChangedTime: Date.now(), })); - const linkToLogStream = useCallback( - (filter, id, timeKey) => { + const linkToLogStream = useCallback( + (filter: string, id: string, timeKey?: TimeKey) => { const params = { logPosition: encode({ end: moment(queryTimeRange.value.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), @@ -144,14 +151,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { filteredDatasets: selectedDatasets, }); - const { - flyoutVisible, - setFlyoutVisibility, - flyoutError, - flyoutItem, - isLoading: isFlyoutLoading, - } = useContext(LogFlyout.Context); - const handleQueryTimeRangeChange = useCallback( ({ start: startTime, end: endTime }: { start: string; end: string }) => { setQueryTimeRange({ @@ -305,14 +304,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - - {flyoutVisible ? ( + {isLogEntryFlyoutOpen ? ( ) : null} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index a226486666095..b639cecf676ad 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useState, useContext } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; @@ -37,7 +37,7 @@ import { } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; import { LogEntryAnomaly } from '../../../../../../common/http_api'; -import { LogFlyout } from '../../../../../containers/logs/log_flyout'; +import { useLogEntryFlyoutContext } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -88,7 +88,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ const setItemIsHovered = useCallback(() => setIsHovered(true), []); const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); - const { setFlyoutVisibility, setFlyoutId } = useContext(LogFlyout.Context); + const { openFlyout: openLogEntryFlyout } = useLogEntryFlyoutContext(); // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -129,8 +129,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ { label: VIEW_DETAILS_LABEL, onClick: () => { - setFlyoutId(id); - setFlyoutVisibility(true); + openLogEntryFlyout(id); }, }, { @@ -144,13 +143,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ href: viewAnomalyInMachineLearningLinkProps.href, }, ]; - }, [ - id, - setFlyoutId, - setFlyoutVisibility, - viewInStreamLinkProps, - viewAnomalyInMachineLearningLinkProps, - ]); + }, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); return ( { const { sourceConfiguration, sourceId } = useLogSourceContext(); const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { - setFlyoutVisibility, - flyoutVisible, - setFlyoutId, surroundingLogsId, setSurroundingLogsId, - flyoutItem, - flyoutError, - isLoading, - } = useContext(LogFlyoutState.Context); + closeFlyout: closeLogEntryFlyout, + openFlyout: openLogEntryFlyout, + isFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { @@ -76,13 +74,12 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { - {flyoutVisible ? ( + {isFlyoutOpen ? ( ) : null} @@ -116,8 +113,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { scale={textScale} target={targetPosition} wrap={textWrap} - setFlyoutItem={setFlyoutId} - setFlyoutVisibility={setFlyoutVisibility} + onOpenLogEntryFlyout={openLogEntryFlyout} setContextEntry={setContextEntry} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} 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 2e5ddab77d374..2f312d9ee64ac 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 @@ -103,7 +103,18 @@ export const Layout = () => { if (currentView != null || !shouldLoadDefault) { reload(); } - }, [reload, currentView, shouldLoadDefault]); + + /** + * INFO: why disable exhaustive-deps + * We need to wait on the currentView not to be null because it is loaded async and could change the view state. + * We don't actually need to watch the value of currentView though, since the view state will be synched up by the + * changing params in the reload method so we should only "watch" the reload method. + * + * TODO: Should refactor this in the future to make it more clear where all the view state is coming + * from and it's precedence [query params, localStorage, defaultView, out of the box view] + */ + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [reload, shouldLoadDefault]); useEffect(() => { setShowLoading(true); @@ -156,18 +167,20 @@ export const Layout = () => { bottomMargin={height} topMargin={topActionHeight} /> - - - + > + + + )} )} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx index 03fb53898e316..bf4682e65815c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx @@ -16,6 +16,7 @@ import { InfraWaffleMapNode, InfraWaffleMapOptions, } from '../../../../../lib/lib'; +import { ConditionalToolTip } from './conditional_tooltip'; import { colorFromValue } from '../../lib/color_from_value'; import { InventoryItemType } from '../../../../../../common/inventory_models/types'; import { NodeContextPopover } from '../node_details/overlay'; @@ -55,6 +56,8 @@ export const Node = class extends React.PureComponent { values: { nodeName: node.name }, }); + const nodeBorder = this.state.isOverlayOpen ? { border: 'solid 4px #000' } : undefined; + return ( <> { popoverPosition="downCenter" openNewOverlay={this.toggleNewOverlay} > - + { + // INFO: We currently only use the "to" time, but in the future we may do more. + const [getTime] = useKibanaTimefilterTime({ from: 'now', to: 'now' }); + const kibanaTime = DateMath.parse(getTime().to); const [urlState, setUrlState] = useUrlState({ - defaultState: DEFAULT_WAFFLE_TIME_STATE, + defaultState: { + ...DEFAULT_WAFFLE_TIME_STATE, + currentTime: kibanaTime ? kibanaTime.toDate().getTime() : Date.now(), + }, decodeUrlState, encodeUrlState, urlStateKey: 'waffleTime', diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx index e312fe2dc1d9d..10f9fc083e924 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx @@ -15,6 +15,11 @@ import { createSeries, } from '../../../../utils/fixtures/metrics_explorer'; +jest.mock('../../../../hooks/use_kibana_timefilter_time', () => ({ + useKibanaTimefilterTime: (defaults: { from: string; to: string }) => [() => defaults], + useSyncKibanaTimeFilterTime: () => [() => {}], +})); + const renderUseMetricsExplorerStateHook = () => renderHook((props) => useMetricsExplorerState(props.source, props.derivedIndexPattern), { initialProps: { source, derivedIndexPattern }, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx index c35e9f17bdcc3..3203793e13e53 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx @@ -24,6 +24,11 @@ jest.mock('../../../../alerting/use_alert_prefill', () => ({ }), })); +jest.mock('../../../../hooks/use_kibana_timefilter_time', () => ({ + useKibanaTimefilterTime: (defaults: { from: string; to: string }) => [() => defaults], + useSyncKibanaTimeFilterTime: () => [() => {}], +})); + const renderUseMetricsExplorerOptionsHook = () => renderHook(() => useMetricsExplorerOptions()); interface LocalStore { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index d54cb758188c6..4f53fd87ff44f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -11,6 +11,10 @@ import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; import { Color } from '../../../../../common/color_palette'; import { metricsExplorerMetricRT } from '../../../../../common/http_api/metrics_explorer'; +import { + useKibanaTimefilterTime, + useSyncKibanaTimeFilterTime, +} from '../../../../hooks/use_kibana_timefilter_time'; const metricsExplorerOptionsMetricRT = t.intersection([ metricsExplorerMetricRT, @@ -145,14 +149,29 @@ function useStateWithLocalStorage( } export const useMetricsExplorerOptions = () => { + const TIME_DEFAULTS = { from: 'now-1h', to: 'now' }; + const [getTime] = useKibanaTimefilterTime(TIME_DEFAULTS); + const { from, to } = getTime(); + const defaultTimeRange = { + from, + to, + interval: DEFAULT_TIMERANGE.interval, + }; + const [options, setOptions] = useStateWithLocalStorage( 'MetricsExplorerOptions', DEFAULT_OPTIONS ); const [currentTimerange, setTimeRange] = useStateWithLocalStorage( 'MetricsExplorerTimeRange', - DEFAULT_TIMERANGE + defaultTimeRange ); + + useSyncKibanaTimeFilterTime(TIME_DEFAULTS, { + from: currentTimerange.from, + to: currentTimerange.to, + }); + const [chartOptions, setChartOptions] = useStateWithLocalStorage( 'MetricsExplorerChartOptions', DEFAULT_CHART_OPTIONS @@ -177,7 +196,7 @@ export const useMetricsExplorerOptions = () => { defaultViewState: { options: DEFAULT_OPTIONS, chartOptions: DEFAULT_CHART_OPTIONS, - currentTimerange: DEFAULT_TIMERANGE, + currentTimerange: defaultTimeRange, }, options, chartOptions, diff --git a/x-pack/plugins/infra/public/test_utils/index.ts b/x-pack/plugins/infra/public/test_utils/index.ts index 3de4c40f47cc9..bec7cc04561fd 100644 --- a/x-pack/plugins/infra/public/test_utils/index.ts +++ b/x-pack/plugins/infra/public/test_utils/index.ts @@ -307,3 +307,11 @@ export const FAKE_SNAPSHOT_RESPONSE = { ], interval: '300s', }; + +export const FAKE_OVERVIEW_RESPONSE = { + stats: { + hosts: { type: 'number', value: 1 }, + cpu: { type: 'percent', value: 0.10691011235955057 }, + memory: { type: 'percent', value: 0.5389775280898876 }, + }, +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx new file mode 100644 index 0000000000000..a698b806b4cd7 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx @@ -0,0 +1,140 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; + + + +# The `data` plugin and `SearchStrategies` + +The search functionality abstraction provided by the `search` service of the +`data` plugin is pretty powerful: + +- The execution of the request is delegated to a search strategy, which is + executed on the Kibana server side. +- Any plugin can register custom search strategies with custom parameters and + response shapes. +- Search requests can be cancelled via an `AbortSignal`. +- Search requests are decoupled from the transport layer. The service will poll + for new results transparently. +- Partial responses can be returned as they become available if the search + takes longer. + +# Working with `data.search.search()` in the Browser + +The following chapters describe a set of React components and hooks that aim to +make it easy to take advantage of these characteristics from client-side React +code. They implement a producer/consumer pattern that decouples the craeation +of search requests from the consumption of the responses. This keeps each +code-path small and encourages the use of reactive processing, which in turn +reduces the risk of race conditions and incorrect assumptions about the +response timing. + +## Issuing new requests + +The main API to issue new requests is the `data.search.search()` function. It +returns an `Observable` representing the stream of partial and final results +without the consumer having to know the underlying transport mechanisms. +Besides receiving a search-strategy-specific parameter object, it supports +selection of the search strategy as well an `AbortSignal` used for request +cancellation. + +The hook `useDataSearch()` is designed to ease the integration between the +`Observable` world and the React world. It uses the function it is given to +derive the parameters to use for the next search request. The request can then +be issued by calling the returned `search()` function. For each new request the +hook emits an object describing the request and its state in the `requests$` +`Observable`. + +```typescript +const { search, requests$ } = useDataSearch({ + getRequest: useCallback((searchTerm: string) => ({ + request: { + params: { + searchTerm + } + } + }), []); +}); +``` + +## Executing requests and consuming the responses + +The response `Observable`s emitted by `data.search.search()` is "cold", so it +won't be executed unless a subscriber subscribes to it. And in order to cleanly +cancel and garbage collect the subscription it should be integrated with the +React component life-cycle. + +The `useLatestPartialDataSearchResponse()` does that in such a way that the +newest response observable is subscribed to and that any previous response +observables are unsubscribed from for proper cancellation if a new request has +been created. This uses RxJS's `switchMap()` operator under the hood. The hook +also makes sure that all observables are unsubscribed from on unmount. + +Since the specific response shape depends on the data strategy used, the hook +takes a projection function, that is responsible for decoding the response in +an appropriate way. + +A request can fail due to various reasons that include servers-side errors, +Elasticsearch shard failures and network failures. The intention is to map all +of them to a common `SearchStrategyError` interface. While the +`useLatestPartialDataSearchResponse()` hook does that for errors emitted +natively by the response `Observable`, it's the responsibility of the +projection function to handle errors that are encoded in the response body, +which includes most server-side errors. Note that errors and partial results in +a response are not mutually exclusive. + +The request status (running, partial, etc), the response +and the errors are turned in to React component state so they can be used in +the usual rendering cycle: + +```typescript +const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, +} = useLatestPartialDataSearchResponse( + requests$, + 'initialValue', + useMemo(() => decodeOrThrow(mySearchStrategyResponsePayloadRT), []), +); +``` + +## Representing the request state to the user + +After the values have been made available to the React rendering process using +the `useLatestPartialDataSearchResponse()` hook, normal component hierarchies +can be used to make the request state and result available to the user. The +following utility components can make that even easier. + +### Undetermined progress + +If `total` and `loaded` are not (yet) known, we can show an undetermined +progress bar. + + + + + +### Known progress + +If `total` and `loaded` are returned by the search strategy, they can be used +to show a progress bar with the option to cancel the request if it takes too +long. + + + + + +### Failed requests + +Assuming the errors are represented as an array of `SearchStrategyError`s in +the `latestResponseErrors` return value, they can be rendered as appropriate +for the respective part of the UI. For many cases a `EuiCallout` is suitable, +so the `DataSearchErrorCallout` can serve as a starting point: + + + + + diff --git a/x-pack/plugins/infra/public/utils/data_search/index.ts b/x-pack/plugins/infra/public/utils/data_search/index.ts new file mode 100644 index 0000000000000..c08ab0727fd90 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './types'; +export * from './use_data_search_request'; +export * from './use_latest_partial_data_search_response'; diff --git a/x-pack/plugins/infra/public/utils/data_search/types.ts b/x-pack/plugins/infra/public/utils/data_search/types.ts new file mode 100644 index 0000000000000..ba0a4c639dae4 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; + +export interface DataSearchRequestDescriptor { + request: Request; + options: ISearchOptions; + response$: Observable>; + abortController: AbortController; +} + +export interface NormalizedKibanaSearchResponse { + total?: number; + loaded?: number; + isRunning: boolean; + isPartial: boolean; + data: ResponseData; + errors: SearchStrategyError[]; +} + +export interface DataSearchResponseDescriptor { + request: Request; + options: ISearchOptions; + response: NormalizedKibanaSearchResponse; + abortController: AbortController; +} diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx new file mode 100644 index 0000000000000..87c091f12ad90 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { Observable, of, Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { + DataPublicPluginStart, + IKibanaSearchResponse, + ISearchGeneric, + ISearchStart, +} from '../../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { PluginKibanaContextValue } from '../../hooks/use_kibana'; +import { useDataSearch } from './use_data_search_request'; + +describe('useDataSearch hook', () => { + it('forwards the search function arguments to the getRequest function', async () => { + const dataMock = createDataPluginMock(); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((_firstArgument: string, _secondArgument: string) => null); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.search('first', 'second'); + }); + + expect(getRequest).toHaveBeenLastCalledWith('first', 'second'); + expect(dataMock.search.search).not.toHaveBeenCalled(); + }); + + it('creates search requests with the given params and options', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = of({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + expect(dataMock.search.search).toHaveBeenLastCalledWith( + { + params: { firstArgument: 'first', secondArgument: 'second' }, + }, + { + abortSignal: expect.any(Object), + strategy: 'test-search-strategy', + } + ); + expect(firstRequest).toHaveProperty('abortController', expect.any(Object)); + expect(firstRequest).toHaveProperty('request.params', { + firstArgument: 'first', + secondArgument: 'second', + }); + expect(firstRequest).toHaveProperty('options.strategy', 'test-search-strategy'); + expect(firstRequest).toHaveProperty('response$', expect.any(Observable)); + await expect(firstRequest.response$.toPromise()).resolves.toEqual({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + }); + + it('aborts the request when the response observable looses the last subscriber', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = new Subject(); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + // execute requests$ observable + const firstResponseSubscription = firstRequest.response$.subscribe({ + next: jest.fn(), + }); + + // get the abort signal + const [, firstRequestOptions] = dataMock.search.search.mock.calls[0]; + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(false); + + // unsubscribe + firstResponseSubscription.unsubscribe(); + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(true); + }); +}); + +const createDataPluginMock = () => { + const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { + search: ISearchStart & { search: jest.MockedFunction }; + }; + return dataMock; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts new file mode 100644 index 0000000000000..a23f06adc0353 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Subject } from 'rxjs'; +import { map, share, switchMap, tap } from 'rxjs/operators'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import { tapUnsubscribe, useObservable } from '../use_observable'; + +export type DataSearchRequestFactory = ( + ...args: Args +) => + | { + request: Request; + options: ISearchOptions; + } + | null + | undefined; + +export const useDataSearch = < + RequestFactoryArgs extends any[], + Request extends IKibanaSearchRequest, + RawResponse +>({ + getRequest, +}: { + getRequest: DataSearchRequestFactory; +}) => { + const { services } = useKibanaContextForPlugin(); + const request$ = useObservable( + () => new Subject<{ request: Request; options: ISearchOptions }>(), + [] + ); + const requests$ = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequest$]) => currentRequest$), + map(({ request, options }) => { + const abortController = new AbortController(); + let isAbortable = true; + + return { + abortController, + request, + options, + response$: services.data.search + .search>(request, { + abortSignal: abortController.signal, + ...options, + }) + .pipe( + // avoid aborting failed or completed requests + tap({ + error: () => { + isAbortable = false; + }, + complete: () => { + isAbortable = false; + }, + }), + tapUnsubscribe(() => { + if (isAbortable) { + abortController.abort(); + } + }), + share() + ), + }; + }) + ), + [request$] + ); + + const search = useCallback( + (...args: RequestFactoryArgs) => { + const request = getRequest(...args); + + if (request) { + request$.next(request); + } + }, + [getRequest, request$] + ); + + return { + requests$, + search, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx new file mode 100644 index 0000000000000..4c336aa1107a2 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { Observable, of, Subject } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../../src/plugins/data/public'; +import { DataSearchRequestDescriptor } from './types'; +import { useLatestPartialDataSearchResponse } from './use_latest_partial_data_search_response'; + +describe('useLatestPartialDataSearchResponse hook', () => { + it("subscribes to the latest request's response observable", () => { + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Subject>(), + }; + + const secondRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'secondRequestParam' }, + response$: new Subject>(), + }; + + const requests$ = new Subject< + DataSearchRequestDescriptor, string> + >(); + + const { result } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty('current.latestResponseData', undefined); + + // first request is started + act(() => { + requests$.next(firstRequest); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty('current.latestResponseData', 'initial'); + + // first response of the first request arrives + act(() => { + firstRequest.response$.next({ rawResponse: 'request-1-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-1-response-1' + ); + + // second request is started before the second response of the first request arrives + act(() => { + requests$.next(secondRequest); + secondRequest.response$.next({ rawResponse: 'request-2-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-1' + ); + + // second response of the second request arrives + act(() => { + secondRequest.response$.next({ rawResponse: 'request-2-response-2', isRunning: false }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-2' + ); + }); + + it("unsubscribes from the latest request's response observable on unmount", () => { + const onUnsubscribe = jest.fn(); + + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Observable>(() => { + return onUnsubscribe; + }), + }; + + const requests$ = of, string>>( + firstRequest + ); + + const { unmount } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(onUnsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(onUnsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts new file mode 100644 index 0000000000000..71fd96283d0ef --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Observable, of } from 'rxjs'; +import { catchError, map, startWith, switchMap } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../../src/plugins/kibana_utils/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; +import { useLatest, useObservable, useObservableState } from '../use_observable'; +import { DataSearchRequestDescriptor, DataSearchResponseDescriptor } from './types'; + +export const useLatestPartialDataSearchResponse = < + Request extends IKibanaSearchRequest, + RawResponse, + Response, + InitialResponse +>( + requests$: Observable>, + initialResponse: InitialResponse, + projectResponse: (rawResponse: RawResponse) => { data: Response; errors?: SearchStrategyError[] } +) => { + const latestInitialResponse = useLatest(initialResponse); + const latestProjectResponse = useLatest(projectResponse); + + const latestResponse$: Observable< + DataSearchResponseDescriptor + > = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequests$]) => + currentRequests$.pipe( + switchMap(({ abortController, options, request, response$ }) => + response$.pipe( + map((response) => { + const { data, errors = [] } = latestProjectResponse.current(response.rawResponse); + return { + abortController, + options, + request, + response: { + data, + errors, + isPartial: response.isPartial ?? false, + isRunning: response.isRunning ?? false, + loaded: response.loaded, + total: response.total, + }, + }; + }), + startWith({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [], + isPartial: true, + isRunning: true, + loaded: 0, + total: undefined, + }, + }), + catchError((error) => + of({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [ + error instanceof AbortError + ? { + type: 'aborted' as const, + } + : { + type: 'generic' as const, + message: `${error.message ?? error}`, + }, + ], + isPartial: true, + isRunning: false, + loaded: 0, + total: undefined, + }, + }) + ) + ) + ) + ) + ) + ), + [requests$] as const + ); + + const { latestValue } = useObservableState(latestResponse$, undefined); + + const cancelRequest = useCallback(() => { + latestValue?.abortController.abort(); + }, [latestValue]); + + return { + cancelRequest, + isRequestRunning: latestValue?.response.isRunning ?? false, + isResponsePartial: latestValue?.response.isPartial ?? false, + latestResponseData: latestValue?.response.data, + latestResponseErrors: latestValue?.response.errors, + loaded: latestValue?.response.loaded, + total: latestValue?.response.total, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/use_observable.ts b/x-pack/plugins/infra/public/utils/use_observable.ts new file mode 100644 index 0000000000000..342aa5aa797b1 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/use_observable.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { BehaviorSubject, Observable, PartialObserver, Subscription } from 'rxjs'; + +export const useLatest = (value: Value) => { + const valueRef = useRef(value); + valueRef.current = value; + return valueRef; +}; + +export const useObservable = < + OutputValue, + OutputObservable extends Observable, + InputValues extends Readonly +>( + createObservableOnce: (inputValues: Observable) => OutputObservable, + inputValues: InputValues +) => { + const [inputValues$] = useState(() => new BehaviorSubject(inputValues)); + const [output$] = useState(() => createObservableOnce(inputValues$)); + + useEffect(() => { + inputValues$.next(inputValues); + // `inputValues` can't be statically analyzed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, inputValues); + + return output$; +}; + +export const useObservableState = ( + state$: Observable, + initialState: InitialState | (() => InitialState) +) => { + const [latestValue, setLatestValue] = useState(initialState); + const [latestError, setLatestError] = useState(); + + useSubscription(state$, { + next: setLatestValue, + error: setLatestError, + }); + + return { latestValue, latestError }; +}; + +export const useSubscription = ( + input$: Observable, + { next, error, complete, unsubscribe }: PartialObserver & { unsubscribe?: () => void } +) => { + const latestSubscription = useRef(); + const latestNext = useLatest(next); + const latestError = useLatest(error); + const latestComplete = useLatest(complete); + const latestUnsubscribe = useLatest(unsubscribe); + + useEffect(() => { + const fixedUnsubscribe = latestUnsubscribe.current; + + const subscription = input$.subscribe({ + next: (value) => latestNext.current?.(value), + error: (value) => latestError.current?.(value), + complete: () => latestComplete.current?.(), + }); + + latestSubscription.current = subscription; + + return () => { + subscription.unsubscribe(); + fixedUnsubscribe?.(); + }; + }, [input$, latestNext, latestError, latestComplete, latestUnsubscribe]); + + return latestSubscription.current; +}; + +export const tapUnsubscribe = (onUnsubscribe: () => void) => (source$: Observable) => { + return new Observable((subscriber) => { + const subscription = source$.subscribe({ + next: (value) => subscriber.next(value), + error: (error) => subscriber.error(error), + complete: () => subscriber.complete(), + }); + + return () => { + onUnsubscribe(); + subscription.unsubscribe(); + }; + }); +}; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 6c0d4e9d302ee..b510519a4fd0d 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -38,6 +38,7 @@ import { import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; import { initSourceRoute } from './routes/source'; +import { initOverviewRoute } from './routes/overview'; import { initAlertPreviewRoute } from './routes/alerting'; import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts'; import { initProcessListRoute } from './routes/process_list'; @@ -82,4 +83,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initAlertPreviewRoute(libs); initGetLogAlertsChartPreviewDataRoute(libs); initProcessListRoute(libs); + initOverviewRoute(libs); }; diff --git a/x-pack/plugins/infra/server/routes/overview/index.ts b/x-pack/plugins/infra/server/routes/overview/index.ts new file mode 100644 index 0000000000000..05f5f83ba71cb --- /dev/null +++ b/x-pack/plugins/infra/server/routes/overview/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Boom from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { findInventoryFields } from '../../../common/inventory_models'; +import { throwErrors } from '../../../common/runtime_types'; +import { OverviewRequestRT } from '../../../common/http_api/overview_api'; +import { InfraBackendLibs } from '../../lib/infra_types'; +import { createSearchClient } from '../../lib/create_search_client'; + +const escapeHatch = schema.object({}, { unknowns: 'allow' }); + +interface OverviewESAggResponse { + memory: { value: number }; + hosts: { value: number }; + cpu: { value: number }; +} + +export const initOverviewRoute = (libs: InfraBackendLibs) => { + const { framework } = libs; + + framework.registerRoute( + { + method: 'post', + path: '/api/metrics/overview', + validate: { + body: escapeHatch, + }, + }, + async (requestContext, request, response) => { + try { + const overviewRequest = pipe( + OverviewRequestRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = createSearchClient(requestContext, framework); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + overviewRequest.sourceId + ); + + const inventoryModelFields = findInventoryFields('host', source.configuration.fields); + + const params = { + index: source.configuration.metricAlias, + body: { + query: { + range: { + [source.configuration.fields.timestamp]: { + gte: overviewRequest.timerange.from, + lte: overviewRequest.timerange.to, + format: 'epoch_millis', + }, + }, + }, + aggs: { + hosts: { + cardinality: { + field: inventoryModelFields.id, + }, + }, + cpu: { + avg: { + field: 'system.cpu.total.norm.pct', + }, + }, + memory: { + avg: { + field: 'system.memory.actual.used.pct', + }, + }, + }, + }, + }; + + const esResponse = await client<{}, OverviewESAggResponse>(params); + + return response.ok({ + body: { + stats: { + hosts: { + type: 'number', + value: esResponse.aggregations?.hosts.value ?? 0, + }, + cpu: { + type: 'percent', + value: esResponse.aggregations?.cpu.value ?? 0, + }, + memory: { + type: 'percent', + value: esResponse.aggregations?.memory.value ?? 0, + }, + }, + }, + }); + } catch (error) { + return response.internalError({ + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 044cea3899baf..38626675f5ae7 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -164,6 +164,35 @@ describe('LogEntry search strategy', () => { await expect(response.toPromise()).rejects.toThrowError(ResponseError); }); + + it('forwards cancellation to the underlying search strategy', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntrySearchStrategy = logEntrySearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + const requestId = logEntrySearchRequestStateRT.encode({ + esRequestId: 'ASYNC_REQUEST_ID', + }); + + await logEntrySearchStrategy.cancel?.(requestId, {}, mockDependencies); + + expect(esSearchStrategyMock.cancel).toHaveBeenCalled(); + }); }); const createSourceConfigurationMock = () => ({ @@ -208,6 +237,7 @@ const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({ return of(esSearchResponse); } }), + cancel: jest.fn().mockResolvedValue(undefined), }); const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({ diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 880a48fd5b8f7..dac97479d4b04 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -17,7 +17,7 @@ export const createGetLogEntryQuery = ( logEntryId: string, timestampField: string, tiebreakerField: string -): RequestParams.Search> => ({ +): RequestParams.AsyncSearchSubmit> => ({ index: logEntryIndex, terminate_after: 1, track_scores: false, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx index f663832702b1c..ab4d36104d7de 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx @@ -81,7 +81,7 @@ export const AddProcessorForm: FunctionComponent = ({
- +

{getFlyoutTitle(isOnFailure)}

diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx index 7f4caa09b6df0..fa35c1e7e7c3e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx @@ -46,6 +46,7 @@ export const EmptyList: FunctionComponent = () => { } actions={ + + +`; + exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` { ).toMatchSnapshot(); }); + test('it renders actions column when there are row actions', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + onClickValue={onClickValue} + getType={jest.fn()} + onRowContextMenuClick={() => undefined} + rowHasRowClickTriggerActions={[true, true, true]} + /> + ) + ).toMatchSnapshot(); + }); + test('it invokes executeTriggerActions with correct context on click on top value', () => { const { args, data } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 6502e07697816..f1eaab908717a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -10,13 +10,22 @@ import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { EuiBasicTable, EuiFlexGroup, EuiButtonIcon, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiButtonIcon, + EuiFlexItem, + EuiToolTip, + EuiBasicTableColumn, + EuiTableActionsColumnType, +} from '@elastic/eui'; import { IAggType } from 'src/plugins/data/public'; import { FormatFactory, ILensInterpreterRenderHandlers, LensFilterEvent, LensMultiTable, + LensTableRowContextMenuEvent, } from '../types'; import { ExpressionFunctionDefinition, @@ -45,7 +54,14 @@ export interface DatatableProps { type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; onClickValue: (data: LensFilterEvent['data']) => void; + onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; getType: (name: string) => IAggType; + + /** + * A boolean for each table row, which is true if the row active + * ROW_CLICK_TRIGGER actions attached to it, otherwise false. + */ + rowHasRowClickTriggerActions?: boolean[]; }; export interface DatatableRender { @@ -143,13 +159,47 @@ export const getDatatableRenderer = (dependencies: { const onClickValue = (data: LensFilterEvent['data']) => { handlers.event({ name: 'filter', data }); }; + const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { + handlers.event({ name: 'tableRowContextMenuClick', data }); + }; + const { hasCompatibleActions } = handlers; + + // An entry for each table row, whether it has any actions attached to + // ROW_CLICK_TRIGGER trigger. + let rowHasRowClickTriggerActions: boolean[] = []; + if (hasCompatibleActions) { + const table = Object.values(config.data.tables)[0]; + if (!!table) { + rowHasRowClickTriggerActions = await Promise.all( + table.rows.map(async (row, rowIndex) => { + try { + const hasActions = await hasCompatibleActions({ + name: 'tableRowContextMenuClick', + data: { + rowIndex, + table, + columns: config.args.columns.columnIds, + }, + }); + + return hasActions; + } catch { + return false; + } + }) + ); + } + } + ReactDOM.render( , domNode, @@ -169,7 +219,7 @@ export function DatatableComponent(props: DatatableRenderProps) { formatters[column.id] = props.formatFactory(column.meta?.params); }); - const { onClickValue } = props; + const { onClickValue, onRowContextMenuClick } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; @@ -214,6 +264,124 @@ export function DatatableComponent(props: DatatableRenderProps) { return ; } + const tableColumns: Array< + EuiBasicTableColumn<{ rowIndex: number; [key: string]: unknown }> + > = props.args.columns.columnIds + .map((field) => { + const col = firstTable.columns.find((c) => c.id === field); + const filterable = bucketColumns.includes(field); + const colIndex = firstTable.columns.findIndex((c) => c.id === field); + return { + field, + name: (col && col.name) || '', + render: (value: unknown) => { + const formattedValue = formatters[field]?.convert(value); + const fieldName = col?.meta?.field; + + if (filterable) { + return ( + + {formattedValue} + + + + handleFilterClick(field, value, colIndex)} + /> + + + + handleFilterClick(field, value, colIndex, true)} + /> + + + + + + ); + } + return {formattedValue}; + }, + }; + }) + .filter(({ field }) => !!field); + + if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); + if (hasAtLeastOneRowClickAction) { + const actions: EuiTableActionsColumnType<{ rowIndex: number; [key: string]: unknown }> = { + name: i18n.translate('xpack.lens.datatable.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('xpack.lens.tableRowMore', { + defaultMessage: 'More', + }), + description: i18n.translate('xpack.lens.tableRowMoreDescription', { + defaultMessage: 'Table row context menu', + }), + type: 'icon', + icon: ({ rowIndex }: { rowIndex: number }) => { + if ( + !!props.rowHasRowClickTriggerActions && + !props.rowHasRowClickTriggerActions[rowIndex] + ) + return 'empty'; + return 'boxesVertical'; + }, + onClick: ({ rowIndex }) => { + onRowContextMenuClick({ + rowIndex, + table: firstTable, + columns: props.args.columns.columnIds, + }); + }, + }, + ], + }; + tableColumns.push(actions); + } + } + return ( { - const col = firstTable.columns.find((c) => c.id === field); - const filterable = bucketColumns.includes(field); - const colIndex = firstTable.columns.findIndex((c) => c.id === field); - return { - field, - name: (col && col.name) || '', - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); - const fieldName = col?.meta?.field; - - if (filterable) { - return ( - - {formattedValue} - - - - handleFilterClick(field, value, colIndex)} - /> - - - - handleFilterClick(field, value, colIndex, true)} - /> - - - - - - ); - } - return {formattedValue}; - }, - }; - }) - .filter(({ field }) => !!field)} - items={firstTable ? firstTable.rows : []} + columns={tableColumns} + items={firstTable ? firstTable.rows.map((row, rowIndex) => ({ ...row, rowIndex })) : []} /> ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 5d9be46db7fb5..9c7d7ae1f2d43 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -7,11 +7,9 @@ import { CoreSetup } from 'kibana/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; -import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; interface DatatableVisualizationPluginStartPlugins { - uiActions: UiActionsStart; data: DataPublicPluginStart; } export interface DatatableVisualizationPluginSetupPlugins { @@ -34,6 +32,7 @@ export class DatatableVisualization { getDatatableRenderer, datatableVisualization, } = await import('../async_services'); + expressions.registerFunction(() => datatableColumns); expressions.registerFunction(() => datatable); expressions.registerRenderer(() => diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 54517e4ee8c84..175c573d3be3a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -25,7 +25,7 @@ import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks' import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public/embeddable'; import { coreMock, httpServiceMock } from '../../../../../../src/core/public/mocks'; import { IBasePath } from '../../../../../../src/core/public'; -import { AttributeService } from '../../../../../../src/plugins/embeddable/public'; +import { AttributeService, ViewMode } from '../../../../../../src/plugins/embeddable/public'; import { LensAttributeService } from '../../lens_attribute_service'; import { OnSaveProps } from '../../../../../../src/plugins/saved_objects/public/save_modal'; import { act } from 'react-dom/test-utils'; @@ -221,6 +221,74 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(2); }); + it('should re-render when dashboard view/edit mode changes', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + + embeddable.updateInput({ + viewMode: ViewMode.VIEW, + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); + + it('should re-render when dynamic actions input changes', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + + embeddable.updateInput({ + enhancements: { + dynamicActions: {}, + }, + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); + it('should pass context to embeddable', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; @@ -396,6 +464,37 @@ describe('embeddable', () => { ); }); + it('should execute trigger on row click event from expression renderer', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + const onEvent = expressionRenderer.mock.calls[0][0].onEvent!; + + onEvent({ name: 'tableRowContextMenuClick', data: {} }); + + expect(getTrigger).toHaveBeenCalledWith(VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick); + }); + it('should not re-render if only change is in disabled filter', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index e7d3e1a4bfa5b..6c86ae5cff2c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -21,6 +21,8 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { Subscription } from 'rxjs'; import { toExpression, Ast } from '@kbn/interpreter/common'; import { RenderMode } from 'src/plugins/expressions'; +import { map, distinctUntilChanged, skip } from 'rxjs/operators'; +import isEqual from 'fast-deep-equal'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -38,7 +40,11 @@ import { import { Document, injectFilterReferences } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { isLensBrushEvent, isLensFilterEvent } from '../../types'; +import { + isLensBrushEvent, + isLensFilterEvent, + isLensTableRowContextMenuClickEvent, +} from '../../types'; import { IndexPatternsContract } from '../../../../../../src/plugins/data/public'; import { getEditPath, DOC_TYPE } from '../../../common'; @@ -71,6 +77,7 @@ export interface LensEmbeddableDeps { timefilter: TimefilterContract; basePath: IBasePath; getTrigger?: UiActionsStart['getTrigger'] | undefined; + getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions']; } export class Embeddable @@ -117,6 +124,36 @@ export class Embeddable this.autoRefreshFetchSubscription = deps.timefilter .getAutoRefreshFetch$() .subscribe(this.reload.bind(this)); + + const input$ = this.getInput$(); + + // Lens embeddable does not re-render when embeddable input changes in + // general, to improve performance. This line makes sure the Lens embeddable + // re-renders when anything in ".dynamicActions" (e.g. drilldowns) changes. + input$ + .pipe( + map((input) => input.enhancements?.dynamicActions), + distinctUntilChanged((a, b) => isEqual(a, b)), + skip(1) + ) + .subscribe((input) => { + this.reload(); + }); + + // Lens embeddable does not re-render when embeddable input changes in + // general, to improve performance. This line makes sure the Lens embeddable + // re-renders when dashboard view mode switches between "view/edit". This is + // needed to see the changes to ".dynamicActions" (e.g. drilldowns) when + // dashboard's mode is toggled. + input$ + .pipe( + map((input) => input.viewMode), + distinctUntilChanged(), + skip(1) + ) + .subscribe((input) => { + this.reload(); + }); } public supportedTriggers() { @@ -127,6 +164,7 @@ export class Embeddable case 'lnsXY': return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush]; case 'lnsDatatable': + return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick]; case 'lnsPie': return [VIS_EVENT_TO_TRIGGER.filter]; case 'lnsMetric': @@ -217,11 +255,31 @@ export class Embeddable handleEvent={this.handleEvent} onData$={this.updateActiveData} renderMode={input.renderMode} + hasCompatibleActions={this.hasCompatibleActions} />, domNode ); } + private readonly hasCompatibleActions = async ( + event: ExpressionRendererEvent + ): Promise => { + if (isLensTableRowContextMenuClickEvent(event)) { + const { getTriggerCompatibleActions } = this.deps; + if (!getTriggerCompatibleActions) { + return false; + } + const actions = await getTriggerCompatibleActions(VIS_EVENT_TO_TRIGGER[event.name], { + data: event.data, + embeddable: this, + }); + + return actions.length > 0; + } + + return false; + }; + /** * Combines the embeddable context with the saved object context, and replaces * any references to index patterns @@ -264,6 +322,16 @@ export class Embeddable embeddable: this, }); } + + if (isLensTableRowContextMenuClickEvent(event)) { + this.deps.getTrigger(VIS_EVENT_TO_TRIGGER[event.name]).exec( + { + data: event.data, + embeddable: this, + }, + true + ); + } }; async reload() { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 65e9c22d24eaf..175ec0dbcfd54 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -94,6 +94,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { editable: await this.isEditable(), basePath: coreHttp.basePath, getTrigger: uiActions?.getTrigger, + getTriggerCompatibleActions: uiActions?.getTriggerCompatibleActions, documentToExpression, }, input, diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 4645420898314..2fc1cfee82fd3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, + ReactExpressionRendererProps, } from 'src/plugins/expressions/public'; import { ExecutionContextSearch } from 'src/plugins/data/public'; import { RenderMode } from 'src/plugins/expressions'; @@ -26,6 +27,7 @@ export interface ExpressionWrapperProps { handleEvent: (event: ExpressionRendererEvent) => void; onData$: (data: unknown, inspectorAdapters?: LensInspectorAdapters | undefined) => void; renderMode?: RenderMode; + hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions']; } export function ExpressionWrapper({ @@ -37,6 +39,7 @@ export function ExpressionWrapper({ searchSessionId, onData$, renderMode, + hasCompatibleActions, }: ExpressionWrapperProps) { return ( @@ -80,6 +83,7 @@ export function ExpressionWrapper({
)} onEvent={handleEvent} + hasCompatibleActions={hasCompatibleActions} />
)} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx index ddcb5633b376f..de7a826485831 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx @@ -47,7 +47,7 @@ export const LabelInput = ({ inputRef.current = node; } }} - onKeyDown={({ key }: React.KeyboardEvent) => { + onKeyUp={({ key }: React.KeyboardEvent) => { if (keys.ENTER === key && onSubmit) { onSubmit(); } diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.scss b/x-pack/plugins/lens/public/pie_visualization/visualization.scss index d9ff75d849708..a8890208596b6 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.scss +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.scss @@ -1,4 +1,7 @@ .lnsPieExpression__container { height: 100%; width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index b0da6cf2e8434..23d026bf2b443 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -8,6 +8,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; +import { ROW_CLICK_TRIGGER } from '../../../../src/plugins/ui_actions/public'; import { ExpressionAstExpression, ExpressionRendererEvent, @@ -614,11 +615,17 @@ export interface LensFilterEvent { name: 'filter'; data: TriggerContext['data']; } + export interface LensBrushEvent { name: 'brush'; data: TriggerContext['data']; } +export interface LensTableRowContextMenuEvent { + name: 'tableRowContextMenuClick'; + data: TriggerContext['data']; +} + export function isLensFilterEvent(event: ExpressionRendererEvent): event is LensFilterEvent { return event.name === 'filter'; } @@ -627,11 +634,17 @@ export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensB return event.name === 'brush'; } +export function isLensTableRowContextMenuClickEvent( + event: ExpressionRendererEvent +): event is LensBrushEvent { + return event.name === 'tableRowContextMenuClick'; +} + /** * Expression renderer handlers specifically for lens renderers. This is a narrowed down * version of the general render handlers, specifying supported event types. If this type is * used, dispatched events will be handled correctly. */ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { - event: (event: LensFilterEvent | LensBrushEvent) => void; + event: (event: LensFilterEvent | LensBrushEvent | LensTableRowContextMenuEvent) => void; } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.scss b/x-pack/plugins/lens/public/xy_visualization/expression.scss index 579f66f99b9fb..68f5e9863d2bb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.scss +++ b/x-pack/plugins/lens/public/xy_visualization/expression.scss @@ -1,6 +1,9 @@ .lnsXyExpression__container { height: 100%; width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; } .lnsChart__empty { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index dc6ce285754fc..cc4df1f0f9315 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -154,6 +154,28 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } +function getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, +}: { + isAreaPercentage: boolean; + isHistogramSeries: boolean; +}): string { + if (isHistogramSeries) { + return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on histograms.', + }); + } + if (isAreaPercentage) { + return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on percentage area charts.', + }); + } + return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', + }); +} + export function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; @@ -246,20 +268,17 @@ export function XyToolbar(props: VisualizationToolbarProps) { const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; const isFittingEnabled = hasNonBarSeries; + const valueLabelsDisabledReason = getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, + }); + return ( _score: 0, _source: getSearchEsListItemMock(), _type: '', + matched_queries: ['0.0'], }, ], max_score: 0, diff --git a/x-pack/plugins/lists/common/schemas/response/index.ts b/x-pack/plugins/lists/common/schemas/response/index.ts index deca06ad99fea..5e739ccf3a0a0 100644 --- a/x-pack/plugins/lists/common/schemas/response/index.ts +++ b/x-pack/plugins/lists/common/schemas/response/index.ts @@ -15,3 +15,4 @@ export * from './found_list_schema'; export * from './list_item_schema'; export * from './list_schema'; export * from './list_item_index_exist_schema'; +export * from './search_list_item_schema'; diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts new file mode 100644 index 0000000000000..1ad241ffca077 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchListItemSchema } from '../../../common/schemas'; +import { VALUE } from '../../../common/constants.mock'; + +import { getListItemResponseMock } from './list_item_schema.mock'; + +export const getSearchListItemResponseMock = (): SearchListItemSchema => ({ + items: [getListItemResponseMock()], + value: VALUE, +}); diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.test.ts new file mode 100644 index 0000000000000..132c3f16688f0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.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 { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../shared_imports'; + +import { getSearchListItemResponseMock } from './search_list_item_schema.mock'; +import { SearchListItemSchema, searchListItemSchema } from './search_list_item_schema'; + +describe('search_list_item_schema', () => { + test('it should validate a typical search list item response', () => { + const payload = getSearchListItemResponseMock(); + const decoded = searchListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate with an "undefined" for "items"', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { items, ...noItems } = getSearchListItemResponseMock(); + const decoded = searchListItemSchema.decode(noItems); + const checked = exactCheck(noItems, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "items"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: SearchListItemSchema & { extraKey?: string } = getSearchListItemResponseMock(); + payload.extraKey = 'some new value'; + const decoded = searchListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.ts new file mode 100644 index 0000000000000..5177098a6f67f --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.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 * as t from 'io-ts'; + +import { listItemArraySchema } from './list_item_schema'; + +/** + * NOTE: Although this is defined within "response" this does not expose a REST API + * endpoint right now for this particular response. Instead this is only used internally + * for the plugins at this moment. If this changes, please remove this message. + */ +export const searchListItemSchema = t.exact( + t.type({ + items: listItemArraySchema, + value: t.unknown, + }) +); + +export type SearchListItemSchema = t.TypeOf; + +export const searchListItemArraySchema = t.array(searchListItemSchema); +export type SearchListItemArraySchema = t.TypeOf; diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts index f658a51730d97..1120f99bf917a 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts @@ -47,7 +47,15 @@ describe('delete_list_item_by_value', () => { body: { query: { bool: { - filter: [{ term: { list_id: 'some-list-id' } }, { terms: { ip: ['127.0.0.1'] } }], + filter: [ + { term: { list_id: 'some-list-id' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ], }, }, }, diff --git a/x-pack/plugins/lists/server/services/items/index.ts b/x-pack/plugins/lists/server/services/items/index.ts index bc04ba88b943e..31003771679a9 100644 --- a/x-pack/plugins/lists/server/services/items/index.ts +++ b/x-pack/plugins/lists/server/services/items/index.ts @@ -11,6 +11,7 @@ export * from './delete_list_item_by_value'; export * from './delete_list_item'; export * from './find_list_item'; export * from './get_list_item_by_value'; +export * from './get_list_item_by_values'; export * from './get_list_item'; export * from './get_list_item_by_values'; export * from './get_list_item_template'; @@ -18,3 +19,4 @@ export * from './get_list_item_index'; export * from './update_list_item'; export * from './write_lines_to_bulk_list_items'; export * from './write_list_items_to_stream'; +export * from './search_list_item_by_values'; diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts new file mode 100644 index 0000000000000..40b5fbb3ab8fa --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +import { SearchListItemByValuesOptions } from '../items'; +import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; + +export const searchListItemByValuesOptionsMocks = (): SearchListItemByValuesOptions => ({ + callCluster: getCallClusterMock(), + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], +}); diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts new file mode 100644 index 0000000000000..b2a89dfe321ad --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchListItemArraySchema } from '../../../common/schemas'; +import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; +import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; + +import { searchListItemByValues } from './search_list_item_by_values'; + +describe('search_list_item_by_values', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns a an empty array of items if the value is empty', async () => { + const data = getSearchListItemMock(); + data.hits.hits = []; + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [], + }); + + expect(listItem).toEqual([]); + }); + + test('Returns a an empty array of items if the ES query is also empty', async () => { + const data = getSearchListItemMock(); + data.hits.hits = []; + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], + }); + + const expected: SearchListItemArraySchema = [ + { items: [], value: VALUE }, + { items: [], value: VALUE_2 }, + ]; + expect(listItem).toEqual(expected); + }); + + test('Returns transformed list item if the data exists within ES', async () => { + const data = getSearchListItemMock(); + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], + }); + + const expected: SearchListItemArraySchema = [ + { + items: [ + { + _version: undefined, + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + deserializer: undefined, + id: 'some-list-item-id', + list_id: 'some-list-id', + meta: {}, + serializer: undefined, + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'some user', + value: '127.0.0.1', + }, + ], + value: '127.0.0.1', + }, + { + items: [], + value: VALUE_2, + }, + ]; + expect(listItem).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts new file mode 100644 index 0000000000000..33025a6a177ff --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.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 { LegacyAPICaller } from 'kibana/server'; + +import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; +import { getQueryFilterFromTypeValue, transformElasticNamedSearchToListItem } from '../utils'; + +export interface SearchListItemByValuesOptions { + listId: string; + callCluster: LegacyAPICaller; + listItemIndex: string; + type: Type; + value: unknown[]; +} + +export const searchListItemByValues = async ({ + listId, + callCluster, + listItemIndex, + type, + value, +}: SearchListItemByValuesOptions): Promise => { + const response = await callCluster('search', { + body: { + query: { + bool: { + filter: getQueryFilterFromTypeValue({ listId, type, value }), + }, + }, + }, + ignoreUnavailable: true, + index: listItemIndex, + size: 10000, // TODO: This has a limit on the number which is 10,000 the default of Elastic but we might want to provide a way to increase that number + }); + return transformElasticNamedSearchToListItem({ response, type, value }); +}; diff --git a/x-pack/plugins/lists/server/services/lists/list_client.ts b/x-pack/plugins/lists/server/services/lists/list_client.ts index 590bfef6625f5..b0640ac8d6ba9 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.ts @@ -12,6 +12,7 @@ import { ListItemArraySchema, ListItemSchema, ListSchema, + SearchListItemArraySchema, } from '../../../common/schemas'; import { ConfigType } from '../../config'; import { @@ -35,6 +36,7 @@ import { getListItemIndex, getListItemTemplate, importListItemsToStream, + searchListItemByValues, updateListItem, } from '../../services/items'; import { @@ -67,6 +69,7 @@ import { GetListItemsByValueOptions, GetListOptions, ImportListItemsToStreamOptions, + SearchListItemByValuesOptions, UpdateListItemOptions, UpdateListOptions, } from './list_client_types'; @@ -472,6 +475,22 @@ export class ListClient { }); }; + public searchListItemByValues = async ({ + type, + listId, + value, + }: SearchListItemByValuesOptions): Promise => { + const { callCluster } = this; + const listItemIndex = this.getListItemIndex(); + return searchListItemByValues({ + callCluster, + listId, + listItemIndex, + type, + value, + }); + }; + public findList = async ({ filter, currentIndexPosition, diff --git a/x-pack/plugins/lists/server/services/lists/list_client_types.ts b/x-pack/plugins/lists/server/services/lists/list_client_types.ts index ea983b38c7e5d..fd9066cfe2409 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client_types.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client_types.ts @@ -160,3 +160,9 @@ export interface FindListItemOptions { sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; } + +export interface SearchListItemByValuesOptions { + type: Type; + listId: string; + value: unknown[]; +} diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts index 3d48e44e26eaa..aec9ef629788c 100644 --- a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { QueryFilterType, getQueryFilterFromTypeValue } from './get_query_filter_from_type_value'; +import { + QueryFilterType, + getEmptyQuery, + getQueryFilterFromTypeValue, + getShouldQuery, + getTermsQuery, + getTextQuery, +} from './get_query_filter_from_type_value'; describe('get_query_filter_from_type_value', () => { beforeEach(() => { @@ -15,78 +22,813 @@ describe('get_query_filter_from_type_value', () => { jest.clearAllMocks(); }); - test('it returns an ip if given an ip', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: ['127.0.0.1'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { ip: ['127.0.0.1'] } }, - ]; - expect(queryFilter).toEqual(expected); - }); + describe('getQueryFilterFromTypeValue', () => { + test('it returns an ip if given an ip', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two ip if given two ip', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: '127.0.0.1' } } }, + { term: { ip: { _name: '1.0', value: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); - test('it returns two ip if given two ip', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: ['127.0.0.1', '127.0.0.2'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { ip: ['127.0.0.1', '127.0.0.2'] } }, - ]; - expect(queryFilter).toEqual(expected); + test('it returns a keyword if given a keyword', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: ['host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { keyword: { _name: '0.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two keywords if given two values', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: ['host-name-1', 'host-name-2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { keyword: { _name: '0.0', value: 'host-name-1' } } }, + { term: { keyword: { _name: '1.0', value: 'host-name-2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query given an empty value', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: [], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query object given an empty array', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query object given an array with only null values', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [null, null], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value for non-text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value for non-text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value for text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'text', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { text: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out object values if mixed with a string value for text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'text', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { text: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); }); - test('it returns a keyword if given a keyword', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: ['host-name-1'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: ['host-name-1'] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getEmptyQuery', () => { + test('it returns an empty query given a list_id', () => { + const emptyQuery = getEmptyQuery({ listId: 'list-123' }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { bool: { minimum_should_match: 1, should: [{ match_none: { _name: 'empty' } }] } }, + ]; + expect(emptyQuery).toEqual(expected); + }); }); - test('it returns two keywords if given two values', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: ['host-name-1', 'host-name-2'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: ['host-name-1', 'host-name-2'] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getTermsQuery', () => { + describe('scalar values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: '127.0.0.1' } } }, + { term: { ip: { _name: '1.0', value: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [5, 3], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: 5 } } }, + { term: { ip: { _name: '1.0', value: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [5, '3'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: 5 } } }, + { term: { ip: { _name: '1.0', value: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); + + describe('array values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1'], ['127.0.0.2']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { terms: { _name: '0.0', ip: ['127.0.0.1'] } }, + { terms: { _name: '1.0', ip: ['127.0.0.2'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], [3]], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: [5] } }, { terms: { _name: '1.0', ip: [3] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], ['3']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { terms: { _name: '0.0', ip: [5] } }, + { terms: { _name: '1.0', ip: ['3'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[null], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '1.0', ip: ['host-name-1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[{}], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '1.0', ip: ['host-name-1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it flattens and removes null values correctly in a deeply nested set of arrays', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [ + [null], + [ + 'host-name-1', + ['host-name-2', [null], ['host-name-3'], ['host-name-4', null, 'host-name-5']], + ], + ['host-name-6'], + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + terms: { + _name: '1.0', + ip: ['host-name-1', 'host-name-2', 'host-name-3', 'host-name-4', 'host-name-5'], + }, + }, + { terms: { _name: '2.0', ip: ['host-name-6'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); }); - test('it returns an empty keyword given an empty value', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: [], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: [] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getTextQuery', () => { + describe('scalar values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [5, 3], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [5, '3'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); + + describe('array values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1'], ['127.0.0.2']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], [3]], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], ['3']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[null], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a object value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[{}], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it flattens and removes null values correctly in a deeply nested set of arrays', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [ + [null], + [ + 'host-name-1', + ['host-name-2', [null], ['host-name-3'], ['host-name-4', null, 'host-name-5']], + ], + ['host-name-6'], + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }, + { match: { ip: { _name: '1.1', operator: 'and', query: 'host-name-2' } } }, + { match: { ip: { _name: '1.2', operator: 'and', query: 'host-name-3' } } }, + { match: { ip: { _name: '1.3', operator: 'and', query: 'host-name-4' } } }, + { match: { ip: { _name: '1.4', operator: 'and', query: 'host-name-5' } } }, + { match: { ip: { _name: '2.0', operator: 'and', query: 'host-name-6' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); }); - test('it returns an empty ip given an empty value', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: [], + describe('getShouldQuery', () => { + test('it returns a should as-is when passed one', () => { + const query = getShouldQuery({ + listId: 'list-123', + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); }); - const expected: QueryFilterType = [{ term: { list_id: 'list-123' } }, { terms: { ip: [] } }]; - expect(queryFilter).toEqual(expected); }); }); diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts index 3baba07aa9885..cf332cd6dd957 100644 --- a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts @@ -4,19 +4,170 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty, isObject } from 'lodash/fp'; + import { Type } from '../../../common/schemas'; export type QueryFilterType = [ - { term: Record }, - { terms: Record } + { term: Record }, + { terms: Record } | { bool: {} } ]; +/** + * Given a type, value, and listId, this will return a valid query. If the type is + * "text" it will return a "text" match, otherwise it returns a terms query. If an + * array or array of arrays is passed, this will flatten, remove any "null" values, + * and then the result. + * @param type The type of list + * @param value The unknown value + * @param listId The list id + */ export const getQueryFilterFromTypeValue = ({ type, value, listId, }: { type: Type; - value: string[]; + value: unknown[]; + listId: string; +}): QueryFilterType => { + const valueFlattened = value + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (isEmpty(valueFlattened)) { + return getEmptyQuery({ listId }); + } else if (type === 'text') { + return getTextQuery({ listId, type, value }); + } else { + return getTermsQuery({ listId, type, value }); + } +}; + +/** + * Returns an empty named query that should not match anything + * @param listId The list id to associate with the empty query + */ +export const getEmptyQuery = ({ listId }: { listId: string }): QueryFilterType => [ + { term: { list_id: listId } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, +]; + +/** + * Returns a terms query against a large value based list. If it detects that an array or item has a "null" + * value it will filter that value out. If it has arrays within arrays it will flatten those out as well. + * @param value The value which can be unknown + * @param type The list type type + * @param listId The list id + */ +export const getTermsQuery = ({ + value, + type, + listId, +}: { + value: unknown[]; + type: Type; + listId: string; +}): QueryFilterType => { + const should = value.reduce((accum, item, index) => { + if (Array.isArray(item)) { + const itemFlattened = item + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (itemFlattened.length === 0) { + return accum; + } else { + return [...accum, { terms: { _name: `${index}.0`, [type]: itemFlattened } }]; + } + } else { + if (item == null || isObject(item)) { + return accum; + } else { + return [...accum, { term: { [type]: { _name: `${index}.0`, value: item } } }]; + } + } + }, []); + return getShouldQuery({ listId, should }); +}; + +/** + * Returns a text query against a large value based list. If it detects that an array or item has a "null" + * value it will filter that value out. If it has arrays within arrays it will flatten those out as well. + * @param value The value which can be unknown + * @param type The list type type + * @param listId The list id + */ +export const getTextQuery = ({ + value, + type, + listId, +}: { + value: unknown[]; + type: Type; + listId: string; +}): QueryFilterType => { + const should = value.reduce((accum, item, index) => { + if (Array.isArray(item)) { + const itemFlattened = item + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (itemFlattened.length === 0) { + return accum; + } else { + return [ + ...accum, + ...itemFlattened.map((flatItem, secondIndex) => ({ + match: { + [type]: { _name: `${index}.${secondIndex}`, operator: 'and', query: flatItem }, + }, + })), + ]; + } + } else { + if (item == null || isObject(item)) { + return accum; + } else { + return [ + ...accum, + { match: { [type]: { _name: `${index}.0`, operator: 'and', query: item } } }, + ]; + } + } + }, []); + + return getShouldQuery({ listId, should }); +}; + +/** + * Given an unknown should this constructs a simple bool and terms with the should + * clause/query. + * @param listId The list id to query against + * @param should The unknown should to construct the query against + */ +export const getShouldQuery = ({ + listId, + should, +}: { listId: string; -}): QueryFilterType => [{ term: { list_id: listId } }, { terms: { [type]: value } }]; + should: unknown; +}): QueryFilterType => { + return [ + { term: { list_id: listId } }, + { + bool: { + minimum_should_match: 1, + should, + }, + }, + ]; +}; diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts index f7ed118ea5857..57f37a1d6bfca 100644 --- a/x-pack/plugins/lists/server/services/utils/index.ts +++ b/x-pack/plugins/lists/server/services/utils/index.ts @@ -15,6 +15,7 @@ export * from './get_search_after_with_tie_breaker'; export * from './get_sort_with_tie_breaker'; export * from './get_source_with_tie_breaker'; export * from './scroll_to_start_page'; +export * from './transform_elastic_named_search_to_list_item'; export * from './transform_elastic_to_list'; export * from './transform_elastic_to_list_item'; export * from './transform_list_item_to_elastic_query'; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts new file mode 100644 index 0000000000000..83a486b5d1544 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSearchListItemResponseMock } from '../../../common/schemas/response/search_list_item_schema.mock'; +import { LIST_INDEX, LIST_ITEM_ID, TYPE, VALUE } from '../../../common/constants.mock'; +import { + getSearchEsListItemMock, + getSearchListItemMock, +} from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; +import { SearchListItemArraySchema } from '../../../common/schemas'; + +import { transformElasticNamedSearchToListItem } from './transform_elastic_named_search_to_list_item'; + +describe('transform_elastic_named_search_to_list_item', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('if given an empty array for values, it returns an empty array', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [], + }); + const expected: SearchListItemArraySchema = []; + expect(queryFilter).toEqual(expected); + }); + + test('if given an empty array for hits, it returns an empty match', () => { + const response = getSearchListItemMock(); + response.hits.hits = []; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const expected: SearchListItemArraySchema = [{ items: [], value: VALUE }]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms a single elastic type to a search list item type', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const expected: SearchListItemArraySchema = [getSearchListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms two elastic types to a search list item type', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['1.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE, VALUE], + }); + const expected: SearchListItemArraySchema = [ + getSearchListItemResponseMock(), + getSearchListItemResponseMock(), + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms only 1 elastic type to a search list item type if only 1 is found as a value', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE, '127.0.0.2'], + }); + const expected: SearchListItemArraySchema = [ + getSearchListItemResponseMock(), + { items: [], value: '127.0.0.2' }, + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it attaches two found results if the value is found in two hits from Elastic Search', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['0.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const { + items: [firstItem], + value, + } = getSearchListItemResponseMock(); + const expected: SearchListItemArraySchema = [ + { + items: [firstItem, firstItem], + value, + }, + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it will return an empty array if no values are passed in', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['1.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [], + }); + expect(queryFilter).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts new file mode 100644 index 0000000000000..0326d22aa8436 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; + +import { transformElasticHitsToListItem } from './transform_elastic_to_list_item'; + +export interface TransformElasticMSearchToListItemOptions { + response: SearchResponse; + type: Type; + value: unknown[]; +} + +/** + * Given an Elasticsearch response this will look to see if the named query matches the + * index found. The named query will have to be in the format of, "1.0", "1.1", "2.0" where the + * major number "1,2,n" will match with the index. + * Ref: https://www.elastic.co/guide/en/elasticsearch//reference/7.9/query-dsl-bool-query.html#named-queries + * @param response The elastic response + * @param type The list type + * @param value The values to check against the named queries. + */ +export const transformElasticNamedSearchToListItem = ({ + response, + type, + value, +}: TransformElasticMSearchToListItemOptions): SearchListItemArraySchema => { + return value.map((singleValue, index) => { + const matchingHits = response.hits.hits.filter((hit) => { + if (hit.matched_queries != null) { + return hit.matched_queries.some((matchedQuery) => { + const [matchedQueryIndex] = matchedQuery.split('.'); + return matchedQueryIndex === `${index}`; + }); + } else { + return false; + } + }); + const items = transformElasticHitsToListItem({ hits: matchingHits, type }); + return { + items, + value: singleValue, + }; + }); +}; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts index 8a5554c3865c5..09e5ecd74b0de 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts @@ -8,7 +8,10 @@ import { getSearchListItemMock } from '../../../common/schemas/elastic_response/ import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; import { ListItemArraySchema } from '../../../common/schemas'; -import { transformElasticToListItem } from './transform_elastic_to_list_item'; +import { + transformElasticHitsToListItem, + transformElasticToListItem, +} from './transform_elastic_to_list_item'; describe('transform_elastic_to_list_item', () => { beforeEach(() => { @@ -19,28 +22,61 @@ describe('transform_elastic_to_list_item', () => { jest.clearAllMocks(); }); - test('it transforms an elastic type to a list item type', () => { - const response = getSearchListItemMock(); - const queryFilter = transformElasticToListItem({ - response, - type: 'ip', + describe('transformElasticToListItem', () => { + test('it transforms an elastic type to a list item type', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticToListItem({ + response, + type: 'ip', + }); + const expected: ListItemArraySchema = [getListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms an elastic keyword type to a list item type', () => { + const response = getSearchListItemMock(); + response.hits.hits[0]._source.ip = undefined; + response.hits.hits[0]._source.keyword = 'host-name-example'; + const queryFilter = transformElasticToListItem({ + response, + type: 'keyword', + }); + const listItemResponse = getListItemResponseMock(); + listItemResponse.type = 'keyword'; + listItemResponse.value = 'host-name-example'; + const expected: ListItemArraySchema = [listItemResponse]; + expect(queryFilter).toEqual(expected); }); - const expected: ListItemArraySchema = [getListItemResponseMock()]; - expect(queryFilter).toEqual(expected); }); - test('it transforms an elastic keyword type to a list item type', () => { - const response = getSearchListItemMock(); - response.hits.hits[0]._source.ip = undefined; - response.hits.hits[0]._source.keyword = 'host-name-example'; - const queryFilter = transformElasticToListItem({ - response, - type: 'keyword', + describe('transformElasticHitsToListItem', () => { + test('it transforms an elastic type to a list item type', () => { + const { + hits: { hits }, + } = getSearchListItemMock(); + const queryFilter = transformElasticHitsToListItem({ + hits, + type: 'ip', + }); + const expected: ListItemArraySchema = [getListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms an elastic keyword type to a list item type', () => { + const { + hits: { hits }, + } = getSearchListItemMock(); + hits[0]._source.ip = undefined; + hits[0]._source.keyword = 'host-name-example'; + const queryFilter = transformElasticHitsToListItem({ + hits, + type: 'keyword', + }); + const listItemResponse = getListItemResponseMock(); + listItemResponse.type = 'keyword'; + listItemResponse.value = 'host-name-example'; + const expected: ListItemArraySchema = [listItemResponse]; + expect(queryFilter).toEqual(expected); }); - const listItemResponse = getListItemResponseMock(); - listItemResponse.type = 'keyword'; - listItemResponse.value = 'host-name-example'; - const expected: ListItemArraySchema = [listItemResponse]; - expect(queryFilter).toEqual(expected); }); }); diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts index 14794870bf67a..db16f213adec8 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts @@ -17,11 +17,23 @@ export interface TransformElasticToListItemOptions { type: Type; } +export interface TransformElasticHitToListItemOptions { + hits: SearchResponse['hits']['hits']; + type: Type; +} + export const transformElasticToListItem = ({ response, type, }: TransformElasticToListItemOptions): ListItemArraySchema => { - return response.hits.hits.map((hit) => { + return transformElasticHitsToListItem({ hits: response.hits.hits, type }); +}; + +export const transformElasticHitsToListItem = ({ + hits, + type, +}: TransformElasticHitToListItemOptions): ListItemArraySchema => { + return hits.map((hit) => { const { _id, _source: { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.test.ts new file mode 100644 index 0000000000000..dabd8cd4cf4ee --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { percentilesValuesToFieldMeta } from './dynamic_style_property'; + +describe('percentilesValuesToFieldMeta', () => { + test('should return null when values is not defined', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect(percentilesValuesToFieldMeta({})).toBeNull(); + }); + + test('should convert values to percentiles field meta', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect( + percentilesValuesToFieldMeta({ + values: { + '25.0': 375.0, + '50.0': 400.0, + '75.0': 550.0, + }, + }) + ).toEqual([ + { percentile: '25.0', value: 375.0 }, + { percentile: '50.0', value: 400.0 }, + { percentile: '75.0', value: 550.0 }, + ]); + }); + + test('should remove duplicated percentile percentilesValuesToFieldMeta', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect( + percentilesValuesToFieldMeta({ + values: { + '25.0': 375.0, + '50.0': 375.0, + '75.0': 550.0, + }, + }) + ).toEqual([ + { percentile: '25.0', value: 375.0 }, + { percentile: '75.0', value: 550.0 }, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 2f2ddd7d539cf..882247e375ddc 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -28,6 +28,7 @@ import { import { CategoryFieldMeta, FieldMetaOptions, + PercentilesFieldMeta, RangeFieldMeta, StyleMetaData, } from '../../../../../common/descriptor_types'; @@ -144,15 +145,8 @@ export class DynamicStyleProperty const styleMetaData = styleMetaDataRequest.getData() as StyleMetaData; const percentiles = styleMetaData[`${this._field.getRootName()}_percentiles`] as | undefined - | { values?: { [key: string]: number } }; - return percentiles !== undefined && percentiles.values !== undefined - ? Object.keys(percentiles.values).map((key) => { - return { - percentile: key, - value: percentiles.values![key], - }; - }) - : null; + | PercentilesValues; + return percentilesValuesToFieldMeta(percentiles); } getCategoryFieldMeta() { @@ -499,3 +493,21 @@ export function getNumericalMbFeatureStateValue(value: RawValue) { const valueAsFloat = parseFloat(value); return isNaN(valueAsFloat) ? null : valueAsFloat; } + +interface PercentilesValues { + values?: { [key: string]: number }; +} +export function percentilesValuesToFieldMeta( + percentiles?: PercentilesValues | undefined +): PercentilesFieldMeta | null { + if (percentiles === undefined || percentiles.values === undefined) { + return null; + } + const percentilesFieldMeta = Object.keys(percentiles.values).map((key) => { + return { + percentile: key, + value: percentiles.values![key], + }; + }); + return _.uniqBy(percentilesFieldMeta, 'value'); +} diff --git a/x-pack/plugins/ml/common/constants/field_types.ts b/x-pack/plugins/ml/common/constants/field_types.ts index 93641fd45c499..24b099d176c64 100644 --- a/x-pack/plugins/ml/common/constants/field_types.ts +++ b/x-pack/plugins/ml/common/constants/field_types.ts @@ -16,7 +16,17 @@ export enum ML_JOB_FIELD_TYPES { } export const MLCATEGORY = 'mlcategory'; + +/** + * For use as summary_count_field_name in datafeeds which use aggregations. + */ export const DOC_COUNT = 'doc_count'; +/** + * Elasticsearch field showing number of documents aggregated in a single summary field for + * pre-aggregated data. For use as summary_count_field_name in datafeeds which do not use aggregations. + */ +export const _DOC_COUNT = '_doc_count'; + // List of system fields we don't want to display. export const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score']; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 04ce7f79e1c02..69c554b413655 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -24,7 +24,7 @@ import { EuiModalBody } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../contexts/kibana'; -import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public'; +import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public'; import { getDefaultPanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { useDashboardService } from '../services/dashboard_service'; import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; @@ -35,7 +35,7 @@ export interface DashboardItem { id: string; title: string; description: string | undefined; - attributes: SavedObjectDashboard; + attributes: DashboardSavedObject; } export type EuiTableProps = EuiInMemoryTableProps; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 440585dcf2a19..23eff26b41c32 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -13,7 +13,11 @@ import { ML_JOB_AGGREGATION, SPARSE_DATA_AGGREGATIONS, } from '../../../../../../../common/constants/aggregation_types'; -import { MLCATEGORY, DOC_COUNT } from '../../../../../../../common/constants/field_types'; +import { + MLCATEGORY, + DOC_COUNT, + _DOC_COUNT, +} from '../../../../../../../common/constants/field_types'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { EVENT_RATE_FIELD_ID, @@ -113,7 +117,11 @@ export function createDocCountFieldOption(usingAggregations: boolean) { label: DOC_COUNT, }, ] - : []; + : [ + { + label: _DOC_COUNT, + }, + ]; } function getDetectorsAdvanced(job: Job, datafeed: Datafeed) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx index 8136008dce11b..e54a48817c4ae 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx @@ -23,8 +23,8 @@ interface Props { export const SummaryCountFieldSelect: FC = ({ fields, changeHandler, selectedField }) => { const { jobCreator } = useContext(JobCreatorContext); const options: EuiComboBoxOptionOption[] = [ - ...createFieldOptions(fields, jobCreator.additionalFields), ...createDocCountFieldOption(jobCreator.aggregationFields.length > 0), + ...createFieldOptions(fields, jobCreator.additionalFields), ]; const selection: EuiComboBoxOptionOption[] = []; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts index 00adb2d325833..41412cf6e378f 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -6,7 +6,7 @@ import { dashboardServiceProvider } from './dashboard_service'; import { savedObjectsServiceMock } from '../../../../../../src/core/public/mocks'; -import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; +import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; import { DashboardUrlGenerator, SavedDashboardPanel, @@ -91,7 +91,7 @@ describe('DashboardService', () => { kibanaSavedObjectMeta: { searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', }, - } as unknown) as SavedObjectDashboard, + } as unknown) as DashboardSavedObject, [{ title: 'Test title', type: 'test-panel', embeddableConfig: { testConfig: '' } }] ); // assert diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts index d6ccfc2f203e9..27f2bd366b880 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -11,7 +11,7 @@ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGenerator, SavedDashboardPanel, - SavedObjectDashboard, + DashboardSavedObject, } from '../../../../../../src/plugins/dashboard/public'; import { useMlKibana } from '../contexts/kibana'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; @@ -32,7 +32,7 @@ export function dashboardServiceProvider( * Fetches dashboards */ async fetchDashboards(query?: string) { - return await savedObjectClient.find({ + return await savedObjectClient.find({ type: 'dashboard', perPage: 1000, search: query ? `${query}*` : '', @@ -60,7 +60,7 @@ export function dashboardServiceProvider( */ async attachPanels( dashboardId: string, - dashboardAttributes: SavedObjectDashboard, + dashboardAttributes: DashboardSavedObject, panelsData: Array> ) { const panels = JSON.parse(dashboardAttributes.panelsJSON) as SavedDashboardPanel[]; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index 62e64b3d4092e..2fa869b058aa2 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -11,6 +11,8 @@ import { get } from 'lodash'; +import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { DOC_COUNT, _DOC_COUNT } from '../../../common/constants/field_types'; import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; // Builds the basic configuration to plot a chart of the source data @@ -35,9 +37,10 @@ export function buildConfigFromDetector(job, detectorIndex) { // Extra checks if the job config uses a summary count field. const summaryCountFieldName = analysisConfig.summary_count_field_name; if ( - config.metricFunction === 'count' && + config.metricFunction === ES_AGGREGATION.COUNT && summaryCountFieldName !== undefined && - summaryCountFieldName !== 'doc_count' + summaryCountFieldName !== DOC_COUNT && + summaryCountFieldName !== _DOC_COUNT ) { // Check for a detector looking at cardinality (distinct count) using an aggregation. // The cardinality field will be in: @@ -50,18 +53,23 @@ export function buildConfigFromDetector(job, detectorIndex) { get(Object.values(topAgg)[0], [ 'aggregations', summaryCountFieldName, - 'cardinality', + ES_AGGREGATION.CARDINALITY, 'field', ]) || - get(Object.values(topAgg)[0], ['aggs', summaryCountFieldName, 'cardinality', 'field']); + get(Object.values(topAgg)[0], [ + 'aggs', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]); } - if (detector.function === 'non_zero_count' && cardinalityField !== undefined) { - config.metricFunction = 'cardinality'; + if (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT && cardinalityField !== undefined) { + config.metricFunction = ES_AGGREGATION.CARDINALITY; config.metricFieldName = cardinalityField; } else { // For count detectors using summary_count_field, plot sum(summary_count_field_name) - config.metricFunction = 'sum'; + config.metricFunction = ES_AGGREGATION.SUM; config.metricFieldName = summaryCountFieldName; } } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json new file mode 100644 index 0000000000000..862f970b7405d --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json new file mode 100644 index 0000000000000..c627e5a6f6253 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json @@ -0,0 +1,104 @@ +{ + "id": "security_linux", + "title": "Security: Linux", + "description": "Detect suspicious activity using ECS Linux events. Tested with Auditbeat and the Elastic agent.", + "type": "linux data", + "logoFile": "logo.json", + "defaultIndexPattern": "auditbeat-*,logs-endpoint.events.*", + "query": { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + } + ] + } + }, + "jobs": [ + { + "id": "v2_rare_process_by_host_linux_ecs", + "file": "v2_rare_process_by_host_linux_ecs.json" + }, + { + "id": "v2_linux_rare_metadata_user", + "file": "v2_linux_rare_metadata_user.json" + }, + { + "id": "v2_linux_rare_metadata_process", + "file": "v2_linux_rare_metadata_process.json" + }, + { + "id": "v2_linux_anomalous_user_name_ecs", + "file": "v2_linux_anomalous_user_name_ecs.json" + }, + { + "id": "v2_linux_anomalous_process_all_hosts_ecs", + "file": "v2_linux_anomalous_process_all_hosts_ecs.json" + }, + { + "id": "v2_linux_anomalous_network_port_activity_ecs", + "file": "v2_linux_anomalous_network_port_activity_ecs.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-v2_rare_process_by_host_linux_ecs", + "file": "datafeed_v2_rare_process_by_host_linux_ecs.json", + "job_id": "v2_rare_process_by_host_linux_ecs" + }, + { + "id": "datafeed-v2_linux_rare_metadata_user", + "file": "datafeed_v2_linux_rare_metadata_user.json", + "job_id": "v2_linux_rare_metadata_user" + }, + { + "id": "datafeed-v2_linux_rare_metadata_process", + "file": "datafeed_v2_linux_rare_metadata_process.json", + "job_id": "v2_linux_rare_metadata_process" + }, + { + "id": "datafeed-v2_linux_anomalous_user_name_ecs", + "file": "datafeed_v2_linux_anomalous_user_name_ecs.json", + "job_id": "v2_linux_anomalous_user_name_ecs" + }, + { + "id": "datafeed-v2_linux_anomalous_process_all_hosts_ecs", + "file": "datafeed_v2_linux_anomalous_process_all_hosts_ecs.json", + "job_id": "v2_linux_anomalous_process_all_hosts_ecs" + }, + { + "id": "datafeed-v2_linux_anomalous_network_port_activity_ecs", + "file": "datafeed_v2_linux_anomalous_network_port_activity_ecs.json", + "job_id": "v2_linux_anomalous_network_port_activity_ecs" + } + ] +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json new file mode 100644 index 0000000000000..67c198b3f56ec --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json @@ -0,0 +1,76 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": + { + "filter": [ + {"term": {"event.category": "network"}}, + {"term": {"event.type": "start"}} + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ], + "must_not": [ + { + "bool": { + "should": [ + {"term": {"destination.ip": "127.0.0.1"}}, + {"term": {"destination.ip": "::"}}, + {"term": {"destination.ip": "::1"}}, + {"term": {"user.name":"jenkins"}} + ] + } + } + ] + } + } + } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json new file mode 100644 index 0000000000000..da41aff66ea01 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json @@ -0,0 +1,101 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ], + "must_not": [ + { + "bool": { + "should": [ + { + "term": { + "user.name": "jenkins-worker" + } + }, + { + "term": { + "user.name": "jenkins-user" + } + }, + { + "term": { + "user.name": "jenkins" + } + }, + { + "wildcard": { + "process.name": { + "wildcard": "jenkins*" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json new file mode 100644 index 0000000000000..673de388e68b9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json new file mode 100644 index 0000000000000..b79d97ef5e40c --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json @@ -0,0 +1,66 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json new file mode 100644 index 0000000000000..b79d97ef5e40c --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json @@ -0,0 +1,66 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json new file mode 100644 index 0000000000000..673de388e68b9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json new file mode 100644 index 0000000000000..2d3be4593c5d6 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json @@ -0,0 +1,55 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity.", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"destination.port\"", + "function": "rare", + "by_field_name": "destination.port" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json new file mode 100644 index 0000000000000..03837cd77a5cc --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json @@ -0,0 +1,54 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for processes that are unusual to all Linux hosts. Such unusual processes may indicate unauthorized services, malware, or persistence mechanisms.", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.name\"", + "function": "rare", + "by_field_name": "process.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "512mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json new file mode 100644 index 0000000000000..3bc5afa6ec8d7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json @@ -0,0 +1,54 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "process" + ], + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"user.name\"", + "function": "rare", + "by_field_name": "user.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json new file mode 100644 index 0000000000000..c550378dad0b3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json @@ -0,0 +1,36 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.name\"", + "function": "rare", + "by_field_name": "process.name" + } + ], + "influencers": [ + "host.name", + "user.name", + "process.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json new file mode 100644 index 0000000000000..66f35bdce12cd --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json @@ -0,0 +1,35 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"user.name\"", + "function": "rare", + "by_field_name": "user.name" + } + ], + "influencers": [ + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json new file mode 100644 index 0000000000000..fa87be8efb010 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json @@ -0,0 +1,55 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for processes that are unusual to a particular Linux host. Such unusual processes may indicate unauthorized services, malware, or persistence mechanisms.", + "groups": [ + "security", + "auditbeat", + "endpoint", + "linux", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare process executions on Linux", + "function": "rare", + "by_field_name": "process.name", + "partition_field_name": "host.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-linux", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json new file mode 100644 index 0000000000000..862f970b7405d --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json new file mode 100644 index 0000000000000..e1bd6eacc6882 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json @@ -0,0 +1,112 @@ +{ + "id": "security_windows", + "title": "Security: Windows", + "description": "Detects suspicious activity using ECS Windows events. Tested with Winlogbeat and the Elastic agent.", + "type": "windows data", + "logoFile": "logo.json", + "defaultIndexPattern": "winlogbeat-*,logs-endpoint.events.*", + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + }, + "jobs": [ + { + "id": "v2_rare_process_by_host_windows_ecs", + "file": "v2_rare_process_by_host_windows_ecs.json" + }, + { + "id": "v2_windows_anomalous_network_activity_ecs", + "file": "v2_windows_anomalous_network_activity_ecs.json" + }, + { + "id": "v2_windows_anomalous_path_activity_ecs", + "file": "v2_windows_anomalous_path_activity_ecs.json" + }, + { + "id": "v2_windows_anomalous_process_all_hosts_ecs", + "file": "v2_windows_anomalous_process_all_hosts_ecs.json" + }, + { + "id": "v2_windows_anomalous_process_creation", + "file": "v2_windows_anomalous_process_creation.json" + }, + { + "id": "v2_windows_anomalous_user_name_ecs", + "file": "v2_windows_anomalous_user_name_ecs.json" + }, + { + "id": "v2_windows_rare_metadata_process", + "file": "v2_windows_rare_metadata_process.json" + }, + { + "id": "v2_windows_rare_metadata_user", + "file": "v2_windows_rare_metadata_user.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-v2_rare_process_by_host_windows_ecs", + "file": "datafeed_v2_rare_process_by_host_windows_ecs.json", + "job_id": "v2_rare_process_by_host_windows_ecs" + }, + { + "id": "datafeed-v2_windows_anomalous_network_activity_ecs", + "file": "datafeed_v2_windows_anomalous_network_activity_ecs.json", + "job_id": "v2_windows_anomalous_network_activity_ecs" + }, + { + "id": "datafeed-v2_windows_anomalous_path_activity_ecs", + "file": "datafeed_v2_windows_anomalous_path_activity_ecs.json", + "job_id": "v2_windows_anomalous_path_activity_ecs" + }, + { + "id": "datafeed-v2_windows_anomalous_process_all_hosts_ecs", + "file": "datafeed_v2_windows_anomalous_process_all_hosts_ecs.json", + "job_id": "v2_windows_anomalous_process_all_hosts_ecs" + }, + { + "id": "datafeed-v2_windows_anomalous_process_creation", + "file": "datafeed_v2_windows_anomalous_process_creation.json", + "job_id": "v2_windows_anomalous_process_creation" + }, + { + "id": "datafeed-v2_windows_anomalous_user_name_ecs", + "file": "datafeed_v2_windows_anomalous_user_name_ecs.json", + "job_id": "v2_windows_anomalous_user_name_ecs" + }, + { + "id": "datafeed-v2_windows_rare_metadata_process", + "file": "datafeed_v2_windows_rare_metadata_process.json", + "job_id": "v2_windows_rare_metadata_process" + }, + { + "id": "datafeed-v2_windows_rare_metadata_user", + "file": "datafeed_v2_windows_rare_metadata_user.json", + "job_id": "v2_windows_rare_metadata_user" + } + ] +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json new file mode 100644 index 0000000000000..fd3c03b3a3e96 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json new file mode 100644 index 0000000000000..d085cfa38c65a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ], + "must_not": [ + { + "bool": { + "should": [ + { + "term": { + "destination.ip": "127.0.0.1" + } + }, + { + "term": { + "destination.ip": "127.0.0.53" + } + }, + { + "term": { + "destination.ip": "::1" + } + } + ], + "minimum_should_match": 1 + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json new file mode 100644 index 0000000000000..fd3c03b3a3e96 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json new file mode 100644 index 0000000000000..fd3c03b3a3e96 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json new file mode 100644 index 0000000000000..fd3c03b3a3e96 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json new file mode 100644 index 0000000000000..fd3c03b3a3e96 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json new file mode 100644 index 0000000000000..f0be23df84c42 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json @@ -0,0 +1,23 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "host.os.family": "windows" + } + }, + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json new file mode 100644 index 0000000000000..f0be23df84c42 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json @@ -0,0 +1,23 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "host.os.family": "windows" + } + }, + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json new file mode 100644 index 0000000000000..a645d3167c302 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json @@ -0,0 +1,57 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Detects unusually rare processes on Windows hosts.", + "groups": [ + "security", + "endpoint", + "event-log", + "sysmon", + "windows", + "winlogbeat", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare process executions on Windows", + "function": "rare", + "by_field_name": "process.name", + "partition_field_name": "host.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json new file mode 100644 index 0000000000000..61bafc6057079 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json @@ -0,0 +1,56 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", + "groups": [ + "security", + "endpoint", + "sysmon", + "windows", + "winlogbeat", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.name\"", + "function": "rare", + "by_field_name": "process.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json new file mode 100644 index 0000000000000..9aea3305cc641 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json @@ -0,0 +1,54 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "security", + "sysmon", + "windows", + "winlogbeat", + "process" + ], + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for activity in unusual paths that may indicate execution of malware or persistence mechanisms. Windows payloads often execute from user profile paths.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.working_directory\"", + "function": "rare", + "by_field_name": "process.working_directory" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json new file mode 100644 index 0000000000000..07e8e872b1b8b --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json @@ -0,0 +1,56 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for processes that are unusual to all Windows hosts. Such unusual processes may indicate execution of unauthorized services, malware, or persistence mechanisms.", + "groups": [ + "security", + "endpoint", + "event-log", + "sysmon", + "windows", + "winlogbeat", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.executable\"", + "function": "rare", + "by_field_name": "process.executable" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json new file mode 100644 index 0000000000000..e59d887ccc909 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json @@ -0,0 +1,57 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "security", + "endpoint", + "event-log", + "sysmon", + "windows", + "winlogbeat", + "process" + ], + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for unusual process relationships which may indicate execution of malware or persistence mechanisms.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Unusual process creation activity", + "function": "rare", + "by_field_name": "process.name", + "partition_field_name": "process.parent.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json new file mode 100644 index 0000000000000..af04625e56fcd --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json @@ -0,0 +1,56 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", + "groups": [ + "security", + "endpoint", + "event-log", + "sysmon", + "windows", + "winlogbeat", + "process" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"user.name\"", + "function": "rare", + "by_field_name": "user.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json new file mode 100644 index 0000000000000..e8f5317be0308 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "security", + "endpoint", + "event-log", + "process", + "sysmon", + "windows", + "winlogbeat" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"process.name\"", + "function": "rare", + "by_field_name": "process.name" + } + ], + "influencers": [ + "process.name", + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json new file mode 100644 index 0000000000000..027dbd84de332 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "security", + "endpoint", + "event-log", + "process", + "sysmon", + "windows", + "winlogbeat" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"user.name\"", + "function": "rare", + "by_field_name": "user.name" + } + ], + "influencers": [ + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-windows" + } +} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts index 7559111d012d0..73d35efd66c8b 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -5,6 +5,7 @@ */ import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { _DOC_COUNT } from '../../../../common/constants/field_types'; import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; import { fieldServiceProvider } from './field_service'; @@ -22,10 +23,12 @@ export function newJobCapsProvider(client: IScopedClusterClient) { const { aggs, fields } = await fieldService.getData(); convertForStringify(aggs, fields); + // Remove the _doc_count field as we don't want to display this in the fields lists in the UI + const fieldsWithoutDocCount = fields.filter(({ id }) => id !== _DOC_COUNT); return { [indexPattern]: { aggs, - fields, + fields: fieldsWithoutDocCount, }, }; } diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts index 8d1ffc89f5dd6..cf382701ca40f 100644 --- a/x-pack/plugins/monitoring/common/constants.ts +++ b/x-pack/plugins/monitoring/common/constants.ts @@ -453,6 +453,42 @@ export const ALERT_DETAILS = { }, }; +export const ALERT_PANEL_MENU = [ + { + label: i18n.translate('xpack.monitoring.alerts.badge.panelCategory.clusterHealth', { + defaultMessage: 'Cluster health', + }), + alerts: [ + { alertName: ALERT_NODES_CHANGED }, + { alertName: ALERT_CLUSTER_HEALTH }, + { alertName: ALERT_ELASTICSEARCH_VERSION_MISMATCH }, + { alertName: ALERT_KIBANA_VERSION_MISMATCH }, + { alertName: ALERT_LOGSTASH_VERSION_MISMATCH }, + ], + }, + { + label: i18n.translate('xpack.monitoring.alerts.badge.panelCategory.resourceUtilization', { + defaultMessage: 'Resource utilization', + }), + alerts: [ + { alertName: ALERT_CPU_USAGE }, + { alertName: ALERT_DISK_USAGE }, + { alertName: ALERT_MEMORY_USAGE }, + ], + }, + { + label: i18n.translate('xpack.monitoring.alerts.badge.panelCategory.errors', { + defaultMessage: 'Errors and exceptions', + }), + alerts: [ + { alertName: ALERT_MISSING_MONITORING_DATA }, + { alertName: ALERT_LICENSE_EXPIRATION }, + { alertName: ALERT_THREAD_POOL_SEARCH_REJECTIONS }, + { alertName: ALERT_THREAD_POOL_WRITE_REJECTIONS }, + ], + }, +]; + /** * A listing of all alert types */ diff --git a/x-pack/plugins/monitoring/common/formatting.js b/x-pack/plugins/monitoring/common/formatting.ts similarity index 65% rename from x-pack/plugins/monitoring/common/formatting.js rename to x-pack/plugins/monitoring/common/formatting.ts index b2a67b3cd48da..65159f532d2aa 100644 --- a/x-pack/plugins/monitoring/common/formatting.js +++ b/x-pack/plugins/monitoring/common/formatting.ts @@ -11,13 +11,14 @@ export const SMALL_FLOAT = '0.[00]'; export const LARGE_BYTES = '0,0.0 b'; export const SMALL_BYTES = '0.0 b'; export const LARGE_ABBREVIATED = '0,0.[0]a'; +export const ROUNDED_FLOAT = '00.[00]'; /** * Format the {@code date} in the user's expected date/time format using their guessed local time zone. * @param date Either a numeric Unix timestamp or a {@code Date} object * @returns The date formatted using 'LL LTS' */ -export function formatDateTimeLocal(date, useUTC = false, timezone = null) { +export function formatDateTimeLocal(date: number | Date, useUTC = false, timezone = null) { return useUTC ? moment.utc(date).format('LL LTS') : moment.tz(date, timezone || moment.tz.guess()).format('LL LTS'); @@ -28,6 +29,18 @@ export function formatDateTimeLocal(date, useUTC = false, timezone = null) { * @param {string} hash The complete hash * @return {string} The shortened hash */ -export function shortenPipelineHash(hash) { +export function shortenPipelineHash(hash: string) { return hash.substr(0, 6); } + +export function getDateFromNow(timestamp: string | number | Date, tz: string) { + return moment(timestamp) + .tz(tz === 'Browser' ? moment.tz.guess() : tz) + .fromNow(); +} + +export function getCalendar(timestamp: string | number | Date, tz: string) { + return moment(timestamp) + .tz(tz === 'Browser' ? moment.tz.guess() : tz) + .calendar(); +} diff --git a/x-pack/plugins/monitoring/common/types/alerts.ts b/x-pack/plugins/monitoring/common/types/alerts.ts index 0daa947b1c82a..0f10e0e48962b 100644 --- a/x-pack/plugins/monitoring/common/types/alerts.ts +++ b/x-pack/plugins/monitoring/common/types/alerts.ts @@ -7,6 +7,8 @@ import { Alert, SanitizedAlert } from '../../../alerts/common'; import { AlertParamType, AlertMessageTokenType, AlertSeverity } from '../enums'; +export type CommonAlert = Alert | SanitizedAlert; + export interface CommonAlertStatus { states: CommonAlertState[]; rawAlert: Alert | SanitizedAlert; @@ -179,6 +181,7 @@ export interface LegacyAlert { message: string; resolved_timestamp: string; metadata: LegacyAlertMetadata; + nodeName: string; nodes?: LegacyAlertNodesChangedList; } diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index e4ee805c4b48f..369f03ab8cb11 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -156,6 +156,10 @@ describe('alert_form', () => { }); it('should update throttle value', async () => { + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onThrottleInterval"]').simulate('click'); + wrapper.update(); const newThrottle = 17; const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); expect(throttleField.exists()).toBeTruthy(); diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index b9e39e43ff73d..31a86757cac8e 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -4,187 +4,115 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiContextMenu, - EuiPopover, - EuiBadge, - EuiFlexGrid, - EuiFlexItem, - EuiText, -} from '@elastic/eui'; -import { CommonAlertStatus, CommonAlertState } from '../../common/types/alerts'; +import { EuiContextMenu, EuiPopover, EuiBadge, EuiSwitch } from '@elastic/eui'; +import { AlertState, CommonAlertStatus } from '../../common/types/alerts'; import { AlertSeverity } from '../../common/enums'; // @ts-ignore import { formatDateTimeLocal } from '../../common/formatting'; -import { AlertState } from '../../common/types/alerts'; -import { AlertPanel } from './panel'; -import { Legacy } from '../legacy_shims'; import { isInSetupMode } from '../lib/setup_mode'; import { SetupModeContext } from '../components/setup_mode/setup_mode_context'; - -function getDateFromState(state: CommonAlertState) { - const timestamp = state.state.ui.triggeredMS; - const tz = Legacy.shims.uiSettings.get('dateFormat:tz'); - return formatDateTimeLocal(timestamp, false, tz === 'Browser' ? null : tz); -} +import { AlertsContext } from './context'; +import { getAlertPanelsByCategory } from './lib/get_alert_panels_by_category'; +import { getAlertPanelsByNode } from './lib/get_alert_panels_by_node'; export const numberOfAlertsLabel = (count: number) => `${count} alert${count > 1 ? 's' : ''}`; -interface AlertInPanel { - alert: CommonAlertStatus; - alertState: CommonAlertState; -} +const MAX_TO_SHOW_BY_CATEGORY = 8; + +const PANEL_TITLE = i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { + defaultMessage: 'Alerts', +}); + +const GROUP_BY_NODE = i18n.translate('xpack.monitoring.alerts.badge.groupByNode', { + defaultMessage: 'Group by node', +}); + +const GROUP_BY_TYPE = i18n.translate('xpack.monitoring.alerts.badge.groupByType', { + defaultMessage: 'Group by alert type', +}); interface Props { alerts: { [alertTypeId: string]: CommonAlertStatus }; stateFilter: (state: AlertState) => boolean; } export const AlertsBadge: React.FC = (props: Props) => { + // We do not always have the alerts that each consumer wants due to licensing const { stateFilter = () => true } = props; + const alerts = Object.values(props.alerts).filter((alertItem) => Boolean(alertItem?.rawAlert)); const [showPopover, setShowPopover] = React.useState(null); const inSetupMode = isInSetupMode(React.useContext(SetupModeContext)); - const alerts = Object.values(props.alerts).filter((alertItem) => Boolean(alertItem?.rawAlert)); - - if (alerts.length === 0) { - return null; - } - - const badges = []; - - if (inSetupMode) { - const button = ( - setShowPopover(true)} - > - {numberOfAlertsLabel(alerts.length)} - - ); - const panels = [ - { - id: 0, - title: i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { - defaultMessage: 'Alerts', - }), - items: alerts.map(({ rawAlert }, index) => { - return { - name: {rawAlert.name}, - panel: index + 1, - }; - }), - }, - ...alerts.map((alertStatus, index) => { - return { - id: index + 1, - title: alertStatus.rawAlert.name, - width: 400, - content: , - }; - }), - ]; - - badges.push( - setShowPopover(null)} - panelPaddingSize="none" - anchorPosition="downLeft" - > - - - ); - } else { - const byType = { - [AlertSeverity.Danger]: [] as AlertInPanel[], - [AlertSeverity.Warning]: [] as AlertInPanel[], - [AlertSeverity.Success]: [] as AlertInPanel[], - }; + const alertsContext = React.useContext(AlertsContext); + const alertCount = inSetupMode + ? alerts.length + : alerts.reduce( + (sum, { states }) => sum + states.filter(({ state }) => stateFilter(state)).length, + 0 + ); + const [showByNode, setShowByNode] = React.useState( + !inSetupMode && alertCount > MAX_TO_SHOW_BY_CATEGORY + ); - for (const alert of alerts) { - for (const alertState of alert.states) { - if (alertState.firing && stateFilter(alertState.state)) { - const state = alertState.state as AlertState; - byType[state.ui.severity].push({ - alertState, - alert, - }); - } - } + React.useEffect(() => { + if (inSetupMode && showByNode) { + setShowByNode(false); } + }, [inSetupMode, showByNode]); - const typesToShow = [AlertSeverity.Danger, AlertSeverity.Warning]; - for (const type of typesToShow) { - const list = byType[type]; - if (list.length === 0) { - continue; - } + if (alertCount === 0) { + return null; + } - const button = ( - setShowPopover(type)} - > - {numberOfAlertsLabel(list.length)} - - ); + const groupByType = GROUP_BY_NODE; + const panels = showByNode + ? getAlertPanelsByNode(PANEL_TITLE, alerts, stateFilter) + : getAlertPanelsByCategory(PANEL_TITLE, inSetupMode, alerts, alertsContext, stateFilter); - const panels = [ + if (panels.length && !inSetupMode && panels[0].items) { + panels[0].items.push( + ...[ { - id: 0, - title: `Alerts`, - items: list.map(({ alert, alertState }, index) => { - return { - name: ( - - -

{getDateFromState(alertState)}

-
- {alert.rawAlert.name} -
- ), - panel: index + 1, - }; - }), + isSeparator: true as const, }, - ...list.map((alertStatus, index) => { - return { - id: index + 1, - title: getDateFromState(alertStatus.alertState), - width: 400, - content: , - }; - }), - ]; - - badges.push( - setShowPopover(null)} - panelPaddingSize="none" - anchorPosition="downLeft" - > - - - ); - } + { + name: ( + setShowByNode(!showByNode)} + label={showByNode ? GROUP_BY_TYPE : groupByType} + /> + ), + }, + ] + ); } + const button = ( + setShowPopover(true)} + > + {numberOfAlertsLabel(alertCount)} + + ); + return ( - - {badges.map((badge, index) => ( - - {badge} - - ))} - + setShowPopover(null)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + ); }; diff --git a/x-pack/plugins/monitoring/public/alerts/callout.tsx b/x-pack/plugins/monitoring/public/alerts/callout.tsx index 2f670ac221bf2..d3feb148cf986 100644 --- a/x-pack/plugins/monitoring/public/alerts/callout.tsx +++ b/x-pack/plugins/monitoring/public/alerts/callout.tsx @@ -5,78 +5,108 @@ */ import React, { Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { CommonAlertStatus } from '../../common/types/alerts'; -import { AlertSeverity } from '../../common/enums'; +import { + EuiPanel, + EuiSpacer, + EuiAccordion, + EuiListGroup, + EuiListGroupItem, + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, +} from '@elastic/eui'; import { replaceTokens } from './lib/replace_tokens'; -import { AlertMessage, AlertState } from '../../common/types/alerts'; - -const TYPES = [ - { - severity: AlertSeverity.Warning, - color: 'warning', - label: i18n.translate('xpack.monitoring.alerts.callout.warningLabel', { - defaultMessage: 'Warning alert(s)', - }), - }, - { - severity: AlertSeverity.Danger, - color: 'danger', - label: i18n.translate('xpack.monitoring.alerts.callout.dangerLabel', { - defaultMessage: 'Danger alert(s)', - }), - }, -]; +import { AlertMessage } from '../../common/types/alerts'; +import { AlertsByName } from './types'; +import { isInSetupMode } from '../lib/setup_mode'; +import { SetupModeContext } from '../components/setup_mode/setup_mode_context'; +import { AlertConfiguration } from './configuration'; interface Props { - alerts: { [alertTypeId: string]: CommonAlertStatus }; - stateFilter: (state: AlertState) => boolean; + alerts: AlertsByName; } export const AlertsCallout: React.FC = (props: Props) => { - const { alerts, stateFilter = () => true } = props; + const { alerts } = props; + const inSetupMode = isInSetupMode(React.useContext(SetupModeContext)); + + if (inSetupMode) { + return null; + } - const callouts = TYPES.map((type) => { - const list = []; - for (const alertTypeId of Object.keys(alerts)) { - const alertInstance = alerts[alertTypeId]; - for (const { firing, state } of alertInstance.states) { - if (firing && stateFilter(state) && state.ui.severity === type.severity) { - list.push(state); - } - } + const list = []; + for (const alertTypeId of Object.keys(alerts)) { + const alertInstance = alerts[alertTypeId]; + for (const state of alertInstance.states) { + list.push({ + alert: alertInstance, + state, + }); } + } - if (list.length) { - return ( - - -
    - {list.map((state, index) => { - const nextStepsUi = - state.ui.message.nextSteps && state.ui.message.nextSteps.length ? ( -
      - {state.ui.message.nextSteps.map( - (step: AlertMessage, nextStepIndex: number) => ( -
    • {replaceTokens(step)}
    • - ) - )} -
    - ) : null; + if (list.length === 0) { + return null; + } - return ( -
  • - {replaceTokens(state.ui.message)} - {nextStepsUi} -
  • - ); - })} -
-
- -
- ); - } + const accordions = list.map((status, index) => { + const buttonContent = ( +
+ + + + + + + + {replaceTokens(status.state.state.ui.message)} + + + +
+ ); + + const accordion = ( + + + {(status.state.state.ui.message.nextSteps || []).map((step: AlertMessage) => { + return {}} label={replaceTokens(step)} />; + })} + } + /> + + + ); + + const spacer = index !== list.length - 1 ? : null; + return ( +
+ {accordion} + {spacer} +
+ ); }); - return {callouts}; + + return ( + + {accordions} + + + ); }; diff --git a/x-pack/plugins/monitoring/public/alerts/configuration.tsx b/x-pack/plugins/monitoring/public/alerts/configuration.tsx new file mode 100644 index 0000000000000..c570e2c840f01 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/configuration.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSwitch } from '@elastic/eui'; +import { CommonAlert } from '../../common/types/alerts'; +import { Legacy } from '../legacy_shims'; +import { hideBottomBar, showBottomBar } from '../lib/setup_mode'; +import { BASE_ALERT_API_PATH } from '../../../alerts/common'; + +interface Props { + alert: CommonAlert; + compressed?: boolean; +} +export const AlertConfiguration: React.FC = (props: Props) => { + const { alert, compressed } = props; + const [showFlyout, setShowFlyout] = React.useState(false); + const [isEnabled, setIsEnabled] = React.useState(alert.enabled); + const [isMuted, setIsMuted] = React.useState(alert.muteAll); + const [isSaving, setIsSaving] = React.useState(false); + + async function disableAlert() { + setIsSaving(true); + try { + await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${alert.id}/_disable`); + } catch (err) { + Legacy.shims.toastNotifications.addDanger({ + title: i18n.translate('xpack.monitoring.alerts.panel.disableAlert.errorTitle', { + defaultMessage: `Unable to disable alert`, + }), + text: err.message, + }); + } + setIsSaving(false); + } + async function enableAlert() { + setIsSaving(true); + try { + await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${alert.id}/_enable`); + } catch (err) { + Legacy.shims.toastNotifications.addDanger({ + title: i18n.translate('xpack.monitoring.alerts.panel.enableAlert.errorTitle', { + defaultMessage: `Unable to enable alert`, + }), + text: err.message, + }); + } + setIsSaving(false); + } + async function muteAlert() { + setIsSaving(true); + try { + await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${alert.id}/_mute_all`); + } catch (err) { + Legacy.shims.toastNotifications.addDanger({ + title: i18n.translate('xpack.monitoring.alerts.panel.muteAlert.errorTitle', { + defaultMessage: `Unable to mute alert`, + }), + text: err.message, + }); + } + setIsSaving(false); + } + async function unmuteAlert() { + setIsSaving(true); + try { + await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${alert.id}/_unmute_all`); + } catch (err) { + Legacy.shims.toastNotifications.addDanger({ + title: i18n.translate('xpack.monitoring.alerts.panel.ummuteAlert.errorTitle', { + defaultMessage: `Unable to unmute alert`, + }), + text: err.message, + }); + } + setIsSaving(false); + } + + const flyoutUi = useMemo( + () => + showFlyout && + Legacy.shims.triggersActionsUi.getEditAlertFlyout({ + initialAlert: alert, + onClose: () => { + setShowFlyout(false); + showBottomBar(); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [showFlyout] + ); + + return ( + + + + { + setShowFlyout(true); + hideBottomBar(); + }} + > + {i18n.translate('xpack.monitoring.alerts.panel.editAlert', { + defaultMessage: `Edit alert`, + })} + + + + { + if (isEnabled) { + setIsEnabled(false); + await disableAlert(); + } else { + setIsEnabled(true); + await enableAlert(); + } + }} + label={ + + } + /> + + + { + if (isMuted) { + setIsMuted(false); + await unmuteAlert(); + } else { + setIsMuted(true); + await muteAlert(); + } + }} + label={ + + } + /> + + + {flyoutUi} + + ); +}; diff --git a/x-pack/plugins/monitoring/public/alerts/context.ts b/x-pack/plugins/monitoring/public/alerts/context.ts new file mode 100644 index 0000000000000..1017a4ade6c73 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/context.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 React from 'react'; +import { AlertsByName } from './types'; + +export interface IAlertsContext { + allAlerts: AlertsByName; +} + +export const AlertsContext = React.createContext({ + allAlerts: {} as AlertsByName, +}); diff --git a/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_category.test.tsx.snap b/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_category.test.tsx.snap new file mode 100644 index 0000000000000..75637b5bfd6c2 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_category.test.tsx.snap @@ -0,0 +1,1287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAlertPanelsByCategory non setup mode should allow for state filtering 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + + Resource utilization + ( + 1 + ) + + , + "panel": 1, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_cpu_usage_label + ( + 1 + ) + , + "panel": 2, + }, + ], + "title": "Resource utilization", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + es_name_0 + + , + "panel": 3, + }, + Object { + "isSeparator": true, + }, + ], + "title": "monitoring_alert_cpu_usage_label", + }, + Object { + "content": , + "id": 3, + "title": "monitoring_alert_cpu_usage_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByCategory non setup mode should not show any alert if none are firing 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [], + "title": "Alerts", + }, +] +`; + +exports[`getAlertPanelsByCategory non setup mode should properly group for alerts in a single category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + + Resource utilization + ( + 2 + ) + + , + "panel": 1, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_jvm_memory_usage_label + ( + 2 + ) + , + "panel": 2, + }, + ], + "title": "Resource utilization", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + + + triggered:1 + + + + es_name_1 + + , + "panel": 3, + }, + Object { + "isSeparator": true, + }, + Object { + "name": + + + triggered:0 + + + + es_name_0 + + , + "panel": 4, + }, + Object { + "isSeparator": true, + }, + ], + "title": "monitoring_alert_jvm_memory_usage_label", + }, + Object { + "content": , + "id": 3, + "title": "monitoring_alert_jvm_memory_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 4, + "title": "monitoring_alert_jvm_memory_usage_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByCategory non setup mode should properly group for alerts in each category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + + Cluster health + ( + 2 + ) + + , + "panel": 1, + }, + Object { + "name": + + Resource utilization + ( + 1 + ) + + , + "panel": 2, + }, + Object { + "name": + + Errors and exceptions + ( + 2 + ) + + , + "panel": 3, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_nodes_changed_label + ( + 2 + ) + , + "panel": 4, + }, + ], + "title": "Cluster health", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + monitoring_alert_disk_usage_label + ( + 1 + ) + , + "panel": 5, + }, + ], + "title": "Resource utilization", + }, + Object { + "id": 3, + "items": Array [ + Object { + "name": + monitoring_alert_license_expiration_label + ( + 2 + ) + , + "panel": 6, + }, + ], + "title": "Errors and exceptions", + }, + Object { + "id": 4, + "items": Array [ + Object { + "name": + + + triggered:1 + + + + es_name_1 + + , + "panel": 7, + }, + Object { + "isSeparator": true, + }, + Object { + "name": + + + triggered:0 + + + + es_name_0 + + , + "panel": 8, + }, + Object { + "isSeparator": true, + }, + ], + "title": "monitoring_alert_nodes_changed_label", + }, + Object { + "id": 5, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + es_name_0 + + , + "panel": 9, + }, + Object { + "isSeparator": true, + }, + ], + "title": "monitoring_alert_disk_usage_label", + }, + Object { + "id": 6, + "items": Array [ + Object { + "name": + + + triggered:1 + + + + es_name_1 + + , + "panel": 10, + }, + Object { + "isSeparator": true, + }, + Object { + "name": + + + triggered:0 + + + + es_name_0 + + , + "panel": 11, + }, + Object { + "isSeparator": true, + }, + ], + "title": "monitoring_alert_license_expiration_label", + }, + Object { + "content": , + "id": 7, + "title": "monitoring_alert_nodes_changed_label", + "width": 400, + }, + Object { + "content": , + "id": 8, + "title": "monitoring_alert_nodes_changed_label", + "width": 400, + }, + Object { + "content": , + "id": 9, + "title": "monitoring_alert_disk_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 10, + "title": "monitoring_alert_license_expiration_label", + "width": 400, + }, + Object { + "content": , + "id": 11, + "title": "monitoring_alert_license_expiration_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByCategory setup mode should properly group for alerts in a single category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + Resource utilization + , + "panel": 1, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_jvm_memory_usage_label + , + "panel": 2, + }, + ], + "title": "Resource utilization", + }, + Object { + "content": , + "id": 2, + "title": "monitoring_alert_jvm_memory_usage_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByCategory setup mode should properly group for alerts in each category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + Cluster health + , + "panel": 1, + }, + Object { + "name": + Resource utilization + , + "panel": 2, + }, + Object { + "name": + Errors and exceptions + , + "panel": 3, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_nodes_changed_label + , + "panel": 4, + }, + ], + "title": "Cluster health", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + monitoring_alert_disk_usage_label + , + "panel": 5, + }, + ], + "title": "Resource utilization", + }, + Object { + "id": 3, + "items": Array [ + Object { + "name": + monitoring_alert_license_expiration_label + , + "panel": 6, + }, + ], + "title": "Errors and exceptions", + }, + Object { + "content": , + "id": 4, + "title": "monitoring_alert_nodes_changed_label", + "width": 400, + }, + Object { + "content": , + "id": 5, + "title": "monitoring_alert_disk_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 6, + "title": "monitoring_alert_license_expiration_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByCategory setup mode should still show alerts if none are firing 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + Cluster health + , + "panel": 1, + }, + Object { + "name": + Resource utilization + , + "panel": 2, + }, + Object { + "name": + Errors and exceptions + , + "panel": 3, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + monitoring_alert_logstash_version_mismatch_label + , + "panel": 4, + }, + ], + "title": "Cluster health", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + monitoring_alert_cpu_usage_label + , + "panel": 5, + }, + ], + "title": "Resource utilization", + }, + Object { + "id": 3, + "items": Array [ + Object { + "name": + monitoring_alert_thread_pool_write_rejections_label + , + "panel": 6, + }, + ], + "title": "Errors and exceptions", + }, + Object { + "content": , + "id": 4, + "title": "monitoring_alert_logstash_version_mismatch_label", + "width": 400, + }, + Object { + "content": , + "id": 5, + "title": "monitoring_alert_cpu_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 6, + "title": "monitoring_alert_thread_pool_write_rejections_label", + "width": 400, + }, +] +`; diff --git a/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_node.test.tsx.snap b/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_node.test.tsx.snap new file mode 100644 index 0000000000000..e9e89112a9758 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/__snapshots__/get_alert_panels_by_node.test.tsx.snap @@ -0,0 +1,660 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAlertPanelsByNode should not show any alert if none are firing 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [], + "title": "Alerts", + }, +] +`; + +exports[`getAlertPanelsByNode should properly group for alerts in a single category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + es_name_0 + ( + 1 + ) + , + "panel": 1, + }, + Object { + "name": + es_name_1 + ( + 1 + ) + , + "panel": 2, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + monitoring_alert_jvm_memory_usage_label + + , + "panel": 3, + }, + ], + "title": "es_name_0", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + monitoring_alert_jvm_memory_usage_label + + , + "panel": 4, + }, + ], + "title": "es_name_1", + }, + Object { + "content": , + "id": 3, + "title": "monitoring_alert_jvm_memory_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 4, + "title": "monitoring_alert_jvm_memory_usage_label", + "width": 400, + }, +] +`; + +exports[`getAlertPanelsByNode should properly group for alerts in each category 1`] = ` +Array [ + Object { + "id": 0, + "items": Array [ + Object { + "name": + es_name_0 + ( + 3 + ) + , + "panel": 1, + }, + Object { + "name": + es_name_1 + ( + 2 + ) + , + "panel": 2, + }, + ], + "title": "Alerts", + }, + Object { + "id": 1, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + monitoring_alert_nodes_changed_label + + , + "panel": 3, + }, + Object { + "name": + + + triggered:0 + + + + monitoring_alert_disk_usage_label + + , + "panel": 4, + }, + Object { + "name": + + + triggered:0 + + + + monitoring_alert_license_expiration_label + + , + "panel": 5, + }, + ], + "title": "es_name_0", + }, + Object { + "id": 2, + "items": Array [ + Object { + "name": + + + triggered:0 + + + + monitoring_alert_nodes_changed_label + + , + "panel": 6, + }, + Object { + "name": + + + triggered:0 + + + + monitoring_alert_license_expiration_label + + , + "panel": 7, + }, + ], + "title": "es_name_1", + }, + Object { + "content": , + "id": 3, + "title": "monitoring_alert_nodes_changed_label", + "width": 400, + }, + Object { + "content": , + "id": 4, + "title": "monitoring_alert_disk_usage_label", + "width": 400, + }, + Object { + "content": , + "id": 5, + "title": "monitoring_alert_license_expiration_label", + "width": 400, + }, + Object { + "content": , + "id": 6, + "title": "monitoring_alert_nodes_changed_label", + "width": 400, + }, + Object { + "content": , + "id": 7, + "title": "monitoring_alert_license_expiration_label", + "width": 400, + }, +] +`; diff --git a/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.test.tsx b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.test.tsx new file mode 100644 index 0000000000000..16b20119c9607 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.test.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ALERTS, + ALERT_CPU_USAGE, + ALERT_LOGSTASH_VERSION_MISMATCH, + ALERT_THREAD_POOL_WRITE_REJECTIONS, +} from '../../../common/constants'; +import { AlertSeverity } from '../../../common/enums'; +import { getAlertPanelsByCategory } from './get_alert_panels_by_category'; +import { + ALERT_LICENSE_EXPIRATION, + ALERT_NODES_CHANGED, + ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, +} from '../../../common/constants'; +import { AlertsByName } from '../types'; +import { AlertExecutionStatusValues } from '../../../../alerts/common'; +import { AlertState } from '../../../common/types/alerts'; + +jest.mock('../../legacy_shims', () => ({ + Legacy: { + shims: { + uiSettings: { + get: () => '', + }, + }, + }, +})); + +jest.mock('../../../common/formatting', () => ({ + getDateFromNow: (timestamp: number) => `triggered:${timestamp}`, + getCalendar: (timestamp: number) => `triggered:${timestamp}`, +})); + +const mockAlert = { + id: '', + enabled: true, + tags: [], + consumer: '', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date('2020-12-08'), + updatedAt: new Date('2020-12-08'), + apiKey: null, + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: AlertExecutionStatusValues[0], + lastExecutionDate: new Date('2020-12-08'), + }, + notifyWhen: null, +}; + +function getAllAlerts() { + return ALERTS.reduce((accum: AlertsByName, alertType) => { + accum[alertType] = { + states: [], + rawAlert: { + alertTypeId: alertType, + name: `${alertType}_label`, + ...mockAlert, + }, + }; + return accum; + }, {}); +} + +describe('getAlertPanelsByCategory', () => { + const ui = { + isFiring: false, + severity: AlertSeverity.Danger, + message: { text: '' }, + resolvedMS: 0, + lastCheckedMS: 0, + triggeredMS: 0, + }; + + const cluster = { clusterUuid: '1', clusterName: 'one' }; + + function getAlert(type: string, firingCount: number) { + const states = []; + + for (let fi = 0; fi < firingCount; fi++) { + states.push({ + firing: true, + meta: {}, + state: { + cluster, + ui: { + ...ui, + triggeredMS: fi, + }, + nodeId: `es${fi}`, + nodeName: `es_name_${fi}`, + }, + }); + } + + return { + states, + rawAlert: { + alertTypeId: type, + name: `${type}_label`, + ...mockAlert, + }, + }; + } + + const alertsContext = { + allAlerts: getAllAlerts(), + }; + + const stateFilter = (state: AlertState) => true; + const panelTitle = 'Alerts'; + + describe('non setup mode', () => { + it('should properly group for alerts in each category', () => { + const alerts = [ + getAlert(ALERT_NODES_CHANGED, 2), + getAlert(ALERT_DISK_USAGE, 1), + getAlert(ALERT_LICENSE_EXPIRATION, 2), + ]; + const result = getAlertPanelsByCategory( + panelTitle, + false, + alerts, + alertsContext, + stateFilter + ); + expect(result).toMatchSnapshot(); + }); + + it('should properly group for alerts in a single category', () => { + const alerts = [getAlert(ALERT_MEMORY_USAGE, 2)]; + const result = getAlertPanelsByCategory( + panelTitle, + false, + alerts, + alertsContext, + stateFilter + ); + expect(result).toMatchSnapshot(); + }); + + it('should not show any alert if none are firing', () => { + const alerts = [ + getAlert(ALERT_LOGSTASH_VERSION_MISMATCH, 0), + getAlert(ALERT_CPU_USAGE, 0), + getAlert(ALERT_THREAD_POOL_WRITE_REJECTIONS, 0), + ]; + const result = getAlertPanelsByCategory( + panelTitle, + false, + alerts, + alertsContext, + stateFilter + ); + expect(result).toMatchSnapshot(); + }); + + it('should allow for state filtering', () => { + const alerts = [getAlert(ALERT_CPU_USAGE, 2)]; + const customStateFilter = (state: AlertState) => state.nodeName === 'es_name_0'; + const result = getAlertPanelsByCategory( + panelTitle, + false, + alerts, + alertsContext, + customStateFilter + ); + expect(result).toMatchSnapshot(); + }); + }); + + describe('setup mode', () => { + it('should properly group for alerts in each category', () => { + const alerts = [ + getAlert(ALERT_NODES_CHANGED, 2), + getAlert(ALERT_DISK_USAGE, 1), + getAlert(ALERT_LICENSE_EXPIRATION, 2), + ]; + const result = getAlertPanelsByCategory(panelTitle, true, alerts, alertsContext, stateFilter); + expect(result).toMatchSnapshot(); + }); + + it('should properly group for alerts in a single category', () => { + const alerts = [getAlert(ALERT_MEMORY_USAGE, 2)]; + const result = getAlertPanelsByCategory(panelTitle, true, alerts, alertsContext, stateFilter); + expect(result).toMatchSnapshot(); + }); + + it('should still show alerts if none are firing', () => { + const alerts = [ + getAlert(ALERT_LOGSTASH_VERSION_MISMATCH, 0), + getAlert(ALERT_CPU_USAGE, 0), + getAlert(ALERT_THREAD_POOL_WRITE_REJECTIONS, 0), + ]; + const result = getAlertPanelsByCategory(panelTitle, true, alerts, alertsContext, stateFilter); + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.tsx b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.tsx new file mode 100644 index 0000000000000..82a1a1f841a22 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_category.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiText, EuiToolTip } from '@elastic/eui'; +import { AlertPanel } from '../panel'; +import { ALERT_PANEL_MENU } from '../../../common/constants'; +import { getDateFromNow, getCalendar } from '../../../common/formatting'; +import { IAlertsContext } from '../context'; +import { AlertState, CommonAlertStatus } from '../../../common/types/alerts'; +import { PanelItem } from '../types'; +import { sortByNewestAlert } from './sort_by_newest_alert'; +import { Legacy } from '../../legacy_shims'; + +export function getAlertPanelsByCategory( + panelTitle: string, + inSetupMode: boolean, + alerts: CommonAlertStatus[], + alertsContext: IAlertsContext, + stateFilter: (state: AlertState) => boolean +) { + const menu = []; + for (const category of ALERT_PANEL_MENU) { + let categoryFiringAlertCount = 0; + if (inSetupMode) { + const alertsInCategory = []; + for (const categoryAlert of category.alerts) { + if ( + Boolean(alerts.find(({ rawAlert }) => rawAlert.alertTypeId === categoryAlert.alertName)) + ) { + alertsInCategory.push(categoryAlert); + } + } + if (alertsInCategory.length > 0) { + menu.push({ + ...category, + alerts: alertsInCategory.map(({ alertName }) => { + const alertStatus = alertsContext.allAlerts[alertName]; + return { + alert: alertStatus.rawAlert, + states: [], + alertName, + }; + }), + alertCount: 0, + }); + } + } else { + const firingAlertsInCategory = []; + for (const { alertName } of category.alerts) { + const foundAlert = alerts.find( + ({ rawAlert: { alertTypeId } }) => alertName === alertTypeId + ); + if (foundAlert && foundAlert.states.length > 0) { + const states = foundAlert.states.filter(({ state }) => stateFilter(state)); + if (states.length > 0) { + firingAlertsInCategory.push({ + alert: foundAlert.rawAlert, + states: foundAlert.states, + alertName, + }); + categoryFiringAlertCount += states.length; + } + } + } + + if (firingAlertsInCategory.length > 0) { + menu.push({ + ...category, + alertCount: categoryFiringAlertCount, + alerts: firingAlertsInCategory, + }); + } + } + } + + for (const item of menu) { + for (const alert of item.alerts) { + alert.states.sort(sortByNewestAlert); + } + } + + const panels: PanelItem[] = [ + { + id: 0, + title: panelTitle, + items: [ + ...menu.map((category, index) => { + const name = inSetupMode ? ( + {category.label} + ) : ( + + + {category.label} ({category.alertCount}) + + + ); + return { + name, + panel: index + 1, + }; + }), + ], + }, + ]; + + if (inSetupMode) { + let secondaryPanelIndex = menu.length; + let tertiaryPanelIndex = menu.length; + let nodeIndex = 0; + for (const category of menu) { + panels.push({ + id: nodeIndex + 1, + title: `${category.label}`, + items: category.alerts.map(({ alertName }) => { + const alertStatus = alertsContext.allAlerts[alertName]; + return { + name: {alertStatus.rawAlert.name}, + panel: ++secondaryPanelIndex, + }; + }), + }); + nodeIndex++; + } + + for (const category of menu) { + for (const { alert, alertName } of category.alerts) { + const alertStatus = alertsContext.allAlerts[alertName]; + panels.push({ + id: ++tertiaryPanelIndex, + title: `${alert.name}`, + width: 400, + content: , + }); + } + } + } else { + let primaryPanelIndex = menu.length; + let nodeIndex = 0; + for (const category of menu) { + panels.push({ + id: nodeIndex + 1, + title: `${category.label}`, + items: category.alerts.map(({ alertName, states }) => { + const filteredStates = states.filter(({ state }) => stateFilter(state)); + const alertStatus = alertsContext.allAlerts[alertName]; + const name = inSetupMode ? ( + {alertStatus.rawAlert.name} + ) : ( + + {alertStatus.rawAlert.name} ({filteredStates.length}) + + ); + return { + name, + panel: ++primaryPanelIndex, + }; + }), + }); + nodeIndex++; + } + + let secondaryPanelIndex = menu.length; + let tertiaryPanelIndex = menu.reduce((count, category) => { + count += category.alerts.length; + return count; + }, menu.length); + for (const category of menu) { + for (const { alert, states } of category.alerts) { + const items = []; + for (const alertState of states.filter(({ state }) => stateFilter(state))) { + items.push({ + name: ( + + + + {getDateFromNow( + alertState.state.ui.triggeredMS, + Legacy.shims.uiSettings.get('dateFormat:tz') + )} + + + {alertState.state.nodeName} + + ), + panel: ++tertiaryPanelIndex, + }); + items.push({ + isSeparator: true as const, + }); + } + + panels.push({ + id: ++secondaryPanelIndex, + title: `${alert.name}`, + items, + }); + } + } + + let tertiaryPanelIndex2 = menu.reduce((count, category) => { + count += category.alerts.length; + return count; + }, menu.length); + for (const category of menu) { + for (const { alert, states } of category.alerts) { + for (const state of states.filter(({ state: _state }) => stateFilter(_state))) { + panels.push({ + id: ++tertiaryPanelIndex2, + title: `${alert.name}`, + width: 400, + content: , + }); + } + } + } + } + + return panels; +} diff --git a/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.test.tsx b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.test.tsx new file mode 100644 index 0000000000000..be6ccb1e0981b --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ALERT_CPU_USAGE, + ALERT_LOGSTASH_VERSION_MISMATCH, + ALERT_THREAD_POOL_WRITE_REJECTIONS, +} from '../../../common/constants'; +import { AlertSeverity } from '../../../common/enums'; +import { getAlertPanelsByNode } from './get_alert_panels_by_node'; +import { + ALERT_LICENSE_EXPIRATION, + ALERT_NODES_CHANGED, + ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, +} from '../../../common/constants'; +import { AlertExecutionStatusValues } from '../../../../alerts/common'; +import { AlertState } from '../../../common/types/alerts'; + +jest.mock('../../legacy_shims', () => ({ + Legacy: { + shims: { + uiSettings: { + get: () => '', + }, + }, + }, +})); + +jest.mock('../../../common/formatting', () => ({ + getDateFromNow: (timestamp: number) => `triggered:${timestamp}`, + getCalendar: (timestamp: number) => `triggered:${timestamp}`, +})); + +const mockAlert = { + id: '', + enabled: true, + tags: [], + consumer: '', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date('2020-12-08'), + updatedAt: new Date('2020-12-08'), + apiKey: null, + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: AlertExecutionStatusValues[0], + lastExecutionDate: new Date('2020-12-08'), + }, + notifyWhen: null, +}; + +describe('getAlertPanelsByNode', () => { + const ui = { + isFiring: false, + severity: AlertSeverity.Danger, + message: { text: '' }, + resolvedMS: 0, + lastCheckedMS: 0, + triggeredMS: 0, + }; + + const cluster = { clusterUuid: '1', clusterName: 'one' }; + + function getAlert(type: string, firingCount: number) { + const states = []; + + for (let fi = 0; fi < firingCount; fi++) { + states.push({ + firing: true, + meta: {}, + state: { + cluster, + ui, + nodeId: `es${fi}`, + nodeName: `es_name_${fi}`, + }, + }); + } + + return { + rawAlert: { + alertTypeId: type, + name: `${type}_label`, + ...mockAlert, + }, + states, + }; + } + + const panelTitle = 'Alerts'; + const stateFilter = (state: AlertState) => true; + + it('should properly group for alerts in each category', () => { + const alerts = [ + getAlert(ALERT_NODES_CHANGED, 2), + getAlert(ALERT_DISK_USAGE, 1), + getAlert(ALERT_LICENSE_EXPIRATION, 2), + ]; + const result = getAlertPanelsByNode(panelTitle, alerts, stateFilter); + expect(result).toMatchSnapshot(); + }); + + it('should properly group for alerts in a single category', () => { + const alerts = [getAlert(ALERT_MEMORY_USAGE, 2)]; + const result = getAlertPanelsByNode(panelTitle, alerts, stateFilter); + expect(result).toMatchSnapshot(); + }); + + it('should not show any alert if none are firing', () => { + const alerts = [ + getAlert(ALERT_LOGSTASH_VERSION_MISMATCH, 0), + getAlert(ALERT_CPU_USAGE, 0), + getAlert(ALERT_THREAD_POOL_WRITE_REJECTIONS, 0), + ]; + const result = getAlertPanelsByNode(panelTitle, alerts, stateFilter); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.tsx b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.tsx new file mode 100644 index 0000000000000..c48706f4edcb9 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/get_alert_panels_by_node.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiText, EuiToolTip } from '@elastic/eui'; +import { AlertPanel } from '../panel'; +import { + CommonAlertStatus, + CommonAlertState, + CommonAlert, + AlertState, +} from '../../../common/types/alerts'; +import { getDateFromNow, getCalendar } from '../../../common/formatting'; +import { PanelItem } from '../types'; +import { sortByNewestAlert } from './sort_by_newest_alert'; +import { Legacy } from '../../legacy_shims'; + +export function getAlertPanelsByNode( + panelTitle: string, + alerts: CommonAlertStatus[], + stateFilter: (state: AlertState) => boolean +) { + const alertsByNodes: { + [uuid: string]: { + [alertName: string]: { + alert: CommonAlert; + states: CommonAlertState[]; + count: number; + }; + }; + } = {}; + const statesByNodes: { + [uuid: string]: CommonAlertState[]; + } = {}; + + for (const { states, rawAlert } of alerts) { + const { alertTypeId } = rawAlert; + for (const alertState of states.filter(({ state: _state }) => stateFilter(_state))) { + const { state } = alertState; + statesByNodes[state.nodeId] = statesByNodes[state.nodeId] || []; + statesByNodes[state.nodeId].push(alertState); + + alertsByNodes[state.nodeId] = alertsByNodes[state.nodeId] || {}; + alertsByNodes[state.nodeId][alertTypeId] = alertsByNodes[alertState.state.nodeId][ + alertTypeId + ] || { alert: rawAlert, states: [], count: 0 }; + alertsByNodes[state.nodeId][alertTypeId].count++; + alertsByNodes[state.nodeId][alertTypeId].states.push(alertState); + } + } + + for (const types of Object.values(alertsByNodes)) { + for (const { states } of Object.values(types)) { + states.sort(sortByNewestAlert); + } + } + + const nodeCount = Object.keys(statesByNodes).length; + let secondaryPanelIndex = nodeCount; + let tertiaryPanelIndex = nodeCount; + const panels: PanelItem[] = [ + { + id: 0, + title: panelTitle, + items: [ + ...Object.keys(statesByNodes).map((nodeUuid, index) => { + const states = (statesByNodes[nodeUuid] as CommonAlertState[]).filter(({ state }) => + stateFilter(state) + ); + return { + name: ( + + {states[0].state.nodeName} ({states.length}) + + ), + panel: index + 1, + }; + }), + ], + }, + ...Object.keys(statesByNodes).reduce((accum: PanelItem[], nodeUuid, nodeIndex) => { + const alertsForNode = Object.values(alertsByNodes[nodeUuid]); + const panelItems = []; + let title = ''; + for (const { alert, states } of alertsForNode) { + for (const alertState of states) { + title = alertState.state.nodeName; + panelItems.push({ + name: ( + + + + {getDateFromNow( + alertState.state.ui.triggeredMS, + Legacy.shims.uiSettings.get('dateFormat:tz') + )} + + + {alert.name} + + ), + panel: ++secondaryPanelIndex, + }); + } + } + accum.push({ + id: nodeIndex + 1, + title, + items: panelItems, + }); + return accum; + }, []), + ...Object.keys(statesByNodes).reduce((accum: PanelItem[], nodeUuid, nodeIndex) => { + const alertsForNode = Object.values(alertsByNodes[nodeUuid]); + for (const { alert, states } of alertsForNode) { + for (const alertState of states) { + accum.push({ + id: ++tertiaryPanelIndex, + title: alert.name, + width: 400, + content: , + }); + } + } + return accum; + }, []), + ]; + + return panels; +} diff --git a/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.test.ts b/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.test.ts new file mode 100644 index 0000000000000..29981c3ed32fe --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertSeverity } from '../../../common/enums'; +import { sortByNewestAlert } from './sort_by_newest_alert'; + +describe('sortByNewestAlert', () => { + const ui = { + isFiring: false, + severity: AlertSeverity.Danger, + message: { text: '' }, + resolvedMS: 0, + lastCheckedMS: 0, + triggeredMS: 0, + }; + + const cluster = { clusterUuid: '1', clusterName: 'one' }; + + it('should sort properly', () => { + const a = { + firing: false, + meta: {}, + state: { + cluster, + ui: { + ...ui, + triggeredMS: 2, + }, + nodeId: `es1`, + nodeName: `es_name_1`, + }, + }; + const b = { + firing: false, + meta: {}, + state: { + cluster, + ui: { + ...ui, + triggeredMS: 1, + }, + nodeId: `es1`, + nodeName: `es_name_1`, + }, + }; + expect(sortByNewestAlert(a, b)).toBe(-1); + }); +}); diff --git a/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.ts b/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.ts new file mode 100644 index 0000000000000..f5a20e0bae37e --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/sort_by_newest_alert.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CommonAlertState } from '../../../common/types/alerts'; + +export function sortByNewestAlert(a: CommonAlertState, b: CommonAlertState) { + if (a.state.ui.triggeredMS === b.state.ui.triggeredMS) { + return 0; + } + return a.state.ui.triggeredMS < b.state.ui.triggeredMS ? 1 : -1; +} diff --git a/x-pack/plugins/monitoring/public/alerts/panel.tsx b/x-pack/plugins/monitoring/public/alerts/panel.tsx index b480e46215108..139010a3d2446 100644 --- a/x-pack/plugins/monitoring/public/alerts/panel.tsx +++ b/x-pack/plugins/monitoring/public/alerts/panel.tsx @@ -3,186 +3,39 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment } from 'react'; import { EuiSpacer, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiSwitch, EuiTitle, EuiHorizontalRule, EuiListGroup, EuiListGroupItem, } from '@elastic/eui'; -import { CommonAlertStatus, CommonAlertState, AlertMessage } from '../../common/types/alerts'; -import { Legacy } from '../legacy_shims'; +import { CommonAlert, CommonAlertState, AlertMessage } from '../../common/types/alerts'; import { replaceTokens } from './lib/replace_tokens'; -import { isInSetupMode, hideBottomBar, showBottomBar } from '../lib/setup_mode'; -import { BASE_ALERT_API_PATH } from '../../../alerts/common'; +import { isInSetupMode } from '../lib/setup_mode'; import { SetupModeContext } from '../components/setup_mode/setup_mode_context'; +import { AlertConfiguration } from './configuration'; interface Props { - alert: CommonAlertStatus; + alert: CommonAlert; alertState?: CommonAlertState; } export const AlertPanel: React.FC = (props: Props) => { - const { - alert: { rawAlert }, - alertState, - } = props; - - const [showFlyout, setShowFlyout] = React.useState(false); - const [isEnabled, setIsEnabled] = React.useState(rawAlert?.enabled); - const [isMuted, setIsMuted] = React.useState(rawAlert?.muteAll); - const [isSaving, setIsSaving] = React.useState(false); + const { alert, alertState } = props; const inSetupMode = isInSetupMode(React.useContext(SetupModeContext)); - const flyoutUi = useMemo( - () => - showFlyout && - Legacy.shims.triggersActionsUi.getEditAlertFlyout({ - initialAlert: rawAlert, - onClose: () => { - setShowFlyout(false); - showBottomBar(); - }, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [showFlyout] - ); - - if (!rawAlert) { + if (!alert) { return null; } - async function disableAlert() { - setIsSaving(true); - try { - await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${rawAlert.id}/_disable`); - } catch (err) { - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.alerts.panel.disableAlert.errorTitle', { - defaultMessage: `Unable to disable alert`, - }), - text: err.message, - }); - } - setIsSaving(false); - } - async function enableAlert() { - setIsSaving(true); - try { - await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${rawAlert.id}/_enable`); - } catch (err) { - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.alerts.panel.enableAlert.errorTitle', { - defaultMessage: `Unable to enable alert`, - }), - text: err.message, - }); - } - setIsSaving(false); - } - async function muteAlert() { - setIsSaving(true); - try { - await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${rawAlert.id}/_mute_all`); - } catch (err) { - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.alerts.panel.muteAlert.errorTitle', { - defaultMessage: `Unable to mute alert`, - }), - text: err.message, - }); - } - setIsSaving(false); - } - async function unmuteAlert() { - setIsSaving(true); - try { - await Legacy.shims.http.post(`${BASE_ALERT_API_PATH}/alert/${rawAlert.id}/_unmute_all`); - } catch (err) { - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.alerts.panel.ummuteAlert.errorTitle', { - defaultMessage: `Unable to unmute alert`, - }), - text: err.message, - }); - } - setIsSaving(false); - } - - const configurationUi = ( - - - - { - setShowFlyout(true); - hideBottomBar(); - }} - > - {i18n.translate('xpack.monitoring.alerts.panel.editAlert', { - defaultMessage: `Edit alert`, - })} - - - - { - if (isEnabled) { - setIsEnabled(false); - await disableAlert(); - } else { - setIsEnabled(true); - await enableAlert(); - } - }} - label={ - - } - /> - - - { - if (isMuted) { - setIsMuted(false); - await unmuteAlert(); - } else { - setIsMuted(true); - await muteAlert(); - } - }} - label={ - - } - /> - - - {flyoutUi} - - ); - if (inSetupMode || !alertState) { - return
{configurationUi}
; + return ( +
+ +
+ ); } const nextStepsUi = @@ -204,7 +57,9 @@ export const AlertPanel: React.FC = (props: Props) => { {nextStepsUi}
-
{configurationUi}
+
+ +
); }; diff --git a/x-pack/plugins/monitoring/public/alerts/types.ts b/x-pack/plugins/monitoring/public/alerts/types.ts new file mode 100644 index 0000000000000..80918b5d8767a --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { CommonAlertStatus } from '../../common/types/alerts'; + +export interface AlertsByName { + [name: string]: CommonAlertStatus; +} + +export interface PanelItem { + id: number; + title: string; + width?: number; + content?: React.ReactElement; + items?: Array; +} + +export interface ContextMenuItem { + name: React.ReactElement; + panel?: number; + onClick?: () => void; +} + +export interface ContextMenuItemSeparator { + isSeparator: true; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/advanced.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/advanced.js index 6b72f95a1dbfc..78b5a6f58e238 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node/advanced.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/advanced.js @@ -20,7 +20,7 @@ import { MonitoringTimeseriesContainer } from '../../chart'; import { FormattedMessage } from '@kbn/i18n/react'; import { AlertsCallout } from '../../../alerts/callout'; -export const AdvancedNode = ({ nodeSummary, metrics, alerts, nodeId, ...props }) => { +export const AdvancedNode = ({ nodeSummary, metrics, alerts, ...props }) => { const metricsToShow = [ metrics.node_gc, metrics.node_gc_time, @@ -51,14 +51,10 @@ export const AdvancedNode = ({ nodeSummary, metrics, alerts, nodeId, ...props }) - state.nodeId === nodeId} - /> + - state.nodeId === nodeId} /> + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js index ac1a5212a8d26..f91e251030d76 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js @@ -70,14 +70,10 @@ export const Node = ({ - state.nodeId === nodeId} - /> + - state.nodeId === nodeId} /> + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js index 77d0b294f66d0..85b4d0daddade 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js @@ -11,7 +11,7 @@ import { formatMetric } from '../../../lib/format_number'; import { i18n } from '@kbn/i18n'; import { AlertsStatus } from '../../../alerts/status'; -export function NodeDetailStatus({ stats, alerts = {}, alertsStateFilter = () => true }) { +export function NodeDetailStatus({ stats, alerts = {} }) { const { transport_address: transportAddress, usedHeap, @@ -33,7 +33,7 @@ export function NodeDetailStatus({ stats, alerts = {}, alertsStateFilter = () => label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.alerts', { defaultMessage: 'Alerts', }), - value: , + value: , }, { label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.transportAddress', { diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js index 84e7e5f8b1547..364886516bbd7 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js @@ -130,7 +130,6 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode, clusterUuid, aler defaultMessage: 'Alerts', }), field: 'alerts', - // width: '175px', sortable: true, render: (_field, node) => { return ( diff --git a/x-pack/plugins/monitoring/public/views/base_controller.js b/x-pack/plugins/monitoring/public/views/base_controller.js index 18c3a59d6b9da..f9c6cfb6024da 100644 --- a/x-pack/plugins/monitoring/public/views/base_controller.js +++ b/x-pack/plugins/monitoring/public/views/base_controller.js @@ -13,6 +13,7 @@ import { Legacy } from '../legacy_shims'; import { PromiseWithCancel } from '../../common/cancel_promise'; import { SetupModeFeature } from '../../common/enums'; import { updateSetupModeData, isSetupModeFeatureEnabled } from '../lib/setup_mode'; +import { AlertsContext } from '../alerts/context'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; /** @@ -243,11 +244,13 @@ export class MonitoringViewBaseController { const wrappedComponent = ( - {!this._isDataInitialized ? ( - - ) : ( - component - )} + + {!this._isDataInitialized ? ( + + ) : ( + component + )} + ); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js index 586261eecb250..2f0aa67e4e16b 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js @@ -14,6 +14,7 @@ import { uiRoutes } from '../../../angular/helpers/routes'; import { routeInitProvider } from '../../../lib/route_init'; import { getPageData } from './get_page_data'; import template from './index.html'; +import { SetupModeRenderer } from '../../../components/renderers'; import { Node } from '../../../components/elasticsearch/node/node'; import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; import { nodesByIndices } from '../../../components/elasticsearch/shard_allocation/transformers/nodes_by_indices'; @@ -26,7 +27,9 @@ import { ALERT_MISSING_MONITORING_DATA, ALERT_DISK_USAGE, ALERT_MEMORY_USAGE, + ELASTICSEARCH_SYSTEM_ID, } from '../../../../common/constants'; +import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; uiRoutes.when('/elasticsearch/nodes/:node', { template, @@ -122,14 +125,26 @@ uiRoutes.when('/elasticsearch/nodes/:node', { $scope.labels = labels.node; this.renderReact( - ( + + {flyoutComponent} + + {bottomBarComponent} + + )} /> ); } diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js index 3ec9c6235867b..834134f80ceeb 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js @@ -100,11 +100,6 @@ uiRoutes.when('/elasticsearch/nodes', { ALERT_MEMORY_USAGE, ALERT_MISSING_MONITORING_DATA, ], - filters: [ - { - stackProduct: ELASTICSEARCH_SYSTEM_ID, - }, - ], }, }, }); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts index d23d6c8b32f14..8cba1537965f4 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts @@ -64,6 +64,7 @@ describe('BaseAlert', () => { }, tags: [], throttle: '1d', + notifyWhen: null, }, }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index b345689bdc504..4f989b37421ef 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -42,6 +42,7 @@ import { mapLegacySeverity } from '../lib/alerts/map_legacy_severity'; interface LegacyOptions { watchName: string; + nodeNameLabel: string; changeDataValues?: Partial; } @@ -177,6 +178,7 @@ export class BaseAlert { name, alertTypeId, throttle, + notifyWhen: null, schedule: { interval }, actions: alertActions, }, @@ -199,11 +201,12 @@ export class BaseAlert { return accum; } const alertInstance: RawAlertInstance = states.alertInstances[instanceId]; - if (alertInstance && this.filterAlertInstance(alertInstance, filters)) { - accum[instanceId] = alertInstance; - if (alertInstance.state) { + const filteredAlertInstance = this.filterAlertInstance(alertInstance, filters); + if (filteredAlertInstance) { + accum[instanceId] = filteredAlertInstance as RawAlertInstance; + if (filteredAlertInstance.state) { accum[instanceId].state = { - alertStates: (alertInstance.state as AlertInstanceState).alertStates, + alertStates: (filteredAlertInstance.state as AlertInstanceState).alertStates, }; } } @@ -219,15 +222,15 @@ export class BaseAlert { filterOnNodes: boolean = false ) { if (!filterOnNodes) { - return true; + return alertInstance; } const alertInstanceStates = alertInstance.state?.alertStates as AlertNodeState[]; const nodeFilter = filters?.find((filter) => filter.nodeUuid); if (!filters || !filters.length || !alertInstanceStates?.length || !nodeFilter?.nodeUuid) { - return true; + return alertInstance; } - const nodeAlerts = alertInstanceStates.filter(({ nodeId }) => nodeId === nodeFilter.nodeUuid); - return Boolean(nodeAlerts.length); + const alertStates = alertInstanceStates.filter(({ nodeId }) => nodeId === nodeFilter.nodeUuid); + return { state: { alertStates } }; } protected async execute({ @@ -320,6 +323,7 @@ export class BaseAlert { shouldFire: !legacyAlert.resolved_timestamp, severity: mapLegacySeverity(legacyAlert.metadata.severity), meta: legacyAlert, + nodeName: this.alertOptions.legacy!.nodeNameLabel, ...this.alertOptions.legacy!.changeDataValues, }; }); @@ -392,6 +396,7 @@ export class BaseAlert { } const cluster = clusters.find((c: AlertCluster) => c.clusterUuid === item.clusterUuid); const alertState: AlertState = this.getDefaultAlertState(cluster!, item); + alertState.nodeName = item.nodeName; alertState.ui.triggeredMS = currentUTC; alertState.ui.isFiring = true; alertState.ui.severity = item.severity; diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts index a4e9f56109698..fbf81bc3513f6 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts @@ -119,6 +119,7 @@ describe('ClusterHealthAlert', () => { { cluster: { clusterUuid: 'abc123', clusterName: 'testCluster' }, ccs: undefined, + nodeName: 'Elasticsearch cluster alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts index 3b375654548d8..d7c33ae85cc9d 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts @@ -38,6 +38,9 @@ export class ClusterHealthAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_CLUSTER_HEALTH].label, legacy: { watchName: 'elasticsearch_cluster_status', + nodeNameLabel: i18n.translate('xpack.monitoring.alerts.clusterHealth.nodeNameLabel', { + defaultMessage: 'Elasticsearch cluster alert', + }), }, actionVariables: [ { diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts index 7bdef1ee2c2c4..c587ca52f26ab 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import numeral from '@elastic/numeral'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -16,8 +17,8 @@ import { AlertMessageTimeToken, AlertMessageLinkToken, AlertInstanceState, - CommonAlertFilter, CommonAlertParams, + CommonAlertFilter, } from '../../common/types/alerts'; import { AlertInstance } from '../../../alerts/server'; import { @@ -25,6 +26,8 @@ import { ALERT_CPU_USAGE, ALERT_DETAILS, } from '../../common/constants'; +// @ts-ignore +import { ROUNDED_FLOAT } from '../../common/formatting'; import { fetchCpuUsageNodeStats } from '../lib/alerts/fetch_cpu_usage_node_stats'; import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; import { AlertMessageTokenType, AlertSeverity } from '../../common/enums'; @@ -121,7 +124,7 @@ export class CpuUsageAlert extends BaseAlert { defaultMessage: `Node #start_link{nodeName}#end_link is reporting cpu usage of {cpuUsage}% at #absolute`, values: { nodeName: stat.nodeName, - cpuUsage: stat.cpuUsage, + cpuUsage: numeral(stat.cpuUsage).format(ROUNDED_FLOAT), }, }), nextSteps: [ diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts index 133fe261d0791..4a736f27320eb 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import numeral from '@elastic/numeral'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -15,8 +16,9 @@ import { AlertMessageTimeToken, AlertMessageLinkToken, AlertInstanceState, - CommonAlertFilter, CommonAlertParams, + AlertDiskUsageNodeStats, + CommonAlertFilter, } from '../../common/types/alerts'; import { AlertInstance } from '../../../alerts/server'; import { @@ -24,6 +26,8 @@ import { ALERT_DISK_USAGE, ALERT_DETAILS, } from '../../common/constants'; +// @ts-ignore +import { ROUNDED_FLOAT } from '../../common/formatting'; import { fetchDiskUsageNodeStats } from '../lib/alerts/fetch_disk_usage_node_stats'; import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; import { AlertMessageTokenType, AlertSeverity } from '../../common/enums'; @@ -102,13 +106,13 @@ export class DiskUsageAlert extends BaseAlert { } protected getUiMessage(alertState: AlertState, item: AlertData): AlertMessage { - const stat = item.meta as AlertDiskUsageState; + const stat = item.meta as AlertDiskUsageNodeStats; return { text: i18n.translate('xpack.monitoring.alerts.diskUsage.ui.firingMessage', { defaultMessage: `Node #start_link{nodeName}#end_link is reporting disk usage of {diskUsage}% at #absolute`, values: { nodeName: stat.nodeName, - diskUsage: stat.diskUsage, + diskUsage: numeral(stat.diskUsage).format(ROUNDED_FLOAT), }, }), nextSteps: [ diff --git a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts index 46fdd1fa98563..ed39cbea3381c 100644 --- a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts @@ -124,6 +124,7 @@ describe('ElasticsearchVersionMismatchAlert', () => { { cluster: { clusterUuid: 'abc123', clusterName: 'testCluster' }, ccs: undefined, + nodeName: 'Elasticsearch node alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts index 88b5b708d41f3..9002fb54fcf23 100644 --- a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts @@ -26,6 +26,12 @@ export class ElasticsearchVersionMismatchAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_ELASTICSEARCH_VERSION_MISMATCH].label, legacy: { watchName: 'elasticsearch_version_mismatch', + nodeNameLabel: i18n.translate( + 'xpack.monitoring.alerts.elasticsearchVersionMismatch.nodeNameLabel', + { + defaultMessage: 'Elasticsearch node alert', + } + ), changeDataValues: { severity: AlertSeverity.Warning }, }, interval: '1d', diff --git a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts index 2367b53330ec5..96464e16f8c72 100644 --- a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts @@ -126,6 +126,7 @@ describe('KibanaVersionMismatchAlert', () => { { cluster: { clusterUuid: 'abc123', clusterName: 'testCluster' }, ccs: undefined, + nodeName: 'Kibana instance alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts index c9e5786484899..0d394b8f45ecc 100644 --- a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts @@ -26,6 +26,12 @@ export class KibanaVersionMismatchAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_KIBANA_VERSION_MISMATCH].label, legacy: { watchName: 'kibana_version_mismatch', + nodeNameLabel: i18n.translate( + 'xpack.monitoring.alerts.kibanaVersionMismatch.nodeNameLabel', + { + defaultMessage: 'Kibana instance alert', + } + ), changeDataValues: { severity: AlertSeverity.Warning }, }, interval: '1d', diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts index f7a3d321b960b..c64b6e4b92984 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts @@ -130,6 +130,7 @@ describe('LicenseExpirationAlert', () => { { cluster: { clusterUuid, clusterName }, ccs: undefined, + nodeName: 'Elasticsearch cluster alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts index 80479023a3a60..9dacba1dfe0ec 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts @@ -34,6 +34,9 @@ export class LicenseExpirationAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_LICENSE_EXPIRATION].label, legacy: { watchName: 'xpack_license_expiration', + nodeNameLabel: i18n.translate('xpack.monitoring.alerts.licenseExpiration.nodeNameLabel', { + defaultMessage: 'Elasticsearch cluster alert', + }), }, interval: '1d', actionVariables: [ diff --git a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts index a021a0e6fe179..dd23cfc76dc6d 100644 --- a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts @@ -125,6 +125,7 @@ describe('LogstashVersionMismatchAlert', () => { { cluster: { clusterUuid: 'abc123', clusterName: 'testCluster' }, ccs: undefined, + nodeName: 'Logstash node alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts index 98640fb6e183a..4eae3cd12eed4 100644 --- a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts @@ -26,6 +26,12 @@ export class LogstashVersionMismatchAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_LOGSTASH_VERSION_MISMATCH].label, legacy: { watchName: 'logstash_version_mismatch', + nodeNameLabel: i18n.translate( + 'xpack.monitoring.alerts.logstashVersionMismatch.nodeNameLabel', + { + defaultMessage: 'Logstash node alert', + } + ), changeDataValues: { severity: AlertSeverity.Warning }, }, interval: '1d', diff --git a/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts index 860cd41f9057d..d5ea291aa52ed 100644 --- a/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import numeral from '@elastic/numeral'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -15,8 +16,9 @@ import { AlertMessageTimeToken, AlertMessageLinkToken, AlertInstanceState, - CommonAlertFilter, CommonAlertParams, + AlertMemoryUsageNodeStats, + CommonAlertFilter, } from '../../common/types/alerts'; import { AlertInstance } from '../../../alerts/server'; import { @@ -24,6 +26,8 @@ import { ALERT_MEMORY_USAGE, ALERT_DETAILS, } from '../../common/constants'; +// @ts-ignore +import { ROUNDED_FLOAT } from '../../common/formatting'; import { fetchMemoryUsageNodeStats } from '../lib/alerts/fetch_memory_usage_node_stats'; import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; import { AlertMessageTokenType, AlertSeverity } from '../../common/enums'; @@ -108,13 +112,13 @@ export class MemoryUsageAlert extends BaseAlert { } protected getUiMessage(alertState: AlertState, item: AlertData): AlertMessage { - const stat = item.meta as AlertMemoryUsageState; + const stat = item.meta as AlertMemoryUsageNodeStats; return { text: i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.firingMessage', { defaultMessage: `Node #start_link{nodeName}#end_link is reporting JVM memory usage of {memoryUsage}% at #absolute`, values: { nodeName: stat.nodeName, - memoryUsage: stat.memoryUsage, + memoryUsage: numeral(stat.memoryUsage).format(ROUNDED_FLOAT), }, }), nextSteps: [ diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts index 12bb27ce132d0..6ba4333309f00 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts @@ -128,9 +128,9 @@ describe('MissingMonitoringDataAlert', () => { { ccs: undefined, cluster: { clusterUuid, clusterName }, - gapDuration, - nodeName, nodeId, + nodeName, + gapDuration, ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts index 1c93ff4a28719..b4c8a667a1ce8 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts @@ -12,10 +12,9 @@ import { AlertCluster, AlertState, AlertMessage, - AlertNodeState, AlertMessageTimeToken, - CommonAlertFilter, CommonAlertParams, + CommonAlertFilter, } from '../../common/types/alerts'; import { AlertInstance } from '../../../alerts/server'; import { @@ -40,12 +39,12 @@ export class MissingMonitoringDataAlert extends BaseAlert { super(rawAlert, { id: ALERT_MISSING_MONITORING_DATA, name: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].label, + accessorKey: 'gapDuration', defaultParams: { duration: '15m', limit: '1d', }, throttle: '6h', - accessorKey: 'gapDuration', actionVariables: [ { name: 'nodes', @@ -153,7 +152,7 @@ export class MissingMonitoringDataAlert extends BaseAlert { protected executeActions( instance: AlertInstance, - { alertStates }: { alertStates: AlertNodeState[] }, + { alertStates }: { alertStates: AlertState[] }, item: AlertData | null, cluster: AlertCluster ) { diff --git a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts index 99be91dc293cb..e6017e799cba8 100644 --- a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts @@ -137,6 +137,7 @@ describe('NodesChangedAlert', () => { { cluster: { clusterUuid, clusterName }, ccs: undefined, + nodeName: 'Elasticsearch nodes alert', ui: { isFiring: true, message: { diff --git a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts index 47d5c5ac2c241..09c12d345d930 100644 --- a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts @@ -26,6 +26,9 @@ export class NodesChangedAlert extends BaseAlert { name: LEGACY_ALERT_DETAILS[ALERT_NODES_CHANGED].label, legacy: { watchName: 'elasticsearch_nodes', + nodeNameLabel: i18n.translate('xpack.monitoring.alerts.nodesChanged.nodeNameLabel', { + defaultMessage: 'Elasticsearch nodes alert', + }), changeDataValues: { shouldFire: true }, }, actionVariables: [ diff --git a/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts b/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts index 2d8ccabaac853..1e539c52eeedc 100644 --- a/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts +++ b/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts @@ -13,8 +13,8 @@ import { AlertThreadPoolRejectionsState, AlertMessageTimeToken, AlertMessageLinkToken, - CommonAlertFilter, ThreadPoolRejectionsAlertParams, + CommonAlertFilter, } from '../../common/types/alerts'; import { AlertInstance } from '../../../alerts/server'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.test.ts index a3743a8ff206f..5055017051816 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.test.ts @@ -47,6 +47,7 @@ describe('fetchLegacyAlerts', () => { message, metadata, nodes, + nodeName: '', prefix, }, ]); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.ts index fbf7608a737ba..0ea37b4ac4daa 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_legacy_alerts.ts @@ -86,6 +86,7 @@ export async function fetchLegacyAlerts( message: get(hit, '_source.message'), resolved_timestamp: get(hit, '_source.resolved_timestamp'), nodes: get(hit, '_source.nodes'), + nodeName: '', // This is set by BaseAlert metadata: get(hit, '_source.metadata') as LegacyAlertMetadata, }; return legacyAlert; diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts index 4ca708e9d2832..7d471d528595e 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts @@ -21,17 +21,23 @@ export function handleResponse(response: ElasticsearchResponse, apmUuid: string) return {}; } - const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; - const stats = response.hits.hits[0]._source.beats_stats; + const firstHit = response.hits.hits[0]; - if (!firstStats || !stats) { - return {}; + let firstStats = null; + const stats = firstHit._source.beats_stats ?? {}; + + if ( + firstHit.inner_hits?.first_hit?.hits?.hits && + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + ) { + firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; } - const eventsTotalFirst = firstStats.metrics?.libbeat?.pipeline?.events?.total; - const eventsEmittedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.published; - const eventsDroppedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.dropped; - const bytesWrittenFirst = firstStats.metrics?.libbeat?.output?.write?.bytes; + const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total; + const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published; + const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped; + const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes; const eventsTotalLast = stats.metrics?.libbeat?.pipeline?.events?.total; const eventsEmittedLast = stats.metrics?.libbeat?.pipeline?.events?.published; diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts index f6df94f8de138..7677677ea5e75 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts @@ -24,9 +24,13 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e return accum; } - const earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; - if (!earliestStats) { - return accum; + let earliestStats = null; + if ( + hit.inner_hits?.earliest?.hits?.hits && + hit.inner_hits?.earliest?.hits?.hits.length > 0 && + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats + ) { + earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; } const uuid = stats?.beat?.uuid; @@ -41,7 +45,7 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e // add the beat const rateOptions = { hitTimestamp: stats.timestamp, - earliestHitTimestamp: earliestStats.timestamp, + earliestHitTimestamp: earliestStats?.timestamp, timeWindowMin: start, timeWindowMax: end, }; @@ -54,14 +58,14 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e const { rate: totalEventsRate } = calculateRate({ latestTotal: stats.metrics?.libbeat?.pipeline?.events?.total, - earliestTotal: earliestStats.metrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); const errorsWrittenLatest = stats.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsWrittenEarliest = earliestStats.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; const errorsReadLatest = stats.metrics?.libbeat?.output?.read?.errors ?? 0; - const errorsReadEarliest = earliestStats.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts index 57325673a131a..80b5efda4047a 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts @@ -18,8 +18,18 @@ export function handleResponse(response: ElasticsearchResponse, beatUuid: string return {}; } - const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; - const stats = response.hits.hits[0]._source.beats_stats; + const firstHit = response.hits.hits[0]; + + let firstStats = null; + if ( + firstHit.inner_hits?.first_hit?.hits?.hits && + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + ) { + firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; + } + + const stats = firstHit._source.beats_stats ?? {}; const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total ?? null; const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published ?? null; diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts similarity index 64% rename from x-pack/plugins/monitoring/server/lib/beats/get_beats.js rename to x-pack/plugins/monitoring/server/lib/beats/get_beats.ts index af4b6c31a3e5e..beda4334b4937 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts @@ -5,18 +5,39 @@ */ import moment from 'moment'; -import { upperFirst, get } from 'lodash'; +import { upperFirst } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createBeatsQuery } from './create_beats_query'; +// @ts-ignore import { calculateRate } from '../calculate_rate'; +// @ts-ignore import { getDiffCalculation } from './_beats_stats'; +import { ElasticsearchResponse, LegacyRequest } from '../../types'; + +interface Beat { + uuid: string | undefined; + name: string | undefined; + type: string | undefined; + output: string | undefined; + total_events_rate: number; + bytes_sent_rate: number; + memory: number | undefined; + version: string | undefined; + errors: any; +} -export function handleResponse(response, start, end) { - const hits = get(response, 'hits.hits', []); - const initial = { ids: new Set(), beats: [] }; +export function handleResponse(response: ElasticsearchResponse, start: number, end: number) { + const hits = response.hits?.hits ?? []; + const initial: { ids: Set; beats: Beat[] } = { ids: new Set(), beats: [] }; const { beats } = hits.reduce((accum, hit) => { - const stats = get(hit, '_source.beats_stats'); - const uuid = get(stats, 'beat.uuid'); + const stats = hit._source.beats_stats; + const uuid = stats?.beat?.uuid; + + if (!uuid) { + return accum; + } // skip this duplicated beat, newer one was already added if (accum.ids.has(uuid)) { @@ -25,47 +46,55 @@ export function handleResponse(response, start, end) { // add another beat summary accum.ids.add(uuid); - const earliestStats = get(hit, 'inner_hits.earliest.hits.hits[0]._source.beats_stats'); + + let earliestStats = null; + if ( + hit.inner_hits?.earliest?.hits?.hits && + hit.inner_hits?.earliest?.hits?.hits.length > 0 && + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats + ) { + earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; + } // add the beat const rateOptions = { - hitTimestamp: get(stats, 'timestamp'), - earliestHitTimestamp: get(earliestStats, 'timestamp'), + hitTimestamp: stats?.timestamp, + earliestHitTimestamp: earliestStats?.timestamp, timeWindowMin: start, timeWindowMax: end, }; const { rate: bytesSentRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.output.write.bytes'), - earliestTotal: get(earliestStats, 'metrics.libbeat.output.write.bytes'), + latestTotal: stats?.metrics?.libbeat?.output?.write?.bytes, + earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes, ...rateOptions, }); const { rate: totalEventsRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.pipeline.events.total'), - earliestTotal: get(earliestStats, 'metrics.libbeat.pipeline.events.total'), + latestTotal: stats?.metrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); - const errorsWrittenLatest = get(stats, 'metrics.libbeat.output.write.errors'); - const errorsWrittenEarliest = get(earliestStats, 'metrics.libbeat.output.write.errors'); - const errorsReadLatest = get(stats, 'metrics.libbeat.output.read.errors'); - const errorsReadEarliest = get(earliestStats, 'metrics.libbeat.output.read.errors'); + const errorsWrittenLatest = stats?.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsReadLatest = stats?.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest ); accum.beats.push({ - uuid: get(stats, 'beat.uuid'), - name: get(stats, 'beat.name'), - type: upperFirst(get(stats, 'beat.type')), - output: upperFirst(get(stats, 'metrics.libbeat.output.type')), + uuid: stats?.beat?.uuid, + name: stats?.beat?.name, + type: upperFirst(stats?.beat?.type), + output: upperFirst(stats?.metrics?.libbeat?.output?.type), total_events_rate: totalEventsRate, bytes_sent_rate: bytesSentRate, errors, - memory: get(stats, 'metrics.beat.memstats.memory_alloc'), - version: get(stats, 'beat.version'), + memory: stats?.metrics?.beat?.memstats?.memory_alloc, + version: stats?.beat?.version, }); return accum; @@ -74,7 +103,7 @@ export function handleResponse(response, start, end) { return beats; } -export async function getBeats(req, beatsIndexPattern, clusterUuid) { +export async function getBeats(req: LegacyRequest, beatsIndexPattern: string, clusterUuid: string) { checkParam(beatsIndexPattern, 'beatsIndexPattern in getBeats'); const config = req.server.config(); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/__snapshots__/handle_response.test.js.snap b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/__snapshots__/handle_response.test.js.snap index db74cc5e330a1..ba8506bfd0087 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/__snapshots__/handle_response.test.js.snap +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/__snapshots__/handle_response.test.js.snap @@ -11,6 +11,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9300", "type": "node", + "uuid": "_x_V2YzPQU-a9KRRBxUxZQ", }, Object { "isOnline": false, @@ -21,6 +22,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9301", "type": "node", + "uuid": "DAiX7fFjS3Wii7g2HYKrOg", }, ] `; @@ -156,6 +158,7 @@ Array [ "shardCount": 0, "transport_address": "127.0.0.1:9300", "type": "master", + "uuid": "_x_V2YzPQU-a9KRRBxUxZQ", }, Object { "isOnline": true, @@ -265,6 +268,7 @@ Array [ "shardCount": 0, "transport_address": "127.0.0.1:9301", "type": "node", + "uuid": "DAiX7fFjS3Wii7g2HYKrOg", }, ] `; @@ -286,6 +290,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9300", "type": "master", + "uuid": "_x_V2YzPQU-a9KRRBxUxZQ", }, Object { "isOnline": true, @@ -302,6 +307,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9301", "type": "node", + "uuid": "DAiX7fFjS3Wii7g2HYKrOg", }, ] `; @@ -435,6 +441,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9300", "type": "master", + "uuid": "_x_V2YzPQU-a9KRRBxUxZQ", }, Object { "isOnline": true, @@ -544,6 +551,7 @@ Array [ "shardCount": 6, "transport_address": "127.0.0.1:9301", "type": "node", + "uuid": "DAiX7fFjS3Wii7g2HYKrOg", }, ] `; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js index 3766845d39b4f..ac4fcea6150a0 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js @@ -25,8 +25,9 @@ import { LISTING_METRICS_NAMES, LISTING_METRICS_PATHS } from './nodes_listing_me * * @param {Object} req: server request object * @param {String} esIndexPattern: index pattern for elasticsearch data in monitoring indices + * @param {Object} pageOfNodes: server-side paginated current page of ES nodes * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @return {Array} node info combined with metrics for each node from handle_response */ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, nodesShardCount) { diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js index 62cf138c99506..3f82e8ec3e646 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js @@ -47,6 +47,7 @@ export function handleResponse( // nodesInfo is the source of truth for the nodeIds, where nodesMetrics will lack metrics for offline nodes const nodes = pageOfNodes.map((node) => ({ + ...node, ...nodesInfo[node.uuid], ...nodesMetrics[node.uuid], resolver: node.uuid, diff --git a/x-pack/plugins/monitoring/server/lib/pagination/filter.js b/x-pack/plugins/monitoring/server/lib/pagination/filter.js index 7592f2bb3afde..e906081a8eb5a 100644 --- a/x-pack/plugins/monitoring/server/lib/pagination/filter.js +++ b/x-pack/plugins/monitoring/server/lib/pagination/filter.js @@ -15,7 +15,7 @@ function defaultFilterFn(value, query) { export function filter(data, queryText, fields, filterFn = defaultFilterFn) { return data.filter((item) => { for (const field of fields) { - if (filterFn(get(item, field), queryText)) { + if (filterFn(get(item, field, ''), queryText)) { return true; } } diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 73eea99467c59..84b331df8ba42 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -121,9 +121,9 @@ export interface ElasticsearchResponse { export interface ElasticsearchResponseHit { _source: ElasticsearchSource; - inner_hits: { + inner_hits?: { [field: string]: { - hits: { + hits?: { hits: ElasticsearchResponseHit[]; total: { value: number; diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index f7fe3f5694a4a..d0423b1f44306 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { AreaSeries, ScaleType, Settings } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -11,12 +10,9 @@ import React, { useContext } from 'react'; import styled, { ThemeContext } from 'styled-components'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; -import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { useHasData } from '../../../../hooks/use_has_data'; import { useTimeRange } from '../../../../hooks/use_time_range'; -import { Series } from '../../../../typings'; -import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { @@ -72,12 +68,10 @@ export function MetricsSection({ bucketSize }: Props) { const isLoading = status === FETCH_STATUS.LOADING; - const { appLink, stats, series } = data || {}; + const { appLink, stats } = data || {}; const cpuColor = theme.eui.euiColorVis7; const memoryColor = theme.eui.euiColorVis0; - const inboundTrafficColor = theme.eui.euiColorVis3; - const outboundTrafficColor = theme.eui.euiColorVis2; return ( - - - - - - - - - - ); } - -function AreaChart({ - serie, - isLoading, - color, -}: { - serie?: Series; - isLoading: boolean; - color: string; -}) { - const chartTheme = useChartTheme(); - - return ( - - - {serie && ( - - )} - - ); -} diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 3880dcdcde0be..d672525f1a937 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -14,6 +14,12 @@ export function useChartTheme() { return { ...baseChartTheme, + chartMargins: { + left: 10, + right: 10, + top: 10, + bottom: 10, + }, background: { ...baseChartTheme.background, color: 'transparent', diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts index d5a7992ceabd8..731509f6a0a0f 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts @@ -16,98 +16,6 @@ const response: MetricsFetchDataResponse = { hosts: { value: 11, type: 'number' }, cpu: { value: 0.8, type: 'percent' }, memory: { value: 0.362, type: 'percent' }, - inboundTraffic: { value: 1024, type: 'bytesPerSecond' }, - outboundTraffic: { value: 1024, type: 'bytesPerSecond' }, - }, - series: { - outboundTraffic: { - coordinates: [ - { - x: 1589805437549, - y: 331514, - }, - { - x: 1590047357549, - y: 319208, - }, - { - x: 1590289277549, - y: 309648, - }, - { - x: 1590531197549, - y: 280568, - }, - { - x: 1590773117549, - y: 337180, - }, - { - x: 1591015037549, - y: 122468, - }, - { - x: 1591256957549, - y: 184164, - }, - { - x: 1591498877549, - y: 316323, - }, - { - x: 1591740797549, - y: 307351, - }, - { - x: 1591982717549, - y: 290262, - }, - ], - }, - inboundTraffic: { - coordinates: [ - { - x: 1589805437549, - y: 331514, - }, - { - x: 1590047357549, - y: 319208, - }, - { - x: 1590289277549, - y: 309648, - }, - { - x: 1590531197549, - y: 280568, - }, - { - x: 1590773117549, - y: 337180, - }, - { - x: 1591015037549, - y: 122468, - }, - { - x: 1591256957549, - y: 184164, - }, - { - x: 1591498877549, - y: 316323, - }, - { - x: 1591740797549, - y: 307351, - }, - { - x: 1591982717549, - y: 290262, - }, - ], - }, }, }; @@ -117,15 +25,5 @@ export const emptyResponse: MetricsFetchDataResponse = { hosts: { value: 0, type: 'number' }, cpu: { value: 0, type: 'percent' }, memory: { value: 0, type: 'percent' }, - inboundTraffic: { value: 0, type: 'bytesPerSecond' }, - outboundTraffic: { value: 0, type: 'bytesPerSecond' }, - }, - series: { - outboundTraffic: { - coordinates: [], - }, - inboundTraffic: { - coordinates: [], - }, }, }; diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts index e3f8f877656bd..7ba603cc0c816 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts @@ -79,7 +79,7 @@ describe('getObservabilityAlerts', () => { { id: 2, consumer: 'apm' }, { id: 3, consumer: 'uptime' }, { id: 4, consumer: 'logs' }, - { id: 5, consumer: 'metrics' }, + { id: 5, consumer: 'infrastructure' }, { id: 6, consumer: 'alerts' }, ], }; @@ -104,7 +104,7 @@ describe('getObservabilityAlerts', () => { }, { id: 5, - consumer: 'metrics', + consumer: 'infrastructure', }, { id: 6, diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index b1f8f0fb1bddc..6ada42297868e 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -7,7 +7,7 @@ import { CoreStart } from 'kibana/public'; import { Alert } from '../../../alerts/common'; -const allowedConsumers = ['apm', 'uptime', 'logs', 'metrics', 'alerts']; +const allowedConsumers = ['apm', 'uptime', 'logs', 'infrastructure', 'alerts']; export async function getObservabilityAlerts({ core }: { core: CoreStart }) { try { diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 4cac1d586f295..807b0a684c7e4 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -71,12 +71,6 @@ export interface MetricsFetchDataResponse extends FetchDataResponse { hosts: Stat; cpu: Stat; memory: Stat; - inboundTraffic: Stat; - outboundTraffic: Stat; - }; - series: { - inboundTraffic: Series; - outboundTraffic: Series; }; } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 39f597acec6ec..6d03e39f45f8f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -80,7 +80,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { let buffer: Buffer | null = null; try { tracker.startCompile(); - logger.debug(`Compiling PDF...`); + logger.debug(`Compiling PDF using "${layout.id}" layout...`); pdfOutput.generate(); tracker.endCompile(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts index 5a3671835ce51..746207b5366ff 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts @@ -6,10 +6,12 @@ import { BufferOptions } from 'pdfmake/interfaces'; +export const REPORTING_TABLE_LAYOUT = 'noBorder'; + export function getDocOptions(tableBorderWidth: number): BufferOptions { return { tableLayouts: { - noBorder: { + [REPORTING_TABLE_LAYOUT]: { // format is function (i, node) { ... }; hLineWidth: () => 0, vLineWidth: () => 0, @@ -18,17 +20,6 @@ export function getDocOptions(tableBorderWidth: number): BufferOptions { paddingTop: () => 0, paddingBottom: () => 0, }, - simpleBorder: { - // format is function (i, node) { ... }; - hLineWidth: () => tableBorderWidth, - vLineWidth: () => tableBorderWidth, - hLineColor: () => 'silver', - vLineColor: () => 'silver', - paddingLeft: () => 0, - paddingRight: () => 0, - paddingTop: () => 0, - paddingBottom: () => 0, - }, }, }; } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts index 131d289576384..c935e3cad854f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts @@ -6,8 +6,14 @@ import { i18n } from '@kbn/i18n'; import path from 'path'; -import { TDocumentDefinitions } from 'pdfmake/interfaces'; +import { + ContentText, + DynamicContent, + StyleDictionary, + TDocumentDefinitions, +} from 'pdfmake/interfaces'; import { LayoutInstance } from '../../../../lib/layouts'; +import { REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; export function getTemplate( @@ -29,6 +35,79 @@ export function getTemplate( const subheadingMarginBottom = 5; const subheadingHeight = subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom; + const getStyle = (): StyleDictionary => ({ + heading: { + alignment: 'left', + fontSize: headingFontSize, + bold: true, + margin: [headingMarginTop, 0, headingMarginBottom, 0], + }, + subheading: { + alignment: 'left', + fontSize: subheadingFontSize, + italics: true, + margin: [0, 0, subheadingMarginBottom, 20], + }, + warning: { + color: '#f39c12', // same as @brand-warning in Kibana colors.less + }, + }); + const getHeader = (): ContentText => ({ + margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0], + text: title, + font: getFont(title), + style: { + color: '#aaa', + }, + fontSize: 10, + alignment: 'center', + }); + const getFooter = (): DynamicContent => (currentPage: number, pageCount: number) => { + const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); // Default Elastic Logo + return { + margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0], + layout: REPORTING_TABLE_LAYOUT, + table: { + widths: [100, '*', 100], + body: [ + [ + { + fit: [100, 35], + image: logo || logoPath, + }, + { + alignment: 'center', + text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', { + defaultMessage: 'Page {currentPage} of {pageCount}', + values: { currentPage: currentPage.toString(), pageCount }, + }), + style: { + color: '#aaa', + }, + }, + '', + ], + [ + logo + ? { + text: i18n.translate('xpack.reporting.exportTypes.printablePdf.logoDescription', { + defaultMessage: 'Powered by Elastic', + }), + fontSize: 10, + style: { + color: '#aaa', + }, + margin: [0, 2, 0, 0], + } + : '', + '', + '', + ], + ], + }, + }; + }; + return { // define page size pageOrientation: layout.getPdfPageOrientation(), @@ -40,87 +119,14 @@ export function getTemplate( headingHeight, subheadingHeight, }), - pageMargins: [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom], + pageMargins: layout.useReportingBranding + ? [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom] + : [0, 0, 0, 0], - header() { - return { - margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0], - text: title, - font: getFont(title), - style: { - color: '#aaa', - }, - fontSize: 10, - alignment: 'center', - }; - }, - - footer(currentPage: number, pageCount: number) { - const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); // Default Elastic Logo - return { - margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0], - layout: 'noBorder', - table: { - widths: [100, '*', 100], - body: [ - [ - { - fit: [100, 35], - image: logo || logoPath, - }, - { - alignment: 'center', - text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', { - defaultMessage: 'Page {currentPage} of {pageCount}', - values: { currentPage: currentPage.toString(), pageCount }, - }), - style: { - color: '#aaa', - }, - }, - '', - ], - [ - logo - ? { - text: i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.logoDescription', - { - defaultMessage: 'Powered by Elastic', - } - ), - fontSize: 10, - style: { - color: '#aaa', - }, - margin: [0, 2, 0, 0], - } - : '', - '', - '', - ], - ], - }, - }; - }, + header: layout.hasHeader ? getHeader() : undefined, + footer: layout.hasFooter ? getFooter() : undefined, - styles: { - heading: { - alignment: 'left', - fontSize: headingFontSize, - bold: true, - margin: [headingMarginTop, 0, headingMarginBottom, 0], - }, - subheading: { - alignment: 'left', - fontSize: subheadingFontSize, - italics: true, - margin: [0, 0, subheadingMarginBottom, 20], - }, - warning: { - color: '#f39c12', // same as @brand-warning in Kibana colors.less - }, - }, + styles: layout.useReportingBranding ? getStyle() : undefined, defaultStyle: { fontSize: 12, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts index c58ceae85657b..b331aab5dcac4 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts @@ -10,9 +10,9 @@ import concat from 'concat-stream'; import _ from 'lodash'; import path from 'path'; import Printer from 'pdfmake'; -import { Content, ContentText } from 'pdfmake/interfaces'; +import { Content, ContentImage, ContentText } from 'pdfmake/interfaces'; import { LayoutInstance } from '../../../../lib/layouts'; -import { getDocOptions } from './get_doc_options'; +import { getDocOptions, REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; import { getTemplate } from './get_template'; @@ -67,7 +67,7 @@ export class PdfMaker { this._content.push(contents); } - addImage(base64EncodedData: string, { title = '', description = '' }) { + addBrandedImage(img: ContentImage, { title = '', description = '' }) { const contents: Content[] = []; if (title && title.length > 0) { @@ -88,19 +88,11 @@ export class PdfMaker { }); } - const size = this._layout.getPdfImageSize(); - const img = { - image: `data:image/png;base64,${base64EncodedData}`, - alignment: 'center', - height: size.height, - width: size.width, - }; - const wrappedImg = { table: { body: [[img]], }, - layout: 'noBorder', + layout: REPORTING_TABLE_LAYOUT, }; contents.push(wrappedImg); @@ -108,6 +100,22 @@ export class PdfMaker { this._addContents(contents); } + addImage(base64EncodedData: string, opts = { title: '', description: '' }) { + const size = this._layout.getPdfImageSize(); + const img = { + image: `data:image/png;base64,${base64EncodedData}`, + alignment: 'center' as 'center', + height: size.height, + width: size.width, + }; + + if (this._layout.useReportingBranding) { + return this.addBrandedImage(img, opts); + } + + this._addContents([img]); + } + setTitle(title: string) { this._title = title; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts new file mode 100644 index 0000000000000..f96addc2d7705 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getDefaultLayoutSelectors, + LayoutInstance, + LayoutSelectorDictionary, + LayoutTypes, + PageSizeParams, + Size, +} from './'; +import { Layout } from './layout'; + +// FIXME - should use zoom from capture config +const ZOOM: number = 2; + +/* + * This class provides a Layout definition. The PdfMaker class uses this to + * define a document layout that includes no margins or branding or added logos. + * The single image that was captured should be the only structural part of the + * PDF document definition + */ +export class CanvasLayout extends Layout implements LayoutInstance { + public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); + public readonly groupCount = 1; + public readonly height: number; + public readonly width: number; + private readonly scaledHeight: number; + private readonly scaledWidth: number; + + public hasHeader: boolean = false; + public hasFooter: boolean = false; + public useReportingBranding: boolean = false; + + constructor(size: Size) { + super(LayoutTypes.CANVAS); + this.height = size.height; + this.width = size.width; + this.scaledHeight = size.height * ZOOM; + this.scaledWidth = size.width * ZOOM; + } + + public getPdfPageOrientation() { + return undefined; + } + + public getCssOverridesPath() { + return undefined; + } + + public getBrowserViewport() { + return { + height: this.scaledHeight, + width: this.scaledWidth, + }; + } + + public getBrowserZoom() { + return ZOOM; + } + + public getViewport() { + return { + height: this.scaledHeight, + width: this.scaledWidth, + zoom: ZOOM, + }; + } + + public getPdfImageSize() { + return { + height: this.height, + width: this.width, + }; + } + + public getPdfPageSize(pageSizeParams: PageSizeParams): Size { + return { + height: this.height, + width: this.width, + }; + } +} diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts new file mode 100644 index 0000000000000..a4a59aebc2d49 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReportingConfig } from '../..'; +import { createMockConfig, createMockConfigSchema } from '../../test_helpers'; +import { createLayout, LayoutParams, PreserveLayout } from './'; +import { CanvasLayout } from './canvas_layout'; + +describe('Create Layout', () => { + let config: ReportingConfig; + beforeEach(() => { + config = createMockConfig(createMockConfigSchema()); + }); + + it('creates preserve layout instance', () => { + const { id, height, width } = new PreserveLayout({ width: 16, height: 16 }); + const preserveParams: LayoutParams = { id, dimensions: { height, width } }; + const layout = createLayout(config.get('capture'), preserveParams); + expect(layout).toMatchInlineSnapshot(` + PreserveLayout { + "groupCount": 1, + "hasFooter": true, + "hasHeader": true, + "height": 16, + "id": "preserve_layout", + "scaledHeight": 32, + "scaledWidth": 32, + "selectors": Object { + "itemsCountAttribute": "data-shared-items-count", + "renderComplete": "[data-shared-item]", + "screenshot": "[data-shared-items-container]", + "timefilterDurationAttribute": "data-shared-timefilter-duration", + }, + "useReportingBranding": true, + "width": 16, + } + `); + }); + + it('creates the print layout', () => { + const print = createLayout(config.get('capture')); + const printParams: LayoutParams = { + id: print.id, + }; + const layout = createLayout(config.get('capture'), printParams); + expect(layout).toMatchInlineSnapshot(` + PrintLayout { + "captureConfig": Object { + "browser": Object { + "chromium": Object { + "disableSandbox": true, + }, + }, + }, + "groupCount": 2, + "hasFooter": true, + "hasHeader": true, + "id": "print", + "selectors": Object { + "itemsCountAttribute": "data-shared-items-count", + "renderComplete": "[data-shared-item]", + "screenshot": "[data-shared-item]", + "timefilterDurationAttribute": "data-shared-timefilter-duration", + }, + "useReportingBranding": true, + } + `); + }); + + it('creates the canvas layout', () => { + const { id, height, width } = new CanvasLayout({ width: 18, height: 18 }); + const canvasParams: LayoutParams = { id, dimensions: { height, width } }; + const layout = createLayout(config.get('capture'), canvasParams); + expect(layout).toMatchInlineSnapshot(` + CanvasLayout { + "groupCount": 1, + "hasFooter": false, + "hasHeader": false, + "height": 18, + "id": "canvas", + "scaledHeight": 36, + "scaledWidth": 36, + "selectors": Object { + "itemsCountAttribute": "data-shared-items-count", + "renderComplete": "[data-shared-item]", + "screenshot": "[data-shared-items-container]", + "timefilterDurationAttribute": "data-shared-timefilter-duration", + }, + "useReportingBranding": false, + "width": 18, + } + `); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts index c90f67b81317e..073cf1f083a4c 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts @@ -6,7 +6,8 @@ import { LAYOUT_TYPES } from '../../../common/constants'; import { CaptureConfig } from '../../types'; -import { LayoutInstance, LayoutParams } from './'; +import { LayoutInstance, LayoutParams, LayoutTypes } from './'; +import { CanvasLayout } from './canvas_layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; @@ -18,6 +19,10 @@ export function createLayout( return new PreserveLayout(layoutParams.dimensions); } - // this is the default because some jobs won't have anything specified + if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.CANVAS) { + return new CanvasLayout(layoutParams.dimensions); + } + + // layoutParams is optional as PrintLayout doesn't use it return new PrintLayout(captureConfig); } diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts index 8bfe79aeb8a21..e0b5b3f095443 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/index.ts @@ -19,8 +19,22 @@ export { export { createLayout } from './create_layout'; export { Layout } from './layout'; export { PreserveLayout } from './preserve_layout'; +export { CanvasLayout } from './canvas_layout'; export { PrintLayout } from './print_layout'; +export const LayoutTypes = { + PRESERVE_LAYOUT: 'preserve_layout', + PRINT: 'print', + CANVAS: 'canvas', // no margins or branding in the layout +}; + +export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({ + screenshot: '[data-shared-items-container]', + renderComplete: '[data-shared-item]', + itemsCountAttribute: 'data-shared-items-count', + timefilterDurationAttribute: 'data-shared-timefilter-duration', +}); + interface LayoutSelectors { // Fields that are not part of Layout: the instances // independently implement these fields on their own diff --git a/x-pack/plugins/reporting/server/lib/layouts/layout.ts b/x-pack/plugins/reporting/server/lib/layouts/layout.ts index 4dd4003c269c0..c3f06d6a69dad 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/layout.ts @@ -17,6 +17,10 @@ export abstract class Layout { public id: string = ''; public groupCount: number = 0; + public hasHeader: boolean = true; + public hasFooter: boolean = true; + public useReportingBranding: boolean = true; + constructor(id: string) { this.id = id; } @@ -35,5 +39,5 @@ export abstract class Layout { public abstract getBrowserViewport(): Size; - public abstract getCssOverridesPath(): string; + public abstract getCssOverridesPath(): string | undefined; } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts index 2fc711d4d6f07..0b06f183a8894 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts @@ -27,6 +27,9 @@ export const injectCustomCss = async ( ); const filePath = layout.getCssOverridesPath(); + if (!filePath) { + return; + } const buffer = await fsp.readFile(filePath); try { await browser.evaluate( diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 0bb2f8ba1a246..59184562b67ff 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -8,8 +8,11 @@ import { KibanaRequest } from 'src/core/server'; import { AuthenticationResult } from '../authentication/authentication_result'; /** - * Audit event schema using ECS format. - * https://www.elastic.co/guide/en/ecs/1.6/index.html + * Audit event schema using ECS format: https://www.elastic.co/guide/en/ecs/1.6/index.html + * + * If you add additional fields to the schema ensure you update the Kibana Filebeat module: + * https://github.com/elastic/beats/tree/master/filebeat/module/kibana + * * @public */ export interface AuditEvent { @@ -37,20 +40,45 @@ export interface AuditEvent { }; kibana?: { /** - * Current space id of the request. + * The ID of the space associated with this event. */ space_id?: string; /** - * Saved object that was created, changed, deleted or accessed as part of the action. + * The ID of the user session associated with this event. Each login attempt + * results in a unique session id. + */ + session_id?: string; + /** + * Saved object that was created, changed, deleted or accessed as part of this event. */ saved_object?: { type: string; id: string; }; /** - * Any additional event specific fields. + * Name of authentication provider associated with a login event. + */ + authentication_provider?: string; + /** + * Type of authentication provider associated with a login event. + */ + authentication_type?: string; + /** + * Name of Elasticsearch realm that has authenticated the user. + */ + authentication_realm?: string; + /** + * Name of Elasticsearch realm where the user details were retrieved from. + */ + lookup_realm?: string; + /** + * Set of space IDs that a saved object was shared to. + */ + add_to_spaces?: readonly string[]; + /** + * Set of space IDs that a saved object was removed from. */ - [x: string]: any; + delete_from_spaces?: readonly string[]; }; error?: { code?: string; diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index a9c7668871248..91c656ad69f18 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -27,6 +27,7 @@ const { logging } = coreMock.createSetup(); const http = httpServiceMock.createSetupContract(); const getCurrentUser = jest.fn().mockReturnValue({ username: 'jdoe', roles: ['admin'] }); const getSpaceId = jest.fn().mockReturnValue('default'); +const getSID = jest.fn().mockResolvedValue('SESSION_ID'); beforeEach(() => { logger.info.mockClear(); @@ -45,6 +46,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }) ).toMatchInlineSnapshot(` Object { @@ -70,6 +72,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }); expect(logging.configure).toHaveBeenCalledWith(expect.any(Observable)); }); @@ -82,6 +85,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }); expect(http.registerOnPostAuth).toHaveBeenCalledWith(expect.any(Function)); }); @@ -96,16 +100,17 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + await audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); expect(logger.info).toHaveBeenCalledWith('MESSAGE', { ecs: { version: '1.6.0' }, event: { action: 'ACTION' }, - kibana: { space_id: 'default' }, + kibana: { space_id: 'default', session_id: 'SESSION_ID' }, message: 'MESSAGE', trace: { id: 'REQUEST_ID' }, user: { name: 'jdoe', roles: ['admin'] }, @@ -123,12 +128,13 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + await audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); expect(logger.info).not.toHaveBeenCalled(); }); @@ -143,12 +149,13 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log(undefined); + await audit.asScoped(request).log(undefined); expect(logger.info).not.toHaveBeenCalled(); }); }); @@ -368,6 +375,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -398,6 +406,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -436,6 +445,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -464,6 +474,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -493,6 +504,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 8dbdc48c7dee9..4ad1f873581c9 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -36,9 +36,6 @@ interface AuditLogMeta extends AuditEvent { ecs: { version: string; }; - session?: { - id: string; - }; trace: { id: string; }; @@ -57,6 +54,7 @@ interface AuditServiceSetupParams { getCurrentUser( request: KibanaRequest ): ReturnType | undefined; + getSID(request: KibanaRequest): Promise; getSpaceId( request: KibanaRequest ): ReturnType | undefined; @@ -84,6 +82,7 @@ export class AuditService { logging, http, getCurrentUser, + getSID, getSpaceId, }: AuditServiceSetupParams): AuditServiceSetup { if (config.enabled && !config.appender) { @@ -134,12 +133,13 @@ export class AuditService { * }); * ``` */ - const log: AuditLogger['log'] = (event) => { + const log: AuditLogger['log'] = async (event) => { if (!event) { return; } - const user = getCurrentUser(request); const spaceId = getSpaceId(request); + const user = getCurrentUser(request); + const sessionId = await getSID(request); const meta: AuditLogMeta = { ecs: { version: ECS_VERSION }, ...event, @@ -151,11 +151,10 @@ export class AuditService { event.user, kibana: { space_id: spaceId, + session_id: sessionId, ...event.kibana, }, - trace: { - id: request.id, - }, + trace: { id: request.id }, }; if (filterEvent(meta, config.ignore_filters)) { this.ecsLogger.info(event.message!, meta); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 4016b78b6d998..070e187e869b1 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -188,24 +188,25 @@ export class Plugin { registerSecurityUsageCollector({ usageCollection, config, license }); + const { session } = this.sessionManagementService.setup({ + config, + clusterClient, + http: core.http, + kibanaIndexName: legacyConfig.kibana.index, + taskManager, + }); + const audit = this.auditService.setup({ license, config: config.audit, logging: core.logging, http: core.http, getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), + getSID: (request) => session.getSID(request), getCurrentUser: (request) => authenticationSetup.getCurrentUser(request), }); const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); - const { session } = this.sessionManagementService.setup({ - config, - clusterClient, - http: core.http, - kibanaIndexName: legacyConfig.kibana.index, - taskManager, - }); - const authenticationSetup = this.authenticationService.setup({ legacyAuditLogger, audit, diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts index 973341acbfce3..b740249180407 100644 --- a/x-pack/plugins/security/server/session_management/session.mock.ts +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -10,6 +10,7 @@ import { sessionIndexMock } from './session_index.mock'; export const sessionMock = { create: (): jest.Mocked> => ({ + getSID: jest.fn(), get: jest.fn(), create: jest.fn(), update: jest.fn(), diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index 3010e70c31421..47e391ed57925 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -56,6 +56,20 @@ describe('Session', () => { }); }); + describe('#getSID', () => { + const mockRequest = httpServerMock.createKibanaRequest(); + + it('returns `undefined` if session cookie does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); + await expect(session.getSID(mockRequest)).resolves.toBeUndefined(); + }); + + it('returns session id', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + await expect(session.getSID(mockRequest)).resolves.toEqual('some-long-sid'); + }); + }); + describe('#get', () => { const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 4dc83a1abe4af..3c97c13c2d41d 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -99,6 +99,17 @@ export class Session { this.crypto = nodeCrypto({ encryptionKey: this.options.config.encryptionKey }); } + /** + * Extracts session id for the specified request. + * @param request Request instance to get session value for. + */ + async getSID(request: KibanaRequest) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + if (sessionCookieValue) { + return sessionCookieValue.sid; + } + } + /** * Extracts session value for the specified request. Under the hood it can clear session if it is * invalid or created by the legacy versions of Kibana. diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index cc7e8df757c1d..686c05e2fda51 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -136,9 +136,13 @@ export const TIMELINE_PREPACKAGED_URL = `${TIMELINE_URL}/_prepackaged`; * Default signals index key for kibana.dev.yml */ export const SIGNALS_INDEX_KEY = 'signalsIndex'; + export const DETECTION_ENGINE_SIGNALS_URL = `${DETECTION_ENGINE_URL}/signals`; export const DETECTION_ENGINE_SIGNALS_STATUS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/status`; export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/search`; +export const DETECTION_ENGINE_SIGNALS_MIGRATION_URL = `${DETECTION_ENGINE_SIGNALS_URL}/migration`; +export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/migration_status`; +export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL = `${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration`; /** * Common naming convention for an unauthenticated user diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.mock.ts new file mode 100644 index 0000000000000..58e50f84366e3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.mock.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 { CreateSignalsMigrationSchema } from './create_signals_migration_schema'; + +export const getCreateSignalsMigrationSchemaMock = ( + index: string = 'signals-index' +): CreateSignalsMigrationSchema => ({ + index: [index], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.ts new file mode 100644 index 0000000000000..2c441bd31fe2c --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_signals_migration_schema.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { index } from '../common/schemas'; +import { PositiveInteger, PositiveIntegerGreaterThanZero } from '../types'; + +export const signalsReindexOptions = t.partial({ + requests_per_second: t.number, + size: PositiveIntegerGreaterThanZero, + slices: PositiveInteger, +}); + +export type SignalsReindexOptions = t.TypeOf; + +export const createSignalsMigrationSchema = t.intersection([ + t.exact( + t.type({ + index, + }) + ), + t.exact(signalsReindexOptions), +]); + +export type CreateSignalsMigrationSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts new file mode 100644 index 0000000000000..d0387586a2527 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FinalizeSignalsMigrationSchema } from './finalize_signals_migration_schema'; + +export const getFinalizeSignalsMigrationSchemaMock = (): FinalizeSignalsMigrationSchema => ({ + migration_token: + 'eyJkZXN0aW5hdGlvbkluZGV4IjoiZGVzdGluYXRpb25JbmRleCIsInNvdXJjZUluZGV4Ijoic291cmNlSW5kZXgiLCJ0YXNrSWQiOiJteS10YXNrLWlkIn0=', +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts new file mode 100644 index 0000000000000..7ab2ee3810258 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../types'; + +const migrationToken = NonEmptyString; + +export const finalizeSignalsMigrationSchema = t.exact( + t.type({ + migration_token: migrationToken, + }) +); + +export type FinalizeSignalsMigrationSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts new file mode 100644 index 0000000000000..dfa230fc21d71 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { from } from '../common/schemas'; + +export const getMigrationStatusSchema = t.exact( + t.type({ + from, + }) +); + +export type GetMigrationStatusSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index 9fa7f96599deb..0346f3bb9439b 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -9,6 +9,7 @@ import { Inspect, Maybe } from '../../../common'; import { TimelineRequestOptionsPaginated } from '../..'; export interface TimelineEventsDetailsItem { + category?: string; field: string; values?: Maybe; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index e447a004fb51c..aa114ff074898 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -411,7 +411,6 @@ export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom'; export interface TimelineExpandedEventType { eventId: string; indexName: string; - loading: boolean; } // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 b3c82a8d9d6f0..3ce507c791f0a 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 @@ -217,7 +217,7 @@ describe('Custom detection rules creation', () => { }); }); -describe('Custom detection rules deletion and edition', () => { +describe.skip('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); 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 ab0a53f4cd014..4d013ee7d7232 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 @@ -83,7 +83,9 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { DETECTIONS_URL } from '../urls/navigation'; -describe('Detection rules, override', () => { +// FLAKY: https://github.com/elastic/kibana/issues/85671 +// FLAKY: https://github.com/elastic/kibana/issues/84020 +describe.skip('Detection rules, override', () => { const expectedUrls = newOverrideRule.referenceUrls.join(''); const expectedFalsePositives = newOverrideRule.falsePositivesExamples.join(''); const expectedTags = newOverrideRule.tags.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts index 1cece57c2fea5..2bfe72033135b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts @@ -4,14 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ALERT_ID } from '../screens/alerts'; import { PROVIDER_BADGE } from '../screens/timeline'; -import { - expandFirstAlert, - investigateFirstAlertInTimeline, - waitForAlertsPanelToBeLoaded, -} from '../tasks/alerts'; +import { investigateFirstAlertInTimeline, waitForAlertsPanelToBeLoaded } from '../tasks/alerts'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; @@ -29,13 +24,13 @@ describe('Alerts timeline', () => { it('Investigate alert in default timeline', () => { waitForAlertsPanelToBeLoaded(); - expandFirstAlert(); - cy.get(ALERT_ID) + investigateFirstAlertInTimeline(); + cy.get(PROVIDER_BADGE) .first() .invoke('text') .then((eventId) => { investigateFirstAlertInTimeline(); - cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', `_id: "${eventId}"`); + cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', eventId); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts index 831fa8fbbf9fa..4009ac13ab120 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts @@ -45,7 +45,8 @@ import { openTimeline } from '../tasks/timelines'; import { OVERVIEW_URL } from '../urls/navigation'; -describe('Timelines', () => { +// FLAKY: https://github.com/elastic/kibana/issues/79389 +describe.skip('Timelines', () => { let timelineId: string; after(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index 288e2178d71ae..abf6ca38844e2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -24,7 +24,9 @@ import { closeTimeline, createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; -describe('timeline data providers', () => { +// FLAKY: https://github.com/elastic/kibana/issues/85098 +// FLAKY: https://github.com/elastic/kibana/issues/62060 +describe.skip('timeline data providers', () => { before(() => { loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index f85e6f683cba5..b911fc5b81f98 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -48,7 +48,8 @@ const ABSOLUTE_DATE = { startTimeTimeline: '2019-08-02T20:03:29.186Z', }; -describe('url state', () => { +// FLAKY: https://github.com/elastic/kibana/issues/61612 +describe.skip('url state', () => { it('sets the global start and end dates from the url', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should( diff --git a/x-pack/plugins/security_solution/cypress/screens/shared.ts b/x-pack/plugins/security_solution/cypress/screens/shared.ts index ccfe0f97c732c..eae8de6d5ee8b 100644 --- a/x-pack/plugins/security_solution/cypress/screens/shared.ts +++ b/x-pack/plugins/security_solution/cypress/screens/shared.ts @@ -6,4 +6,4 @@ export const NOTIFICATION_TOASTS = '[data-test-subj="globalToastList"]'; -export const TOAST_ERROR_CLASS = 'euiToast--danger'; +export const TOAST_ERROR = '.euiToast--danger'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 9397307684d6a..0f5e8c133f0d0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -40,7 +40,8 @@ export const GRAPH_TAB_BUTTON = '[data-test-subj="timelineTabs-graph"]'; export const HEADER = '[data-test-subj="header"]'; -export const HEADERS_GROUP = '[data-test-subj="headers-group"]'; +export const HEADERS_GROUP = + '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"]'; export const ID_HEADER_FIELD = '[data-test-subj="timeline"] [data-test-subj="header-text-_id"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index cb099ea26d37b..026813e4a11cf 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -71,7 +71,7 @@ import { MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON, MITRE_ATTACK_ADD_TECHNIQUE_BUTTON, } from '../screens/create_new_rule'; -import { NOTIFICATION_TOASTS, TOAST_ERROR_CLASS } from '../screens/shared'; +import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; import { TIMELINE } from '../screens/timelines'; import { refreshPage } from './security_header'; @@ -262,11 +262,20 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { }; export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { + cy.get(EQL_QUERY_INPUT).should('exist'); + cy.get(EQL_QUERY_INPUT).should('be.visible'); cy.get(EQL_QUERY_INPUT).type(rule.customQuery!); cy.get(EQL_QUERY_VALIDATION_SPINNER).should('not.exist'); cy.get(QUERY_PREVIEW_BUTTON).should('not.be.disabled').click({ force: true }); - cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); - cy.get(NOTIFICATION_TOASTS).children().should('not.have.class', TOAST_ERROR_CLASS); // asserts no error toast on page + cy.get(EQL_QUERY_PREVIEW_HISTOGRAM) + .invoke('text') + .then((text) => { + if (text !== 'Hits') { + cy.get(QUERY_PREVIEW_BUTTON).click({ force: true }); + cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); + } + }); + cy.get(TOAST_ERROR).should('not.exist'); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(EQL_QUERY_INPUT).should('not.exist'); diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 54b02c374e43f..ffed557f28511 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -14,6 +14,7 @@ import { ThemeProvider } from 'styled-components'; import { EuiErrorBoundary } from '@elastic/eui'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { AppLeaveHandler } from '../../../../../src/core/public'; import { ManageUserInfo } from '../detections/components/user_info'; import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; @@ -28,13 +29,21 @@ import { ApolloClientContext } from '../common/utils/apollo_context'; import { ManageGlobalTimeline } from '../timelines/components/manage_timeline'; import { StartServices } from '../types'; import { PageRouter } from './routes'; + interface StartAppComponent extends AppFrontendLibs { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; store: Store; } -const StartAppComponent: FC = ({ children, apolloClient, history, store }) => { +const StartAppComponent: FC = ({ + children, + apolloClient, + history, + onAppLeave, + store, +}) => { const { i18n } = useKibana().services; const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -57,7 +66,9 @@ const StartAppComponent: FC = ({ children, apolloClient, hist - {children} + + {children} + @@ -78,6 +89,7 @@ const StartApp = memo(StartAppComponent); interface SecurityAppComponentProps extends AppFrontendLibs { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; services: StartServices; store: Store; } @@ -86,6 +98,7 @@ const SecurityAppComponent: React.FC = ({ children, apolloClient, history, + onAppLeave, services, store, }) => ( @@ -95,7 +108,7 @@ const SecurityAppComponent: React.FC = ({ ...services, }} > - + {children} diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 3b64c1f7f1f65..30c4e87f695b2 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -23,6 +23,7 @@ import { DETECTIONS_SUB_PLUGIN_ID } from '../../../common/constants'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useUpgradeEndpointPackage } from '../../common/hooks/endpoint/upgrade'; import { useThrottledResizeObserver } from '../../common/components/utils'; +import { AppLeaveHandler } from '../../../../../../src/core/public'; const Main = styled.main.attrs<{ paddingTop: number }>(({ paddingTop }) => ({ style: { @@ -39,9 +40,10 @@ Main.displayName = 'Main'; interface HomePageProps { children: React.ReactNode; + onAppLeave: (handler: AppLeaveHandler) => void; } -const HomePageComponent: React.FC = ({ children }) => { +const HomePageComponent: React.FC = ({ children, onAppLeave }) => { const { application, overlays } = useKibana().services; const subPluginId = useRef(''); const { ref, height = 0 } = useThrottledResizeObserver(300); @@ -87,7 +89,7 @@ const HomePageComponent: React.FC = ({ children }) => { {indicesExist && showTimeline && ( <> - + )} diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 4c8e87c4abfba..d45c5393c01d6 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -14,12 +14,19 @@ export const renderApp = ({ apolloClient, element, history, + onAppLeave, services, store, SubPluginRoutes, }: RenderAppProps): (() => void) => { render( - + , element diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index 1d3a59856caa9..ed6d1f319b7e6 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -7,20 +7,22 @@ import { History } from 'history'; import React, { FC, memo, useEffect } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; - import { useDispatch } from 'react-redux'; -import { NotFoundPage } from './404'; -import { HomePage } from './home'; + +import { AppLeaveHandler } from '../../../../../src/core/public'; import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; import { RouteCapture } from '../common/components/endpoint/route_capture'; import { AppAction } from '../common/store/actions'; +import { NotFoundPage } from './404'; +import { HomePage } from './home'; interface RouterProps { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; } -const PageRouterComponent: FC = ({ history, children }) => { +const PageRouterComponent: FC = ({ children, history, onAppLeave }) => { const dispatch = useDispatch<(action: AppAction) => void>(); useEffect(() => { return () => { @@ -39,7 +41,7 @@ const PageRouterComponent: FC = ({ history, children }) => { - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e5a673b03449f..0e6226f69fce7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -352,7 +352,6 @@ export const CaseComponent = React.memo( event: { eventId: alertId, indexName: index, - loading: false, }, }) ); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 7466d34a9938f..8ec5133ef48b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -134,7 +134,7 @@ const AddToCaseActionComponent: React.FC = ({ ecsRowData, return ( <> - + = ({ startDate, }) => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const getSubtitle = useCallback( (totalCount: number) => diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx index 28a73801c8c0f..c82ef392ce3d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -9,7 +9,7 @@ import { EuiFormRow, EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { IFieldType } from '../../../../../../../src/plugins/data/common'; import { useFindLists, ListSchema } from '../../../lists_plugin_deps'; import { useKibana } from '../../../common/lib/kibana'; -import { getGenericComboBoxProps } from './helpers'; +import { filterFieldToList, getGenericComboBoxProps } from './helpers'; import * as i18n from './translations'; interface AutocompleteFieldListsProps { @@ -41,17 +41,10 @@ export const AutocompleteFieldListsComponent: React.FC name, []); - const optionsMemo = useMemo(() => { - if ( - selectedField != null && - selectedField.esTypes != null && - selectedField.esTypes.length > 0 - ) { - return lists.filter(({ type }) => selectedField.esTypes?.includes(type)); - } else { - return []; - } - }, [lists, selectedField]); + const optionsMemo = useMemo(() => filterFieldToList(lists, selectedField), [ + lists, + selectedField, + ]); const selectedOptionsMemo = useMemo(() => { if (selectedValue != null) { const list = lists.filter(({ id }) => id === selectedValue); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index f78740f764202..abbeec2b64d72 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -6,6 +6,7 @@ import moment from 'moment'; import '../../../common/mock/match_media'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; import * as i18n from './translations'; import { @@ -15,7 +16,16 @@ import { existsOperator, doesNotExistOperator, } from './operators'; -import { getOperators, checkEmptyValue, paramIsValid, getGenericComboBoxProps } from './helpers'; +import { + getOperators, + checkEmptyValue, + paramIsValid, + getGenericComboBoxProps, + typeMatch, + filterFieldToList, +} from './helpers'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../../lists/common'; describe('helpers', () => { // @ts-ignore @@ -260,4 +270,117 @@ describe('helpers', () => { }); }); }); + + describe('#typeMatch', () => { + test('ip -> ip is true', () => { + expect(typeMatch('ip', 'ip')).toEqual(true); + }); + + test('keyword -> keyword is true', () => { + expect(typeMatch('keyword', 'keyword')).toEqual(true); + }); + + test('text -> text is true', () => { + expect(typeMatch('text', 'text')).toEqual(true); + }); + + test('ip_range -> ip is true', () => { + expect(typeMatch('ip_range', 'ip')).toEqual(true); + }); + + test('date_range -> date is true', () => { + expect(typeMatch('date_range', 'date')).toEqual(true); + }); + + test('double_range -> double is true', () => { + expect(typeMatch('double_range', 'double')).toEqual(true); + }); + + test('float_range -> float is true', () => { + expect(typeMatch('float_range', 'float')).toEqual(true); + }); + + test('integer_range -> integer is true', () => { + expect(typeMatch('integer_range', 'integer')).toEqual(true); + }); + + test('long_range -> long is true', () => { + expect(typeMatch('long_range', 'long')).toEqual(true); + }); + + test('ip -> date is false', () => { + expect(typeMatch('ip', 'date')).toEqual(false); + }); + + test('long -> float is false', () => { + expect(typeMatch('long', 'float')).toEqual(false); + }); + + test('integer -> long is false', () => { + expect(typeMatch('integer', 'long')).toEqual(false); + }); + }); + + describe('#filterFieldToList', () => { + test('it returns empty array if given a undefined for field', () => { + const filter = filterFieldToList([], undefined); + expect(filter).toEqual([]); + }); + + test('it returns empty array if filed does not contain esTypes', () => { + const field: IFieldType = { name: 'some-name', type: 'some-type' }; + const filter = filterFieldToList([], field); + expect(filter).toEqual([]); + }); + + test('it returns single filtered list of ip_range -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of ip -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of keyword -> keyword', () => { + const field: IFieldType = { name: 'some-name', type: 'keyword', esTypes: ['keyword'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of text -> text', () => { + const field: IFieldType = { name: 'some-name', type: 'text', esTypes: ['text'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns 2 filtered lists of ip_range -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1, listItem2]; + expect(filter).toEqual(expected); + }); + + test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1]; + expect(filter).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index 1ad296e0299b1..44e5adde65650 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -18,6 +18,7 @@ import { } from './operators'; import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; import * as i18n from './translations'; +import { ListSchema, Type } from '../../../lists_plugin_deps'; /** * Returns the appropriate operators given a field type @@ -138,3 +139,36 @@ export function getGenericComboBoxProps({ selectedComboOptions: newSelectedComboOptions, }; } + +/** + * Given an array of lists and optionally a field this will return all + * the lists that match against the field based on the types from the field + * @param lists The lists to match against the field + * @param field The field to check against the list to see if they are compatible + */ +export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { + if (field != null) { + const { esTypes = [] } = field; + return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType))); + } else { + return []; + } +}; + +/** + * Given an input list type and a string based ES type this will match + * if they're exact or if they are compatible with a range + * @param type The type to match against the esType + * @param esType The ES type to match with + */ +export const typeMatch = (type: Type, esType: string): boolean => { + return ( + type === esType || + (type === 'ip_range' && esType === 'ip') || + (type === 'date_range' && esType === 'date') || + (type === 'double_range' && esType === 'double') || + (type === 'float_range' && esType === 'float') || + (type === 'integer_range' && esType === 'integer') || + (type === 'long_range' && esType === 'long') + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index 5a50442f8dd5f..71da294fb1844 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -11,6 +11,7 @@ import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; import uuid from 'uuid'; import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { useTimeZone } from '../../lib/kibana'; @@ -193,4 +194,11 @@ export const BarChartComponent: React.FC = ({ ); }; -export const BarChart = React.memo(BarChartComponent); +export const BarChart = React.memo( + BarChartComponent, + (prevProps, nextProps) => + prevProps.stackByField === nextProps.stackByField && + prevProps.timelineId === nextProps.timelineId && + deepEqual(prevProps.configs, nextProps.configs) && + deepEqual(prevProps.barChart, nextProps.barChart) +); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts new file mode 100644 index 0000000000000..b4561e6d5bffd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts @@ -0,0 +1,657 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockAlertDetailsData = [ + { category: 'process', field: 'process.name', values: ['-'], originalValue: '-' }, + { category: 'process', field: 'process.pid', values: [0], originalValue: 0 }, + { category: 'process', field: 'process.executable', values: ['-'], originalValue: '-' }, + { + category: 'agent', + field: 'agent.hostname', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'agent', + field: 'agent.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'agent', + field: 'agent.id', + values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + originalValue: 'abfe4a35-d5b4-42a0-a539-bd054c791769', + }, + { category: 'agent', field: 'agent.type', values: ['winlogbeat'], originalValue: 'winlogbeat' }, + { + category: 'agent', + field: 'agent.ephemeral_id', + values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + originalValue: 'b9850845-c000-4ddd-bd51-9978a07b7e7d', + }, + { category: 'agent', field: 'agent.version', values: ['7.10.0'], originalValue: '7.10.0' }, + { + category: 'winlog', + field: 'winlog.computer_name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { category: 'winlog', field: 'winlog.process.pid', values: [624], originalValue: 624 }, + { category: 'winlog', field: 'winlog.process.thread.id', values: [1896], originalValue: 1896 }, + { + category: 'winlog', + field: 'winlog.keywords', + values: ['Audit Failure'], + originalValue: ['Audit Failure'], + }, + { + category: 'winlog', + field: 'winlog.logon.failure.reason', + values: ['Unknown user name or bad password.'], + originalValue: 'Unknown user name or bad password.', + }, + { + category: 'winlog', + field: 'winlog.logon.failure.sub_status', + values: ['User logon with misspelled or bad password'], + originalValue: 'User logon with misspelled or bad password', + }, + { + category: 'winlog', + field: 'winlog.logon.failure.status', + values: ['This is either due to a bad username or authentication information'], + originalValue: 'This is either due to a bad username or authentication information', + }, + { category: 'winlog', field: 'winlog.logon.id', values: ['0x0'], originalValue: '0x0' }, + { category: 'winlog', field: 'winlog.logon.type', values: ['Network'], originalValue: 'Network' }, + { category: 'winlog', field: 'winlog.channel', values: ['Security'], originalValue: 'Security' }, + { + category: 'winlog', + field: 'winlog.event_data.Status', + values: ['0xc000006d'], + originalValue: '0xc000006d', + }, + { category: 'winlog', field: 'winlog.event_data.LogonType', values: ['3'], originalValue: '3' }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectLogonId', + values: ['0x0'], + originalValue: '0x0', + }, + { + category: 'winlog', + field: 'winlog.event_data.TransmittedServices', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.LmPackageName', + values: ['-'], + originalValue: '-', + }, + { category: 'winlog', field: 'winlog.event_data.KeyLength', values: ['0'], originalValue: '0' }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectUserName', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.FailureReason', + values: ['%%2313'], + originalValue: '%%2313', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectDomainName', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.TargetUserName', + values: ['administrator'], + originalValue: 'administrator', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubStatus', + values: ['0xc000006a'], + originalValue: '0xc000006a', + }, + { + category: 'winlog', + field: 'winlog.event_data.LogonProcessName', + values: ['NtLmSsp '], + originalValue: 'NtLmSsp ', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectUserSid', + values: ['S-1-0-0'], + originalValue: 'S-1-0-0', + }, + { + category: 'winlog', + field: 'winlog.event_data.AuthenticationPackageName', + values: ['NTLM'], + originalValue: 'NTLM', + }, + { + category: 'winlog', + field: 'winlog.event_data.TargetUserSid', + values: ['S-1-0-0'], + originalValue: 'S-1-0-0', + }, + { category: 'winlog', field: 'winlog.opcode', values: ['Info'], originalValue: 'Info' }, + { category: 'winlog', field: 'winlog.record_id', values: [890770], originalValue: 890770 }, + { category: 'winlog', field: 'winlog.task', values: ['Logon'], originalValue: 'Logon' }, + { category: 'winlog', field: 'winlog.event_id', values: [4625], originalValue: 4625 }, + { + category: 'winlog', + field: 'winlog.provider_guid', + values: ['{54849625-5478-4994-a5ba-3e3b0328c30d}'], + originalValue: '{54849625-5478-4994-a5ba-3e3b0328c30d}', + }, + { + category: 'winlog', + field: 'winlog.activity_id', + values: ['{e148a943-f9c4-0001-5a39-81b88bbed601}'], + originalValue: '{e148a943-f9c4-0001-5a39-81b88bbed601}', + }, + { + category: 'winlog', + field: 'winlog.api', + values: ['wineventlog'], + originalValue: 'wineventlog', + }, + { + category: 'winlog', + field: 'winlog.provider_name', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { category: 'log', field: 'log.level', values: ['information'], originalValue: 'information' }, + { category: 'source', field: 'source.port', values: [0], originalValue: 0 }, + { category: 'source', field: 'source.domain', values: ['-'], originalValue: '-' }, + { + category: 'source', + field: 'source.ip', + values: ['185.156.74.3'], + originalValue: '185.156.74.3', + }, + { + category: 'base', + field: 'message', + values: [ + 'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.', + ], + originalValue: + 'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.', + }, + { + category: 'cloud', + field: 'cloud.availability_zone', + values: ['us-central1-a'], + originalValue: 'us-central1-a', + }, + { + category: 'cloud', + field: 'cloud.instance.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'cloud', + field: 'cloud.instance.id', + values: ['5896613765949631815'], + originalValue: '5896613765949631815', + }, + { category: 'cloud', field: 'cloud.provider', values: ['gcp'], originalValue: 'gcp' }, + { + category: 'cloud', + field: 'cloud.machine.type', + values: ['e2-medium'], + originalValue: 'e2-medium', + }, + { + category: 'cloud', + field: 'cloud.project.id', + values: ['elastic-siem'], + originalValue: 'elastic-siem', + }, + { + category: 'base', + field: '@timestamp', + values: ['2020-11-25T15:42:39.417Z'], + originalValue: '2020-11-25T15:42:39.417Z', + }, + { + category: 'related', + field: 'related.user', + values: ['administrator'], + originalValue: 'administrator', + }, + { category: 'ecs', field: 'ecs.version', values: ['1.5.0'], originalValue: '1.5.0' }, + { + category: 'host', + field: 'host.hostname', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { category: 'host', field: 'host.os.build', values: ['17763.1577'], originalValue: '17763.1577' }, + { + category: 'host', + field: 'host.os.kernel', + values: ['10.0.17763.1577 (WinBuild.160101.0800)'], + originalValue: '10.0.17763.1577 (WinBuild.160101.0800)', + }, + { + category: 'host', + field: 'host.os.name', + values: ['Windows Server 2019 Datacenter'], + originalValue: 'Windows Server 2019 Datacenter', + }, + { category: 'host', field: 'host.os.family', values: ['windows'], originalValue: 'windows' }, + { category: 'host', field: 'host.os.version', values: ['10.0'], originalValue: '10.0' }, + { category: 'host', field: 'host.os.platform', values: ['windows'], originalValue: 'windows' }, + { + category: 'host', + field: 'host.ip', + values: ['fe80::406c:d205:5b46:767f', '10.128.15.228'], + originalValue: ['fe80::406c:d205:5b46:767f', '10.128.15.228'], + }, + { + category: 'host', + field: 'host.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'host', + field: 'host.id', + values: ['08f50e68-847a-4fae-a8eb-c7dc886447bb'], + originalValue: '08f50e68-847a-4fae-a8eb-c7dc886447bb', + }, + { + category: 'host', + field: 'host.mac', + values: ['42:01:0a:80:0f:e4'], + originalValue: ['42:01:0a:80:0f:e4'], + }, + { category: 'host', field: 'host.architecture', values: ['x86_64'], originalValue: 'x86_64' }, + { + category: 'event', + field: 'event.ingested', + values: ['2020-11-25T15:36:40.924914552Z'], + originalValue: '2020-11-25T15:36:40.924914552Z', + }, + { category: 'event', field: 'event.code', values: [4625], originalValue: 4625 }, + { category: 'event', field: 'event.lag.total', values: [2077], originalValue: 2077 }, + { category: 'event', field: 'event.lag.read', values: [1075], originalValue: 1075 }, + { category: 'event', field: 'event.lag.ingest', values: [1002], originalValue: 1002 }, + { + category: 'event', + field: 'event.provider', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { + category: 'event', + field: 'event.created', + values: ['2020-11-25T15:36:39.922Z'], + originalValue: '2020-11-25T15:36:39.922Z', + }, + { category: 'event', field: 'event.kind', values: ['signal'], originalValue: 'signal' }, + { category: 'event', field: 'event.module', values: ['security'], originalValue: 'security' }, + { + category: 'event', + field: 'event.action', + values: ['logon-failed'], + originalValue: 'logon-failed', + }, + { category: 'event', field: 'event.type', values: ['start'], originalValue: 'start' }, + { + category: 'event', + field: 'event.category', + values: ['authentication'], + originalValue: 'authentication', + }, + { category: 'event', field: 'event.outcome', values: ['failure'], originalValue: 'failure' }, + { + category: 'user', + field: 'user.name', + values: ['administrator'], + originalValue: 'administrator', + }, + { category: 'user', field: 'user.id', values: ['S-1-0-0'], originalValue: 'S-1-0-0' }, + { + category: 'signal', + field: 'signal.parents', + values: [ + '{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}', + ], + originalValue: [ + { + id: '688MAHYB7WTwW_Glsi_d', + type: 'event', + index: 'winlogbeat-7.10.0-2020.11.12-000001', + depth: 0, + }, + ], + }, + { + category: 'signal', + field: 'signal.ancestors', + values: [ + '{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}', + ], + originalValue: [ + { + id: '688MAHYB7WTwW_Glsi_d', + type: 'event', + index: 'winlogbeat-7.10.0-2020.11.12-000001', + depth: 0, + }, + ], + }, + { category: 'signal', field: 'signal.status', values: ['open'], originalValue: 'open' }, + { + category: 'signal', + field: 'signal.rule.id', + values: ['b69d086c-325a-4f46-b17b-fb6d227006ba'], + originalValue: 'b69d086c-325a-4f46-b17b-fb6d227006ba', + }, + { + category: 'signal', + field: 'signal.rule.rule_id', + values: ['e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5'], + originalValue: 'e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5', + }, + { category: 'signal', field: 'signal.rule.actions', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.author', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.false_positives', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.meta.from', values: ['1m'], originalValue: '1m' }, + { + category: 'signal', + field: 'signal.rule.meta.kibana_siem_app_url', + values: ['http://localhost:5601/app/security'], + originalValue: 'http://localhost:5601/app/security', + }, + { category: 'signal', field: 'signal.rule.max_signals', values: [100], originalValue: 100 }, + { category: 'signal', field: 'signal.rule.risk_score', values: [21], originalValue: 21 }, + { category: 'signal', field: 'signal.rule.risk_score_mapping', values: [], originalValue: [] }, + { + category: 'signal', + field: 'signal.rule.output_index', + values: ['.siem-signals-angelachuang-default'], + originalValue: '.siem-signals-angelachuang-default', + }, + { category: 'signal', field: 'signal.rule.description', values: ['xxx'], originalValue: 'xxx' }, + { + category: 'signal', + field: 'signal.rule.from', + values: ['now-360s'], + originalValue: 'now-360s', + }, + { + category: 'signal', + field: 'signal.rule.index', + values: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + originalValue: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + }, + { category: 'signal', field: 'signal.rule.interval', values: ['5m'], originalValue: '5m' }, + { category: 'signal', field: 'signal.rule.language', values: ['kuery'], originalValue: 'kuery' }, + { category: 'signal', field: 'signal.rule.license', values: [''], originalValue: '' }, + { category: 'signal', field: 'signal.rule.name', values: ['xxx'], originalValue: 'xxx' }, + { + category: 'signal', + field: 'signal.rule.query', + values: ['@timestamp : * '], + originalValue: '@timestamp : * ', + }, + { category: 'signal', field: 'signal.rule.references', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.severity', values: ['low'], originalValue: 'low' }, + { category: 'signal', field: 'signal.rule.severity_mapping', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.tags', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.type', values: ['query'], originalValue: 'query' }, + { category: 'signal', field: 'signal.rule.to', values: ['now'], originalValue: 'now' }, + { + category: 'signal', + field: 'signal.rule.filters', + values: [ + '{"meta":{"alias":null,"negate":false,"disabled":false,"type":"exists","key":"message","value":"exists"},"exists":{"field":"message"},"$state":{"store":"appState"}}', + ], + originalValue: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'message', + value: 'exists', + }, + exists: { field: 'message' }, + $state: { store: 'appState' }, + }, + ], + }, + { + category: 'signal', + field: 'signal.rule.created_by', + values: ['angela'], + originalValue: 'angela', + }, + { + category: 'signal', + field: 'signal.rule.updated_by', + values: ['angela'], + originalValue: 'angela', + }, + { category: 'signal', field: 'signal.rule.threat', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.version', values: [2], originalValue: 2 }, + { + category: 'signal', + field: 'signal.rule.created_at', + values: ['2020-11-24T10:30:33.660Z'], + originalValue: '2020-11-24T10:30:33.660Z', + }, + { + category: 'signal', + field: 'signal.rule.updated_at', + values: ['2020-11-25T15:37:40.939Z'], + originalValue: '2020-11-25T15:37:40.939Z', + }, + { category: 'signal', field: 'signal.rule.exceptions_list', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.depth', values: [1], originalValue: 1 }, + { + category: 'signal', + field: 'signal.parent.id', + values: ['688MAHYB7WTwW_Glsi_d'], + originalValue: '688MAHYB7WTwW_Glsi_d', + }, + { category: 'signal', field: 'signal.parent.type', values: ['event'], originalValue: 'event' }, + { + category: 'signal', + field: 'signal.parent.index', + values: ['winlogbeat-7.10.0-2020.11.12-000001'], + originalValue: 'winlogbeat-7.10.0-2020.11.12-000001', + }, + { category: 'signal', field: 'signal.parent.depth', values: [0], originalValue: 0 }, + { + category: 'signal', + field: 'signal.original_time', + values: ['2020-11-25T15:36:38.847Z'], + originalValue: '2020-11-25T15:36:38.847Z', + }, + { + category: 'signal', + field: 'signal.original_event.ingested', + values: ['2020-11-25T15:36:40.924914552Z'], + originalValue: '2020-11-25T15:36:40.924914552Z', + }, + { category: 'signal', field: 'signal.original_event.code', values: [4625], originalValue: 4625 }, + { + category: 'signal', + field: 'signal.original_event.lag.total', + values: [2077], + originalValue: 2077, + }, + { + category: 'signal', + field: 'signal.original_event.lag.read', + values: [1075], + originalValue: 1075, + }, + { + category: 'signal', + field: 'signal.original_event.lag.ingest', + values: [1002], + originalValue: 1002, + }, + { + category: 'signal', + field: 'signal.original_event.provider', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { + category: 'signal', + field: 'signal.original_event.created', + values: ['2020-11-25T15:36:39.922Z'], + originalValue: '2020-11-25T15:36:39.922Z', + }, + { + category: 'signal', + field: 'signal.original_event.kind', + values: ['event'], + originalValue: 'event', + }, + { + category: 'signal', + field: 'signal.original_event.module', + values: ['security'], + originalValue: 'security', + }, + { + category: 'signal', + field: 'signal.original_event.action', + values: ['logon-failed'], + originalValue: 'logon-failed', + }, + { + category: 'signal', + field: 'signal.original_event.type', + values: ['start'], + originalValue: 'start', + }, + { + category: 'signal', + field: 'signal.original_event.category', + values: ['authentication'], + originalValue: 'authentication', + }, + { + category: 'signal', + field: 'signal.original_event.outcome', + values: ['failure'], + originalValue: 'failure', + }, + { + category: '_index', + field: '_index', + values: ['.siem-signals-angelachuang-default-000004'], + originalValue: '.siem-signals-angelachuang-default-000004', + }, + { + category: '_id', + field: '_id', + values: ['5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31'], + originalValue: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + }, + { category: '_score', field: '_score', values: [1], originalValue: 1 }, + { + category: 'fields', + field: 'fields.agent.name', + values: ['windows-native'], + originalValue: ['windows-native'], + }, + { + category: 'fields', + field: 'fields.cloud.machine.type', + values: ['e2-medium'], + originalValue: ['e2-medium'], + }, + { category: 'fields', field: 'fields.cloud.provider', values: ['gcp'], originalValue: ['gcp'] }, + { + category: 'fields', + field: 'fields.agent.id', + values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + }, + { + category: 'fields', + field: 'fields.cloud.instance.id', + values: ['5896613765949631815'], + originalValue: ['5896613765949631815'], + }, + { + category: 'fields', + field: 'fields.agent.type', + values: ['winlogbeat'], + originalValue: ['winlogbeat'], + }, + { + category: 'fields', + field: 'fields.@timestamp', + values: ['2020-11-25T15:42:39.417Z'], + originalValue: ['2020-11-25T15:42:39.417Z'], + }, + { + category: 'fields', + field: 'fields.agent.ephemeral_id', + values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + originalValue: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + }, + { + category: 'fields', + field: 'fields.cloud.instance.name', + values: ['windows-native'], + originalValue: ['windows-native'], + }, + { + category: 'fields', + field: 'fields.cloud.availability_zone', + values: ['us-central1-a'], + originalValue: ['us-central1-a'], + }, + { + category: 'fields', + field: 'fields.agent.version', + values: ['7.10.0'], + originalValue: ['7.10.0'], + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index 8d807825c246a..973d067d9e379 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -566,6 +566,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } eventId="Y-6TfmcB0WOhS6qyMv3s" @@ -1139,6 +1146,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } eventId="Y-6TfmcB0WOhS6qyMv3s" @@ -1296,6 +1310,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } /> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index af9fc61b9585c..2b681870e92fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -1,17 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`JSON View rendering should match snapshot 1`] = ` - + + width="100%" + /> + `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 1a492eee4ae7a..0b2fbcf703d77 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -58,6 +58,7 @@ export const getColumns = ({ onUpdateColumns, contextId, toggleColumn, + getLinkValue, }: { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; @@ -65,6 +66,7 @@ export const getColumns = ({ onUpdateColumns: OnUpdateColumns; contextId: string; toggleColumn: (column: ColumnHeaderOptions) => void; + getLinkValue: (field: string) => string | null; }) => [ { field: 'field', @@ -187,6 +189,7 @@ export const getColumns = ({ fieldName={data.field} fieldType={data.type} value={value} + linkValue={getLinkValue(data.field)} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index bafe3df1a9cc7..20fa6e54e044d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -9,37 +9,45 @@ import React from 'react'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; -import { - defaultHeaders, - mockDetailItemData, - mockDetailItemDataId, - TestProviders, -} from '../../mock'; +import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock'; -import { EventDetails, View } from './event_details'; +import { EventDetails, EventsViewType } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; jest.mock('../link_to'); describe('EventDetails', () => { const mount = useMountAppended(); const defaultProps = { browserFields: mockBrowserFields, - columnHeaders: defaultHeaders, data: mockDetailItemData, id: mockDetailItemDataId, - view: 'table-view' as View, - onUpdateColumns: jest.fn(), + isAlert: false, onViewSelected: jest.fn(), timelineId: 'test', - toggleColumn: jest.fn(), + view: EventsViewType.summaryView, }; + + const alertsProps = { + ...defaultProps, + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + isAlert: true, + }; + const wrapper = mount( ); + const alertsWrapper = mount( + + + + ); + describe('rendering', () => { test('should match snapshot', () => { const shallowWrap = shallow(); @@ -65,4 +73,27 @@ describe('EventDetails', () => { ).toEqual('Table'); }); }); + + describe('alerts tabs', () => { + ['Summary', 'Table', 'JSON View'].forEach((tab) => { + test(`it renders the ${tab} tab`, () => { + expect( + alertsWrapper + .find('[data-test-subj="eventDetails"]') + .find('[role="tablist"]') + .containsMatchingElement({tab}) + ).toBeTruthy(); + }); + }); + + test('the Summary tab is selected by default', () => { + expect( + alertsWrapper + .find('[data-test-subj="eventDetails"]') + .find('.euiTab-isSelected') + .first() + .text() + ).toEqual('Summary'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 92c3ff9b9fa97..291893fe682b4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EuiTabbedContent, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -13,17 +13,20 @@ import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/ti import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; +import { SummaryView } from './summary_view'; -export type View = EventsViewType.tableView | EventsViewType.jsonView; +export type View = EventsViewType.tableView | EventsViewType.jsonView | EventsViewType.summaryView; export enum EventsViewType { tableView = 'table-view', jsonView = 'json-view', + summaryView = 'summary-view', } interface Props { browserFields: BrowserFields; data: TimelineEventsDetailsItem[]; id: string; + isAlert: boolean; view: EventsViewType; onViewSelected: (selected: EventsViewType) => void; timelineId: string; @@ -50,13 +53,33 @@ const EventDetailsComponent: React.FC = ({ view, onViewSelected, timelineId, + isAlert, }) => { - const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [ - onViewSelected, - ]); + const handleTabClick = useCallback((e) => onViewSelected(e.id), [onViewSelected]); + const alerts = useMemo( + () => [ + { + id: EventsViewType.summaryView, + name: i18n.SUMMARY, + content: ( + <> + + + + ), + }, + ], + [data, id, browserFields, timelineId] + ); const tabs: EuiTabbedContentTab[] = useMemo( () => [ + ...(isAlert ? alerts : []), { id: EventsViewType.tableView, name: i18n.TABLE, @@ -83,10 +106,10 @@ const EventDetailsComponent: React.FC = ({ ), }, ], - [browserFields, data, id, timelineId] + [alerts, browserFields, data, id, isAlert, timelineId] ); - const selectedTab = view === EventsViewType.tableView ? tabs[0] : tabs[1]; + const selectedTab = useMemo(() => tabs.find((t) => t.id === view) ?? tabs[0], [tabs, view]); return ( ( const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const items = useMemo( () => - sortBy(data, ['field']).map((item) => ({ + sortBy(['field'], data).map((item) => ({ ...item, ...fieldsByName[item.field], valuesConcatenated: item.values != null ? item.values.join() : '', @@ -90,6 +90,19 @@ export const EventFieldsBrowser = React.memo( return getColumnHeaders(columns, browserFields); }); + const getLinkValue = useCallback( + (field: string) => { + const linkField = (columnHeaders.find((col) => col.id === field) ?? {}).linkField; + if (!linkField) { + return null; + } + const linkFieldData = (data ?? []).find((d) => d.field === linkField); + const linkFieldValue = getOr(null, 'originalValue', linkFieldData); + return Array.isArray(linkFieldValue) ? linkFieldValue[0] : linkFieldValue; + }, + [data, columnHeaders] + ); + const toggleColumn = useCallback( (column: ColumnHeaderOptions) => { if (columnHeaders.some((c) => c.id === column.id)) { @@ -126,8 +139,17 @@ export const EventFieldsBrowser = React.memo( onUpdateColumns, contextId: timelineId, toggleColumn, + getLinkValue, }), - [browserFields, columnHeaders, eventId, onUpdateColumns, timelineId, toggleColumn] + [ + browserFields, + columnHeaders, + eventId, + onUpdateColumns, + timelineId, + toggleColumn, + getLinkValue, + ] ); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx index 0cf158c8ea90b..da93670d647a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx @@ -54,6 +54,9 @@ describe('JSON View', () => { packets: 4, port: 902, }, + event: { + kind: 'event', + }, }; expect(buildJsonView(mockDetailItemData)).toEqual(expectedData); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index bf548d04e780b..2944a15cbeb93 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -16,8 +16,10 @@ interface Props { data: TimelineEventsDetailsItem[]; } -const StyledEuiCodeEditor = styled(EuiCodeEditor)` - flex: 1; +const EuiCodeEditorContainer = styled.div` + .euiCodeEditorWrapper { + position: absolute; + } `; const EDITOR_SET_OPTIONS = { fontSize: '12px' }; @@ -34,19 +36,29 @@ export const JsonView = React.memo(({ data }) => { ); return ( - + + + ); }); JsonView.displayName = 'JsonView'; export const buildJsonView = (data: TimelineEventsDetailsItem[]) => - data.reduce((accumulator, item) => set(item.field, item.originalValue, accumulator), {}); + data.reduce( + (accumulator, item) => + set( + item.field, + Array.isArray(item.originalValue) ? item.originalValue.join() : item.originalValue, + accumulator + ), + {} + ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx new file mode 100644 index 0000000000000..dec1bd9f3ac69 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { waitFor } from '@testing-library/react'; + +import { SummaryViewComponent } from './summary_view'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; + +import { TestProviders } from '../../mock'; +import { mockBrowserFields } from '../../containers/source/mock'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => { + return { + useRuleAsync: jest.fn(), + }; +}); + +const props = { + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + browserFields: mockBrowserFields, + eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + timelineId: 'detections-page', +}; + +describe('SummaryViewComponent', () => { + const mount = useMountAppended(); + + beforeEach(() => { + jest.clearAllMocks(); + (useRuleAsync as jest.Mock).mockReturnValue({ + rule: { + note: 'investigation guide', + }, + }); + }); + test('render correct items', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true); + }); + + test('render investigation guide', async () => { + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(true); + }); + }); + + test("render no investigation guide if it doesn't exist", async () => { + (useRuleAsync as jest.Mock).mockReturnValue({ + rule: { + note: null, + }, + }); + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx new file mode 100644 index 0000000000000..860bf13908855 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, getOr } from 'lodash/fp'; +import { + EuiTitle, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiInMemoryTable, + EuiBasicTableColumn, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; +import * as i18n from './translations'; +import { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { + ALERTS_HEADERS_RISK_SCORE, + ALERTS_HEADERS_RULE, + ALERTS_HEADERS_SEVERITY, +} from '../../../detections/components/alerts_table/translations'; +import { + IP_FIELD_TYPE, + SIGNAL_RULE_NAME_FIELD_NAME, +} from '../../../timelines/components/timeline/body/renderers/constants'; +import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip'; +import { LineClamp } from '../line_clamp'; +import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; + +interface SummaryRow { + title: string; + description: { + contextId: string; + eventId: string; + fieldName: string; + value: string; + fieldType: string; + linkValue: string | undefined; + }; +} +type Summary = SummaryRow[]; + +const fields = [ + { id: 'signal.status' }, + { id: '@timestamp' }, + { + id: SIGNAL_RULE_NAME_FIELD_NAME, + linkField: 'signal.rule.id', + label: ALERTS_HEADERS_RULE, + }, + { id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY }, + { id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, + { id: 'host.name' }, + { id: 'user.name' }, + { id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, + { id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` + .euiTableHeaderCell { + border: none; + } + .euiTableRowCell { + border: none; + } +`; + +const StyledEuiDescriptionList = styled(EuiDescriptionList)` + padding: 24px 4px 4px; +`; + +const getTitle = (title: SummaryRow['title']) => ( + +
{title}
+
+); + +getTitle.displayName = 'getTitle'; + +const getDescription = ({ + contextId, + eventId, + fieldName, + value, + fieldType = '', + linkValue, +}: SummaryRow['description']) => ( + +); + +const getSummary = ({ + data, + browserFields, + timelineId, + eventId, +}: { + data: TimelineEventsDetailsItem[]; + browserFields: BrowserFields; + timelineId: string; + eventId: string; +}) => { + return data != null + ? fields.reduce((acc, item) => { + const field = data.find((d) => d.field === item.id); + if (!field) { + return acc; + } + const linkValueField = + item.linkField != null && data.find((d) => d.field === item.linkField); + const linkValue = getOr(null, 'originalValue.0', linkValueField); + const value = getOr(null, 'originalValue.0', field); + const category = field.category; + const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string; + const description = { + contextId: timelineId, + eventId, + fieldName: item.id, + value, + fieldType: item.fieldType ?? fieldType, + linkValue: linkValue ?? undefined, + }; + + return [ + ...acc, + { + title: item.label ?? item.id, + description, + }, + ]; + }, []) + : []; +}; + +const summaryColumns: Array> = [ + { + field: 'title', + truncateText: false, + render: getTitle, + width: '120px', + name: '', + }, + { + field: 'description', + truncateText: false, + render: getDescription, + name: '', + }, +]; + +export const SummaryViewComponent: React.FC<{ + browserFields: BrowserFields; + data: TimelineEventsDetailsItem[]; + eventId: string; + timelineId: string; +}> = ({ data, eventId, timelineId, browserFields }) => { + const ruleId = useMemo(() => { + const item = data.find((d) => d.field === 'signal.rule.id'); + return Array.isArray(item?.originalValue) + ? item?.originalValue[0] + : item?.originalValue ?? null; + }, [data]); + const { rule: maybeRule } = useRuleAsync(ruleId); + const summaryList = useMemo(() => getSummary({ browserFields, data, eventId, timelineId }), [ + browserFields, + data, + eventId, + timelineId, + ]); + + return ( + <> + + {maybeRule?.note && ( + + {i18n.INVESTIGATION_GUIDE} + + + + + )} + + ); +}; + +export const SummaryView = React.memo(SummaryViewComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 19e71e0f37da6..76ae2cd4a88a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -6,6 +6,17 @@ import { i18n } from '@kbn/i18n'; +export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summary', { + defaultMessage: 'Summary', +}); + +export const INVESTIGATION_GUIDE = i18n.translate( + 'xpack.securitySolution.alertDetails.summary.investigationGuide', + { + defaultMessage: 'Investigation guide', + } +); + export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', { defaultMessage: 'Table', }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx index b3a838ab088df..48bdebbc0aa4f 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx @@ -4,19 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import { some } from 'lodash/fp'; +import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; -import { timelineActions } from '../../../timelines/store/timeline'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { ExpandableEvent, ExpandableEventTitle, } from '../../../timelines/components/timeline/expandable_event'; import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { useTimelineEventsDetails } from '../../../timelines/containers/details'; +import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; const StyledEuiFlyout = styled(EuiFlyout)` z-index: ${({ theme }) => theme.eui.euiZLevel7}; @@ -28,27 +31,33 @@ interface EventDetailsFlyoutProps { timelineId: string; } -const emptyExpandedEvent = {}; - const EventDetailsFlyoutComponent: React.FC = ({ browserFields, docValueFields, timelineId, }) => { const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? emptyExpandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent ?? {} ); const handleClearSelection = useCallback(() => { - dispatch( - timelineActions.toggleExpandedEvent({ - timelineId, - event: emptyExpandedEvent, - }) - ); + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); }, [dispatch, timelineId]); + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent?.indexName ?? '', + eventId: expandedEvent?.eventId ?? '', + skip: !expandedEvent.eventId, + }); + + const isAlert = useMemo( + () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), + [detailsData] + ); + if (!expandedEvent.eventId) { return null; } @@ -56,13 +65,15 @@ const EventDetailsFlyoutComponent: React.FC = ({ return ( - + diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 8710503924d84..5e5bdebffa182 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -65,6 +65,7 @@ const eventsViewerDefaultProps = { deletedEventIds: [], docValueFields: [], end: to, + expandedEvent: {}, filters: [], id: TimelineId.detectionsPage, indexNames: mockIndexNames, @@ -78,6 +79,7 @@ const eventsViewerDefaultProps = { query: '', language: 'kql', }, + handleCloseExpandedEvent: jest.fn(), start: from, sort: [ { diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index c578e017c4d95..4aa8361a0b8e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -6,13 +6,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { Direction } from '../../../../common/search_strategy'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { useTimelineEvents } from '../../../timelines/containers'; +import { timelineActions } from '../../../timelines/store/timeline'; import { useKibana } from '../../lib/kibana'; import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; import { HeaderSection } from '../header_section'; @@ -34,8 +36,8 @@ import { import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; -import { useFullScreen } from '../../containers/use_full_screen'; -import { TimelineId } from '../../../../common/types/timeline'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import { TimelineExpandedEvent, TimelineId } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px @@ -101,6 +103,7 @@ interface Props { deletedEventIds: Readonly; docValueFields: DocValueFields[]; end: string; + expandedEvent: TimelineExpandedEvent; filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; @@ -128,6 +131,7 @@ const EventsViewerComponent: React.FC = ({ deletedEventIds, docValueFields, end, + expandedEvent, filters, headerFilterGroup, id, @@ -145,7 +149,8 @@ const EventsViewerComponent: React.FC = ({ utilityBar, graphEventId, }) => { - const { globalFullScreen, timelineFullScreen } = useFullScreen(); + const dispatch = useDispatch(); + const { globalFullScreen } = useGlobalFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const [isQueryLoading, setIsQueryLoading] = useState(false); @@ -175,6 +180,9 @@ const EventsViewerComponent: React.FC = ({ [justTitle] ); + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -226,6 +234,13 @@ const EventsViewerComponent: React.FC = ({ skip: !canQueryTimeline, }); + useEffect(() => { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; + dispatch(timelineActions.toggleExpandedEvent({ timelineId: id })); + } + }, [combinedQueries, dispatch, id]); + const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), [deletedEventIds.length, totalCount] @@ -271,7 +286,7 @@ const EventsViewerComponent: React.FC = ({ id={!resolverIsShowing(graphEventId) ? id : undefined} height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} subtitle={utilityBar ? undefined : subtitle} - title={timelineFullScreen ? justTitle : titleWithExitFullScreen} + title={globalFullScreen ? titleWithExitFullScreen : justTitle} > {HeaderSectionContent} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index ec3cbbdef98ad..3272b0306f9c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -16,7 +16,7 @@ import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/tim import { Filter } from '../../../../../../../src/plugins/data/public'; import { EventsViewer } from './events_viewer'; import { InspectButtonContainer } from '../inspect'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; import { EventDetailsFlyout } from './event_details_flyout'; @@ -51,6 +51,7 @@ const StatefulEventsViewerComponent: React.FC = ({ deletedEventIds, deleteEventQuery, end, + expandedEvent, excludedRowRendererIds, filters, headerFilterGroup, @@ -77,7 +78,7 @@ const StatefulEventsViewerComponent: React.FC = ({ selectedPatterns, loading: isLoadingIndexPattern, } = useSourcererScope(scopeId); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); useEffect(() => { if (createTimeline != null) { @@ -111,6 +112,7 @@ const StatefulEventsViewerComponent: React.FC = ({ dataProviders={dataProviders!} deletedEventIds={deletedEventIds} end={end} + expandedEvent={expandedEvent} isLoadingIndexPattern={isLoadingIndexPattern} filters={globalFilters} headerFilterGroup={headerFilterGroup} @@ -142,27 +144,29 @@ const makeMapStateToProps = () => { const getInputsTimeline = inputsSelectors.getTimelineSelector(); const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getEvents = timelineSelectors.getEventsByIdSelector(); const getTimeline = timelineSelectors.getTimelineByIdSelector(); const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => { const input: inputsModel.InputsRange = getInputsTimeline(state); - const events: TimelineModel = getEvents(state, id) ?? defaultModel; + const timeline: TimelineModel = getTimeline(state, id) ?? defaultModel; const { columns, dataProviders, deletedEventIds, excludedRowRendererIds, + expandedEvent, + graphEventId, itemsPerPage, itemsPerPageOptions, kqlMode, sort, showCheckboxes, - } = events; + } = timeline; return { columns, dataProviders, deletedEventIds, + expandedEvent, excludedRowRendererIds, filters: getGlobalFiltersQuerySelector(state), id, @@ -175,7 +179,7 @@ const makeMapStateToProps = () => { showCheckboxes, // Used to determine whether the footer should show (since it is hidden if the graph is showing.) // `getTimeline` actually returns `TimelineModel | undefined` - graphEventId: (getTimeline(state, id) as TimelineModel | undefined)?.graphEventId, + graphEventId, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx index 8b5e0555b57b4..badb29e165573 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx @@ -7,7 +7,7 @@ import React, { useCallback } from 'react'; import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { isEqlRule } from '../../../../../common/detection_engine/utils'; +import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils'; import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { FieldComponent } from '../../autocomplete/field'; @@ -149,7 +149,7 @@ export const BuilderEntryItem: React.FC = ({ entry, listType, entry.field != null && entry.field.type === 'boolean', - isFirst && !isEqlRule(ruleType) + isFirst && !isEqlRule(ruleType) && !isThresholdRule(ruleType) ); const comboBox = ( { - const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const exitFullScreen = useCallback(() => { setGlobalFullScreen(false); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 7e8c93e86376a..e8a17d78644df 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { OutPortal } from 'react-reverse-portal'; import { navTabs } from '../../../app/home/home_navigations'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen'; import { SecurityPageName } from '../../../app/types'; import { getAppOverviewUrl } from '../link_to'; import { MlPopover } from '../ml_popover/ml_popover'; @@ -68,7 +68,8 @@ export const HeaderGlobal = React.memo( forwardRef( ({ hideDetectionEngine = false, isFixed = true }, ref) => { const { globalHeaderPortalNode } = useGlobalHeaderPortal(); - const { globalFullScreen, timelineFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen } = useTimelineFullScreen(); const search = useGetUrlSearch(navTabs.overview); const { application, http } = useKibana().services; const { navigateToApp } = application; diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx new file mode 100644 index 0000000000000..1b59b174add4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import styled from 'styled-components'; +import * as i18n from './translations'; + +const LINE_CLAMP = 3; +const LINE_CLAMP_HEIGHT = 4.5; + +const StyledLineClamp = styled.div` + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; + max-height: ${`${LINE_CLAMP_HEIGHT}em`}; + height: ${`${LINE_CLAMP_HEIGHT}em`}; +`; + +const ReadMore = styled(EuiButtonEmpty)` + span.euiButtonContent { + padding: 0; + } +`; + +const LineClampComponent: React.FC<{ content?: string | null }> = ({ content }) => { + const [isOverflow, setIsOverflow] = useState(null); + const [isExpanded, setIsExpanded] = useState(null); + const descriptionRef = useRef(null); + const toggleReadMore = useCallback(() => { + setIsExpanded((prevState) => !prevState); + }, []); + + useEffect(() => { + if (content != null && descriptionRef?.current?.clientHeight != null) { + if ( + (descriptionRef?.current?.scrollHeight ?? 0) > (descriptionRef?.current?.clientHeight ?? 0) + ) { + setIsOverflow(true); + } + + if ( + ((content == null || descriptionRef?.current?.scrollHeight) ?? 0) <= + (descriptionRef?.current?.clientHeight ?? 0) + ) { + setIsOverflow(false); + } + } + }, [content]); + + if (!content) { + return null; + } + + return ( + <> + {isExpanded ? ( +

{content}

+ ) : isOverflow == null || isOverflow === true ? ( + {content} + ) : ( + {content} + )} + {isOverflow && ( + + {isExpanded ? i18n.READ_LESS : i18n.READ_MORE} + + )} + + ); +}; + +export const LineClamp = React.memo(LineClampComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts b/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts new file mode 100644 index 0000000000000..e332d1a2d2b5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const READ_MORE = i18n.translate('xpack.securitySolution.alertDetails.summary.readMore', { + defaultMessage: 'Read More', +}); + +export const READ_LESS = i18n.translate('xpack.securitySolution.alertDetails.summary.readLess', { + defaultMessage: 'Read Less', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index d6cbd31e86ddb..3964acbc9b766 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -327,6 +327,20 @@ const ReputationLinkComponent: React.FC<{ [ipReputationLinksSetting, domain, defaultNameMapping, allItemsLimit] ); + const renderCallback = useCallback( + (rowItem) => + isReputationLink(rowItem) && ( + + <>{rowItem.name ?? domain} + + ), + [allItemsLimit, domain, overflowIndexStart] + ); + return ipReputationLinks?.length > 0 ? (
{ - return ( - isReputationLink(rowItem) && ( - - <>{rowItem.name ?? domain} - - ) - ); - }} + render={renderCallback} moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT} overflowIndexStart={overflowIndexStart} /> diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx index 4dccba08590a4..f2f802124d721 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx @@ -16,4 +16,6 @@ export const mlModules: string[] = [ 'siem_packetbeat', 'siem_winlogbeat', 'siem_winlogbeat_auth', + 'security_linux', + 'security_windows', ]; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index ac03e6c5c0018..fb4cd95ae36f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -58,20 +58,17 @@ export interface Props extends Pick = ({ combinedQueries, defaultView, deleteQuery, - filters = NO_FILTERS, + filters, field, from, indexPattern, indexNames, options, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, timelineId, @@ -132,7 +129,6 @@ const TopNComponent: React.FC = ({ filters={filters} from={from} headerChildren={headerChildren} - indexPattern={indexPattern} onlyField={field} query={query} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 23f9a8a6bce01..e1f6310644be0 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; import { CommonProps } from '@elastic/eui'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { gutterTimeline } from '../../lib/helpers'; import { AppGlobalStyle } from '../page/index'; @@ -53,7 +53,7 @@ const WrapperPageComponent: React.FC = ({ noTimeline, ...otherProps }) => { - const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); useEffect(() => { setGlobalFullScreen(false); // exit full screen mode on page load }, [setGlobalFullScreen]); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index b7938a5f3d755..577d7aa78e35c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; @@ -19,7 +19,8 @@ export const useInitSourcerer = ( scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default ) => { const dispatch = useDispatch(); - + const initialTimelineSourcerer = useRef(true); + const initialDetectionSourcerer = useRef(true); const { loading: loadingSignalIndex, isSignalIndexExists, signalIndexName } = useUserInfo(); const getConfigIndexPatternsSelector = useMemo( () => sourcererSelectors.configIndexPatternsSelector(), @@ -27,6 +28,12 @@ export const useInitSourcerer = ( ); const ConfigIndexPatterns = useDeepEqualSelector(getConfigIndexPatternsSelector); + const getSignalIndexNameSelector = useMemo( + () => sourcererSelectors.signalIndexNameSelector(), + [] + ); + const signalIndexNameSelector = useDeepEqualSelector(getSignalIndexNameSelector); + const getTimelineSelector = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const activeTimeline = useDeepEqualSelector((state) => getTimelineSelector(state, TimelineId.active) @@ -36,42 +43,71 @@ export const useInitSourcerer = ( useIndexFields(SourcererScopeName.timeline); useEffect(() => { - if (!loadingSignalIndex && signalIndexName != null) { + if (!loadingSignalIndex && signalIndexName != null && signalIndexNameSelector == null) { dispatch(sourcererActions.setSignalIndexName({ signalIndexName })); } - }, [dispatch, loadingSignalIndex, signalIndexName]); + }, [dispatch, loadingSignalIndex, signalIndexName, signalIndexNameSelector]); // Related to timeline useEffect(() => { if ( !loadingSignalIndex && signalIndexName != null && - (activeTimeline == null || (activeTimeline != null && activeTimeline.savedObjectId == null)) + signalIndexNameSelector == null && + (activeTimeline == null || + (activeTimeline != null && activeTimeline.savedObjectId == null)) && + initialTimelineSourcerer.current ) { + initialTimelineSourcerer.current = false; dispatch( sourcererActions.setSelectedIndexPatterns({ id: SourcererScopeName.timeline, selectedPatterns: [...ConfigIndexPatterns, signalIndexName], }) ); + } else if (signalIndexNameSelector != null && initialTimelineSourcerer.current) { + initialTimelineSourcerer.current = false; + dispatch( + sourcererActions.setSelectedIndexPatterns({ + id: SourcererScopeName.timeline, + selectedPatterns: [...ConfigIndexPatterns, signalIndexNameSelector], + }) + ); } - }, [activeTimeline, ConfigIndexPatterns, dispatch, loadingSignalIndex, signalIndexName]); + }, [ + activeTimeline, + ConfigIndexPatterns, + dispatch, + loadingSignalIndex, + signalIndexName, + signalIndexNameSelector, + ]); // Related to the detection page useEffect(() => { if ( scopeId === SourcererScopeName.detections && isSignalIndexExists && - signalIndexName != null + signalIndexName != null && + initialDetectionSourcerer.current ) { + initialDetectionSourcerer.current = false; dispatch( sourcererActions.setSelectedIndexPatterns({ id: scopeId, selectedPatterns: [signalIndexName], }) ); + } else if (signalIndexNameSelector != null && initialTimelineSourcerer.current) { + initialDetectionSourcerer.current = false; + dispatch( + sourcererActions.setSelectedIndexPatterns({ + id: scopeId, + selectedPatterns: [signalIndexNameSelector], + }) + ); } - }, [dispatch, isSignalIndexExists, scopeId, signalIndexName]); + }, [dispatch, isSignalIndexExists, scopeId, signalIndexName, signalIndexNameSelector]); }; export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName.default) => { diff --git a/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx index 8357a9d22739e..874005bf07428 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx @@ -28,13 +28,20 @@ export const resetScroll = () => { }, 0); }; -export const useFullScreen = () => { +interface GlobalFullScreen { + globalFullScreen: boolean; + setGlobalFullScreen: (fullScreen: boolean) => void; +} + +interface TimelineFullScreen { + timelineFullScreen: boolean; + setTimelineFullScreen: (fullScreen: boolean) => void; +} + +export const useGlobalFullScreen = (): GlobalFullScreen => { const dispatch = useDispatch(); const globalFullScreen = useShallowEqualSelector(inputsSelectors.globalFullScreenSelector) ?? false; - const timelineFullScreen = - useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false; - const setGlobalFullScreen = useCallback( (fullScreen: boolean) => { if (fullScreen) { @@ -49,21 +56,31 @@ export const useFullScreen = () => { }, [dispatch] ); + const memoizedReturn = useMemo( + () => ({ + globalFullScreen, + setGlobalFullScreen, + }), + [globalFullScreen, setGlobalFullScreen] + ); + return memoizedReturn; +}; + +export const useTimelineFullScreen = (): TimelineFullScreen => { + const dispatch = useDispatch(); + const timelineFullScreen = + useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false; const setTimelineFullScreen = useCallback( (fullScreen: boolean) => dispatch(inputsActions.setFullScreen({ id: 'timeline', fullScreen })), [dispatch] ); - const memoizedReturn = useMemo( () => ({ - globalFullScreen, - setGlobalFullScreen, - setTimelineFullScreen, timelineFullScreen, + setTimelineFullScreen, }), - [globalFullScreen, setGlobalFullScreen, setTimelineFullScreen, timelineFullScreen] + [timelineFullScreen, setTimelineFullScreen] ); - return memoizedReturn; }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx new file mode 100644 index 0000000000000..393c844bf5098 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { createPortalNode, OutPortal } from 'react-reverse-portal'; + +/** + * A singleton portal for rendering content in the global header + */ +const timelineEventsCountPortalNodeSingleton = createPortalNode(); + +export const useTimelineEventsCountPortal = () => { + const [timelineEventsCountPortalNode] = useState(timelineEventsCountPortalNodeSingleton); + + return { timelineEventsCountPortalNode }; +}; + +export const TimelineEventsCountBadge = React.memo(() => { + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + + return ; +}); + +TimelineEventsCountBadge.displayName = 'TimelineEventsCountBadge'; diff --git a/x-pack/plugins/security_solution/public/common/lib/note/index.ts b/x-pack/plugins/security_solution/public/common/lib/note/index.ts index b803cade326ad..19821753a6cdc 100644 --- a/x-pack/plugins/security_solution/public/common/lib/note/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/note/index.ts @@ -8,6 +8,7 @@ export interface Note { /** When the note was created */ created: Date; + eventId?: string | null; /** Uniquely identifies the note */ id: string; /** When not `null`, this represents the last edit */ @@ -18,5 +19,6 @@ export interface Note { user: string; /** SaveObjectID for note */ saveObjectId: string | null | undefined; + timelineId?: string | null; version: string | null | undefined; } diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts index c5d881c540eec..f074495e65b64 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts @@ -109,4 +109,9 @@ export const mockDetailItemData: TimelineEventsDetailsItem[] = [ originalValue: 902, values: ['902'], }, + { + field: 'event.kind', + originalValue: 'event', + values: ['event'], + }, ]; diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 59d783107e587..9808bbb1faed3 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keys } from 'lodash/fp'; +import { keys, values } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import { createSelector } from 'reselect'; import { Note } from '../../lib/note'; import { ErrorModel, NotesById } from './model'; import { State } from '../types'; +import { TimelineResultNote } from '../../../timelines/components/open_timeline/types'; const selectNotesById = (state: State): NotesById => state.app.notesById; @@ -25,6 +26,16 @@ export const getNotes = memoizeOne((notesById: NotesById, noteIds: string[]): No }, []) ); +export const getNotesAsCommentsList = (notesById: NotesById): TimelineResultNote[] => + values(notesById).map((note) => ({ + eventId: note.eventId, + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })); + export const selectNotesByIdSelector = createSelector( selectNotesById, (notesById: NotesById) => notesById @@ -33,4 +44,7 @@ export const selectNotesByIdSelector = createSelector( export const notesByIdsSelector = () => createSelector(selectNotesById, (notesById: NotesById) => notesById); +export const selectNotesAsCommentsListSelector = () => + createSelector(selectNotesById, getNotesAsCommentsList); + export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts index 9feb2f87d7e08..47a63ec843073 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { State } from '../types'; import { InputsModel, InputsRange, GlobalQuery } from './model'; @@ -64,21 +65,18 @@ export const timelineQueryByIdSelector = () => export const globalSelector = () => createSelector(selectGlobal, (global) => global); +const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; + export const globalQuerySelector = () => - createSelector( - selectGlobal, - (global) => - global.query || { - query: '', - language: 'kuery', - } - ); + createSelector(selectGlobal, (global) => global.query || DEFAULT_QUERY); export const globalSavedQuerySelector = () => createSelector(selectGlobal, (global) => global.savedQuery || null); +const NO_FILTERS: Filter[] = []; + export const globalFiltersQuerySelector = () => - createSelector(selectGlobal, (global) => global.filters || []); + createSelector(selectGlobal, (global) => global.filters || NO_FILTERS); export const getTimelineSelector = () => createSelector(selectTimeline, (timeline) => timeline); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 3e47478b783eb..fcf7dfec0f2a4 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -20,6 +20,7 @@ describe('createInitialState', () => { { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: ['auditbeat-*', 'filebeat'], + signalIndexName: 'siem-signals-default', } ); @@ -32,6 +33,7 @@ describe('createInitialState', () => { { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: [], + signalIndexName: 'siem-signals-default', } ); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 8d528f4279955..f48bf31e62575 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -34,7 +34,12 @@ export const createInitialState = ( { kibanaIndexPatterns, configIndexPatterns, - }: { kibanaIndexPatterns: KibanaIndexPatterns; configIndexPatterns: string[] } + signalIndexName, + }: { + kibanaIndexPatterns: KibanaIndexPatterns; + configIndexPatterns: string[]; + signalIndexName: string | null; + } ): PreloadedState => { const preloadedState: PreloadedState = { app: initialAppState, @@ -52,6 +57,7 @@ export const createInitialState = ( }, kibanaIndexPatterns, configIndexPatterns, + signalIndexName, }, }; return preloadedState; diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index 599cddb605148..88694c66bf960 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -91,16 +91,23 @@ export const getSourcererScopeSelector = () => { : selectedPatterns; }); + const getIndexPattern = memoizeOne( + (indexPattern, title) => ({ + ...indexPattern, + title, + }), + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length + ); + const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { const scope = getScopeIdSelector(state, scopeId); const selectedPatterns = getSelectedPatterns(scope.selectedPatterns.sort().join()); + const indexPattern = getIndexPattern(scope.indexPattern, selectedPatterns.join()); + return { ...scope, selectedPatterns, - indexPattern: { - ...scope.indexPattern, - title: selectedPatterns.join(), - }, + indexPattern, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx index bb8cc2267249f..e2ab339fbaa83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx @@ -10,6 +10,8 @@ import { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } fro import { AlertSearchResponse } from '../../containers/detection_engine/alerts/types'; import * as i18n from './translations'; +const EMPTY_ALERTS_DATA: HistogramData[] = []; + export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggregation> | null) => { const groupBuckets: AlertsGroupBucket[] = alertsData?.aggregations?.alertsByGrouping?.buckets ?? []; @@ -25,7 +27,7 @@ export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggre g: group, })), ]; - }, []); + }, EMPTY_ALERTS_DATA); }; export const getAlertsHistogramQuery = ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 9eb0a97a1c9a2..f044eb3799c11 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -19,8 +19,7 @@ import { getOr } from 'lodash/fp'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; -import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { isThresholdRule } from '../../../../../common/detection_engine/utils'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; import { EventsTd, EventsTdContent } from '../../../../timelines/components/timeline/styles'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers'; @@ -323,12 +322,6 @@ const AlertContextMenuComponent: React.FC = ({ setOpenAddExceptionModal('detection'); }, [closePopover]); - const areExceptionsAllowed = useMemo((): boolean => { - const ruleTypes = getOr([], 'signal.rule.type', ecsRowData); - const [ruleType] = ruleTypes as Type[]; - return !isThresholdRule(ruleType); - }, [ecsRowData]); - // eslint-disable-next-line react-hooks/exhaustive-deps const addExceptionComponent = ( = ({ data-test-subj="add-exception-menu-item" id="addException" onClick={handleAddExceptionClick} - disabled={!canUserCRUD || !hasIndexWrite || !areExceptionsAllowed} + disabled={!canUserCRUD || !hasIndexWrite} > {i18n.ACTION_ADD_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index 8960b7a76660b..d7306e26d3cfe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -89,7 +89,6 @@ const InvestigateInTimelineActionComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg deleted file mode 100644 index 716fff726293c..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index aa1db1e31170e..d184ed8d06f11 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -19,7 +19,6 @@ import { FieldHook } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; import { MlCardDescription } from './ml_card_description'; -import EqlSearchIcon from './eql_search_icon.svg'; interface SelectRuleTypeProps { describedByIds: string[]; @@ -156,7 +155,7 @@ export const SelectRuleType: React.FC = ({ title={i18n.EQL_TYPE_TITLE} titleSize="xs" description={i18n.EQL_TYPE_DESCRIPTION} - icon={} + icon={} selectable={eqlSelectableConfig} layout="horizontal" textAlign="left" diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 65993902d4c28..6fa93f9fb4139 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -8,7 +8,6 @@ import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; -import { isThresholdRule } from '../../../../../common/detection_engine/utils'; import { RuleStepProps, RuleStep, @@ -75,8 +74,6 @@ const StepAboutRuleComponent: FC = ({ const [severityValue, setSeverityValue] = useState(initialState.severity.value); const [indexPatternLoading, { indexPatterns }] = useFetchIndex(defineRuleData?.index ?? []); - const canUseExceptions = defineRuleData?.ruleType && !isThresholdRule(defineRuleData.ruleType); - const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, @@ -282,7 +279,7 @@ const StepAboutRuleComponent: FC = ({ idAria: 'detectionEngineStepAboutRuleAssociatedToEndpointList', 'data-test-subj': 'detectionEngineStepAboutRuleAssociatedToEndpointList', euiFieldProps: { - disabled: isLoading || !canUseExceptions, + disabled: isLoading, }, }} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 13be87846df80..dda35ad26a685 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -37,7 +37,7 @@ import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unau import * as i18n from './translations'; import { LinkButton } from '../../../common/components/links'; import { useFormatUrl } from '../../../common/components/link_to'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../../../hosts/pages/display'; import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../timelines/store/timeline'; @@ -61,7 +61,7 @@ const DetectionEnginePageComponent = () => { const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const [ { loading: userInfoLoading, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 62f0d12fd67b1..3986e02b5b9b9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -79,10 +79,9 @@ import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; import { DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; -import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports'; -import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../../../timelines/store/timeline'; @@ -104,7 +103,6 @@ enum RuleDetailTabs { } const getRuleDetailsTabs = (rule: Rule | null) => { - const canUseExceptions = rule && !isThresholdRule(rule.type); return [ { id: RuleDetailTabs.alerts, @@ -115,7 +113,7 @@ const getRuleDetailsTabs = (rule: Rule | null) => { { id: RuleDetailTabs.exceptions, name: i18n.EXCEPTIONS_TAB, - disabled: !canUseExceptions, + disabled: false, dataTestSubj: 'exceptionsTab', }, { @@ -180,7 +178,7 @@ const RuleDetailsPageComponent = () => { const mlCapabilities = useMlCapabilities(); const history = useHistory(); const { formatUrl } = useFormatUrl(SecurityPageName.detections); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 58474f05bb2b9..7eef46a480707 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -43,7 +43,7 @@ import { HostDetailsProps } from './types'; import { type } from './utils'; import { getHostDetailsPageFilters } from './helpers'; import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../display'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineId } from '../../../../common/types/timeline'; @@ -68,7 +68,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const capabilities = useMlCapabilities(); const kibana = useKibana(); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index d54891ba573fd..52ec837a09eb6 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -20,7 +20,7 @@ import { SiemNavigation } from '../../common/components/navigation'; import { HostsKpiComponent } from '../components/kpi_hosts'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useFullScreen } from '../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { TimelineId } from '../../../common/types/timeline'; import { LastEventIndexKey } from '../../../common/search_strategy'; @@ -66,7 +66,7 @@ const HostsComponent = () => { const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const capabilities = useMlCapabilities(); const { uiSettings } = useKibana().services; const { tabName } = useParams<{ tabName: string }>(); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index e30071ec04f0c..1540ffd59f2de 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -16,7 +16,7 @@ import { MatrixHistogramConfigs, } from '../../../common/components/matrix_histogram/types'; import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; @@ -62,7 +62,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { initializeTimeline } = useManageTimeline(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); useEffect(() => { initializeTimeline({ id: TimelineId.hostsPageEvents, diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx index 701094cee88a2..4905fdc2e1f57 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx @@ -11,7 +11,7 @@ import { FormattedFieldValue } from '../../../timelines/components/timeline/body export const SOURCE_IP_FIELD_NAME = 'source.ip'; export const DESTINATION_IP_FIELD_NAME = 'destination.ip'; -const IP_FIELD_TYPE = 'ip'; +export const IP_FIELD_TYPE = 'ip'; /** * Renders text containing a draggable IP address (e.g. `source.ip`, diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index f9e30e30472d9..3a095cfb21f0e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -22,7 +22,7 @@ import { SiemNavigation } from '../../common/components/navigation'; import { NetworkKpiComponent } from '../components/kpi_network'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useFullScreen } from '../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { LastEventIndexKey } from '../../../common/search_strategy'; import { useKibana } from '../../common/lib/kibana'; @@ -62,7 +62,7 @@ const NetworkComponent = React.memo( const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const kibana = useKibana(); const { tabName } = useParams<{ tabName: string }>(); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 704506d9813d9..50b5ae9388fe5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -37,6 +37,10 @@ describe('Alerts by category', () => { indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; describe('before loading data', () => { beforeAll(async () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index 4ab72afc3fb45..a58b5cf315ec1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -35,26 +35,24 @@ import { LinkButton } from '../../../common/components/links'; const ID = 'alertsByCategoryOverview'; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const DEFAULT_STACK_BY = 'event.module'; interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; hideHeaderChildren?: boolean; indexPattern: IIndexPattern; indexNames: string[]; - query?: Query; + query: Query; } const AlertsByCategoryComponent: React.FC = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, hideHeaderChildren = false, indexPattern, indexNames, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx index 44cb7a65dbc5e..7e96ab8779304 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx @@ -21,11 +21,16 @@ describe('EventCounts', () => { const to = '2020-01-21T20:49:57.080Z'; const testProps = { + filters: [], from, indexNames: [], indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; test('it filters the `Host events` widget with a `host.name` `exists` filter', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx index 6e47de68221c7..af3c7ecf1f36d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { OverviewHost } from '../overview_host'; @@ -26,38 +26,52 @@ const HorizontalSpacer = styled(EuiFlexItem)` width: 24px; `; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; - interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; indexNames: string[]; indexPattern: IIndexPattern; - query?: Query; + query: Query; } const EventCountsComponent: React.FC = ({ - filters = NO_FILTERS, + filters, from, indexNames, indexPattern, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { - const kibana = useKibana(); + const { uiSettings } = useKibana().services; + + const hostFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterHostData], + }), + [filters, indexPattern, query, uiSettings] + ); + + const networkFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterNetworkData], + }), + [filters, indexPattern, uiSettings, query] + ); return ( = ({ { combinedQueries?: string; - filters?: Filter[]; + filters: Filter[]; headerChildren?: React.ReactNode; indexPattern: IIndexPattern; indexNames: string[]; onlyField?: string; - query?: Query; + query: Query; setAbsoluteRangeDatePickerTarget?: InputsModelId; showSpacer?: boolean; timelineId?: string; @@ -63,13 +61,13 @@ const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ const EventsByDatasetComponent: React.FC = ({ combinedQueries, deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, indexPattern, indexNames, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index f92f004bd2448..a74d7af7140b7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -84,33 +84,38 @@ const OverviewHostComponent: React.FC = ({ [goToHost, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewHost) ? ( + + ) : ( + <>{''} + ), + [formattedHostEventsCount, hostEventsCount, overviewHost] + ); + return ( - - ) : ( - <>{''} - ) - } - title={ - - } - > + <>{hostPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 178a752d1286f..fd4b7bbd386ba 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -89,34 +89,39 @@ const OverviewNetworkComponent: React.FC = ({ [goToNetwork, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewNetwork) ? ( + + ) : ( + <>{''} + ), + [formattedNetworkEventsCount, networkEventsCount, overviewNetwork] + ); + return ( <> - - ) : ( - <>{''} - ) - } - title={ - - } - > + {networkPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 34722fd147a99..432ad0642be9d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -11,19 +11,15 @@ import { AlertsHistogramPanel } from '../../../detections/components/alerts_hist import { alertsHistogramOptions } from '../../../detections/components/alerts_histogram_panel/config'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { Filter, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { InputsModelId } from '../../../common/store/inputs/constants'; import * as i18n from '../../pages/translations'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - interface Props extends Pick { filters?: Filter[]; headerChildren?: React.ReactNode; - indexPattern: IIndexPattern; /** Override all defaults, and only display this field */ onlyField?: string; query?: Query; @@ -33,11 +29,11 @@ interface Props extends Pick = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget = 'global', setQuery, timelineId, diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 0f34734ebf861..2e1a8d3a6e376 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -6,7 +6,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useState, useMemo } from 'react'; -import { Query, Filter } from 'src/plugins/data/public'; import styled from 'styled-components'; import { AlertsByCategory } from '../components/alerts_by_category'; @@ -33,9 +32,6 @@ import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; `; @@ -46,10 +42,8 @@ const OverviewComponent = () => { [] ); const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); - const query = useDeepEqualSelector((state) => getGlobalQuerySelector(state) ?? DEFAULT_QUERY); - const filters = useDeepEqualSelector( - (state) => getGlobalFiltersQuerySelector(state) ?? NO_FILTERS - ); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { from, deleteQuery, setQuery, to } = useGlobalTime(); const { indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); @@ -97,7 +91,6 @@ const OverviewComponent = () => { { }); }); + test('it returns the expected signal column settings', async () => { + const mockSelectedCategoryId = 'signal'; + const mockBrowserFieldsWithSignal = { + ...mockBrowserFields, + signal: { + fields: { + 'signal.rule.name': { + aggregatable: true, + category: 'signal', + description: 'rule name', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'signal.rule.name', + searchable: true, + type: 'string', + }, + }, + }, + }; + const toggleColumn = jest.fn(); + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="field-signal.rule.name-checkbox"]`) + .last() + .simulate('change', { + target: { checked: true }, + }); + + await waitFor(() => { + expect(toggleColumn).toBeCalledWith({ + columnHeaderType: 'not-filtered', + id: 'signal.rule.name', + width: 180, + }); + }); + }); + test('it renders the expected icon for a field', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap index bbf09856936ca..fced784f8d012 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap @@ -2,6 +2,7 @@ exports[`Flyout rendering it renders correctly against snapshot 1`] = ` `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 0b086610da82a..6a51c7180587f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -4,32 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiHealth, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { isEmpty } from 'lodash/fp'; import styled from 'styled-components'; +import { FormattedRelative } from '@kbn/i18n/react'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; +import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; import { timelineActions } from '../../../store/timeline'; +import * as i18n from './translations'; const ButtonWrapper = styled(EuiFlexItem)` flex-direction: row; align-items: center; `; +const EuiHealthStyled = styled(EuiHealth)` + display: block; +`; + interface ActiveTimelinesProps { timelineId: string; + timelineStatus: TimelineStatus; timelineTitle: string; timelineType: TimelineType; isOpen: boolean; + updated?: number; } +const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + > span { + padding: 0; + } +`; + const ActiveTimelinesComponent: React.FC = ({ timelineId, + timelineStatus, timelineType, timelineTitle, + updated, isOpen, }) => { const dispatch = useDispatch(); @@ -45,17 +62,48 @@ const ActiveTimelinesComponent: React.FC = ({ ? UNTITLED_TEMPLATE : UNTITLED_TIMELINE; + const tooltipContent = useMemo(() => { + if (timelineStatus === TimelineStatus.draft) { + return <>{i18n.UNSAVED}; + } + return ( + <> + {i18n.AUTOSAVED}{' '} + + + ); + }, [timelineStatus, updated]); + return ( - + - - {title} - + + + + + + + {title} + {!isOpen && ( + + + + )} + + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index e09eedcd34dd1..063e968a6c51a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -32,6 +32,8 @@ import { InspectButton } from '../../../../common/components/inspect'; import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; import * as commonI18n from '../../timeline/properties/translations'; +import { getTimelineStatusByIdSelector } from './selectors'; +import { TimelineTabs } from '../../../store/timeline/model'; // to hide side borders const StyledPanel = styled(EuiPanel)` @@ -49,9 +51,27 @@ interface FlyoutHeaderPanelProps { const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { dataProviders, kqlQuery, title, timelineType, show } = useDeepEqualSelector((state) => + const { + activeTab, + dataProviders, + kqlQuery, + title, + timelineType, + status: timelineStatus, + updated, + show, + } = useDeepEqualSelector((state) => pick( - ['dataProviders', 'kqlQuery', 'title', 'timelineType', 'show'], + [ + 'activeTab', + 'dataProviders', + 'kqlQuery', + 'status', + 'title', + 'timelineType', + 'updated', + 'show', + ], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -67,29 +87,33 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline return ( - + {show && ( - - - + {activeTab === TimelineTabs.query && ( + + + + )} = ({ timelineId const TimelineDescription = React.memo(TimelineDescriptionComponent); const TimelineStatusInfoComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const getTimelineStatus = useMemo(() => getTimelineStatusByIdSelector(), []); const { status: timelineStatus, updated } = useDeepEqualSelector((state) => - pick(['status', 'updated'], getTimeline(state, timelineId) ?? timelineDefaults) + getTimelineStatus(state, timelineId) ); const isUnsaved = useMemo(() => timelineStatus === TimelineStatus.draft, [timelineStatus]); @@ -175,7 +199,7 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } return ( - {'Unsaved'} + {i18n.UNSAVED} ); @@ -198,16 +222,16 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( - + - + - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.ts new file mode 100644 index 0000000000000..634fa5a775f1a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.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 { createSelector } from 'reselect'; + +import { TimelineStatus } from '../../../../../common/types/timeline'; +import { timelineSelectors } from '../../../store/timeline'; + +export const getTimelineStatusByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => ({ + status: timeline?.status ?? TimelineStatus.draft, + updated: timeline?.updated ?? undefined, + })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index ef9b88d65c551..2633faf4e3e43 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -13,6 +13,10 @@ export const CLOSE_TIMELINE = i18n.translate( } ); +export const UNSAVED = i18n.translate('xpack.securitySolution.timeline.properties.unsavedLabel', { + defaultMessage: 'Unsaved', +}); + export const AUTOSAVED = i18n.translate( 'xpack.securitySolution.timeline.properties.autosavedLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 5d118b357c8ef..41e2a569f41bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -40,6 +40,10 @@ jest.mock('../timeline', () => ({ describe('Flyout', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); + const props = { + onAppLeave: jest.fn(), + timelineId: 'test', + }; beforeEach(() => { mockDispatch.mockClear(); @@ -49,7 +53,7 @@ describe('Flyout', () => { test('it renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper.find('Flyout')).toMatchSnapshot(); @@ -58,7 +62,7 @@ describe('Flyout', () => { test('it renders the default flyout state as a bottom bar', () => { const wrapper = mount( - + ); @@ -79,7 +83,7 @@ describe('Flyout', () => { const wrapper = mount( - + ); @@ -91,7 +95,7 @@ describe('Flyout', () => { test('should call the onOpen when the mouse is clicked for rendering', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index a1e61b9fa4ae6..0636b76ef61bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { AppLeaveHandler } from '../../../../../../../src/core/public'; +import { TimelineId, TimelineStatus } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { timelineActions } from '../../store/timeline'; +import { TimelineTabs } from '../../store/timeline/model'; import { FlyoutBottomBar } from './bottom_bar'; import { Pane } from './pane'; -import { timelineSelectors } from '../../store/timeline'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; -import { timelineDefaults } from '../../store/timeline/defaults'; +import { getTimelineShowStatusByIdSelector } from './selectors'; const Visible = styled.div<{ show?: boolean }>` visibility: ${({ show }) => (show ? 'visible' : 'hidden')}; @@ -21,14 +26,58 @@ Visible.displayName = 'Visible'; interface OwnProps { timelineId: string; + onAppLeave: (handler: AppLeaveHandler) => void; } -const FlyoutComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const show = useShallowEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).show +const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { + const dispatch = useDispatch(); + const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); + const { show, status: timelineStatus, updated } = useDeepEqualSelector((state) => + getTimelineShowStatus(state, timelineId) ); + useEffect(() => { + onAppLeave((actions, nextAppId) => { + if (show) { + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); + } + // Confirm when the user has made any changes to a timeline + if ( + !(nextAppId ?? '').includes('securitySolution') && + timelineStatus === TimelineStatus.draft && + updated != null + ) { + const showSaveTimelineModal = () => { + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: true })); + dispatch( + timelineActions.setActiveTabTimeline({ + id: TimelineId.active, + activeTab: TimelineTabs.query, + }) + ); + dispatch( + timelineActions.toggleModalSaveTimeline({ + id: TimelineId.active, + showModalSaveTimeline: true, + }) + ); + }; + + return actions.confirm( + i18n.translate('xpack.securitySolution.timeline.unsavedWorkMessage', { + defaultMessage: 'Leave Timeline with unsaved work?', + }), + i18n.translate('xpack.securitySolution.timeline.unsavedWorkTitle', { + defaultMessage: 'Unsaved changes', + }), + showSaveTimelineModal + ); + } else { + return actions.default(); + } + }); + }, [dispatch, onAppLeave, show, timelineStatus, updated]); + return ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts new file mode 100644 index 0000000000000..ca811afd164f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createSelector } from 'reselect'; + +import { TimelineStatus } from '../../../../common/types/timeline'; +import { timelineSelectors } from '../../store/timeline'; + +export const getTimelineShowStatusByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => ({ + status: timeline?.status ?? TimelineStatus.draft, + show: timeline?.show ?? false, + updated: timeline?.updated ?? undefined, + })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx index 3d5e548e726e5..ececded801b45 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx @@ -8,7 +8,10 @@ import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; import React from 'react'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../common/containers/use_full_screen'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; import { TimelineId } from '../../../../common/types/timeline'; @@ -20,17 +23,20 @@ jest.mock('../../../common/hooks/use_selector', () => ({ })); jest.mock('../../../common/containers/use_full_screen', () => ({ - useFullScreen: jest.fn(), + useGlobalFullScreen: jest.fn(), + useTimelineFullScreen: jest.fn(), })); describe('GraphOverlay', () => { beforeEach(() => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: false, - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: false, setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: false, + setTimelineFullScreen: jest.fn(), + }); }); describe('when used in an events viewer (i.e. in the Detections view, or the Host > Events view)', () => { @@ -51,12 +57,14 @@ describe('GraphOverlay', () => { }); test('it has a calculated width that makes room for the Timeline flyout button when isEventViewer is true in full screen mode', async () => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: false, - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: true, // <-- true when an events viewer is in full screen mode setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: false, + setTimelineFullScreen: jest.fn(), + }); const wrapper = mount( @@ -89,12 +97,14 @@ describe('GraphOverlay', () => { }); test('it has 100% width when isEventViewer is false and the active timeline is in full screen mode', async () => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: true, // <-- true when the active timeline is in full screen mode - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: false, setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: true, // <-- true when the active timeline is in full screen mode + setTimelineFullScreen: jest.fn(), + }); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index b53c11868998f..8fac8fec0b61d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiHorizontalRule, EuiToolTip, + EuiLoadingSpinner, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -19,7 +20,10 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../common/containers/use_full_screen'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; @@ -72,7 +76,7 @@ const NavigationComponent: React.FC = ({ timelineFullScreen, toggleFullScreen, }) => ( - + {i18n.CLOSE_ANALYZER} @@ -113,12 +117,8 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } (state) => (getTimeline(state, timelineId) ?? timelineDefaults).graphEventId ); - const { - timelineFullScreen, - setTimelineFullScreen, - globalFullScreen, - setGlobalFullScreen, - } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const fullScreen = useMemo( () => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }), @@ -167,15 +167,17 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } /> - - - {graphEventId !== undefined && indices !== null && ( + {graphEventId !== undefined && indices !== null ? ( + ) : ( + + + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx deleted file mode 100644 index 32e10ac3eb77d..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx +++ /dev/null @@ -1,44 +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. - */ - -/* eslint-disable react/display-name */ - -import React from 'react'; - -import { EuiTableDataType } from '@elastic/eui'; -import { NoteCard } from './note_card'; -import * as i18n from './translations'; - -const Column = React.memo<{ text: string }>(({ text }) => {text}); -Column.displayName = 'Column'; - -interface Item { - created: Date; - note: string; - user: string; -} - -interface Column { - field: string; - dataType?: EuiTableDataType; - name: string; - sortable: boolean; - truncateText: boolean; - render: (value: string, item: Item) => JSX.Element; -} - -export const columns: Column[] = [ - { - field: 'note', - dataType: 'string', - name: i18n.NOTE, - sortable: true, - truncateText: false, - render: (_, { created, note, user }) => ( - - ), - }, -]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx deleted file mode 100644 index 1ba573c0ac6c3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ /dev/null @@ -1,119 +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 { - EuiInMemoryTable, - EuiInMemoryTableProps, - EuiModalBody, - EuiModalHeader, - EuiSpacer, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; - -import { Note } from '../../../common/lib/note'; - -import { AddNote } from './add_note'; -import { columns } from './columns'; -import { AssociateNote, NotesCount, search } from './helpers'; -import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; -import { timelineActions } from '../../store/timeline'; -import { appSelectors } from '../../../common/store/app'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -interface Props { - associateNote: AssociateNote; - noteIds: string[]; - status: TimelineStatusLiteral; -} - -export const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( - EuiInMemoryTable as React.ComponentType> -)` - & thead { - display: none; - } -` as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -InMemoryTable.displayName = 'InMemoryTable'; - -/** A view for entering and reviewing notes */ -export const Notes = React.memo(({ associateNote, noteIds, status }) => { - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = status === TimelineStatus.immutable; - - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - return ( - <> - - - - - - {!isImmutable && ( - - )} - - - - - ); -}); - -Notes.displayName = 'Notes'; - -interface NotesTabContentPros { - noteIds: string[]; - timelineId: string; - timelineStatus: TimelineStatusLiteral; -} - -/** A view for entering and reviewing notes */ -export const NotesTabContent = React.memo( - ({ noteIds, timelineStatus, timelineId }) => { - const dispatch = useDispatch(); - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - const associateNote = useCallback( - (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - [dispatch, timelineId] - ); - - return ( - <> - - - {!isImmutable && ( - - )} - - ); - } -); - -NotesTabContent.displayName = 'NotesTabContent'; 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 deleted file mode 100644 index 58cf0ae1e9f8f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ /dev/null @@ -1,759 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NoteCardBody renders correctly against snapshot 1`] = ` - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx deleted file mode 100644 index 161671ed730f3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCard } from '.'; - -describe('NoteCard', () => { - const created = new Date(); - const rawNote = 'noteworthy'; - const user = 'elastic'; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('it renders a note card header', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-header"]').exists()).toEqual(true); - }); - - test('it renders a note card body', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-body"]').exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx deleted file mode 100644 index e02ebc2a25fd0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx +++ /dev/null @@ -1,29 +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 { EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import { NoteCardBody } from './note_card_body'; -import { NoteCardHeader } from './note_card_header'; - -const NoteCardContainer = styled(EuiPanel)` - width: 100%; -`; - -NoteCardContainer.displayName = 'NoteCardContainer'; - -export const NoteCard = React.memo<{ created: Date; rawNote: string; user: string }>( - ({ created, rawNote, user }) => ( - - - - - ) -); - -NoteCard.displayName = 'NoteCard'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx deleted file mode 100644 index 77f1375b7a3c0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, shallow } from 'enzyme'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; - -import { NoteCardBody } from './note_card_body'; - -describe('NoteCardBody', () => { - const markdownHeaderPrefix = '# '; // translates to an h1 in markdown - const noteText = 'This is a note'; - const rawNote = `${markdownHeaderPrefix} ${noteText}`; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - - - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the text of the note in an h1', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('h1').first().text()).toEqual(noteText); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx deleted file mode 100644 index efda3737cd177..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx +++ /dev/null @@ -1,41 +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 { EuiPanel, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { WithCopyToClipboard } from '../../../../common/lib/clipboard/with_copy_to_clipboard'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { WithHoverActions } from '../../../../common/components/with_hover_actions'; -import * as i18n from '../translations'; - -const BodyContainer = styled(EuiPanel)` - border: none; -`; - -BodyContainer.displayName = 'BodyContainer'; - -export const NoteCardBody = React.memo<{ rawNote: string }>(({ rawNote }) => { - const hoverContent = useMemo( - () => ( - - - - ), - [rawNote] - ); - - const render = useCallback(() => {rawNote}, [rawNote]); - - return ( - - - - ); -}); - -NoteCardBody.displayName = 'NoteCardBody'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx deleted file mode 100644 index 4fbb7ce3f46eb..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx +++ /dev/null @@ -1,53 +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 moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import * as i18n from '../translations'; - -import { NoteCardHeader } from './note_card_header'; - -describe('NoteCardHeader', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - - const date = moment('2019-02-19 06:21:00'); - - const user = 'elastic'; - - test('it renders an avatar containing the first letter of the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual(user[0]); - }); - - test('it renders the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="user"]').first().text()).toEqual(user); - }); - - test('it renders the expected action', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="action"]').first().text()).toEqual(i18n.ADDED_A_NOTE); - }); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx deleted file mode 100644 index e6aa0542df4b3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx +++ /dev/null @@ -1,51 +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 { EuiAvatar, EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from '../translations'; - -import { NoteCreated } from './note_created'; - -const Action = styled.span` - margin-right: 5px; -`; - -Action.displayName = 'Action'; - -const Avatar = styled(EuiAvatar)` - margin-right: 5px; -`; - -Avatar.displayName = 'Avatar'; - -const HeaderContainer = styled.div` - align-items: center; - display: flex; - user-select: none; -`; - -HeaderContainer.displayName = 'HeaderContainer'; - -const User = styled.span` - font-weight: 700; - margin: 5px; -`; - -export const NoteCardHeader = React.memo<{ created: Date; user: string }>(({ created, user }) => ( - - - - {user} - {i18n.ADDED_A_NOTE} - - - -)); - -NoteCardHeader.displayName = 'NoteCardHeader'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx deleted file mode 100644 index 92d334a059ae9..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCreated } from './note_created'; - -describe('NoteCreated', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - const date = moment('2019-02-19 06:21:00'); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-created"]').first().exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx deleted file mode 100644 index dc97373660bd1..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx +++ /dev/null @@ -1,27 +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 { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { LocalizedDateTooltip } from '../../../../common/components/localized_date_tooltip'; - -const NoteCreatedContainer = styled.span` - user-select: none; -`; - -NoteCreatedContainer.displayName = 'NoteCreatedContainer'; - -export const NoteCreated = React.memo<{ created: Date }>(({ created }) => ( - - - - - -)); - -NoteCreated.displayName = 'NoteCreated'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 8fd95feba6031..724f49e9bd481 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx @@ -77,14 +77,9 @@ describe('NoteCards', () => { ); - expect( - wrapper - .find('[data-test-subj="note-card"]') - .find('[data-test-subj="note-card-body"]') - .find('.euiMarkdownFormat') - .first() - .text() - ).toEqual(getNotesByIds().abc.note); + expect(wrapper.find('.euiCommentEvent__body .euiMarkdownFormat').first().text()).toEqual( + getNotesByIds().abc.note + ); }); test('it shows controls for adding notes when showAddNote is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 4ce4de1851863..6c3fd2b50ae6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -12,7 +12,8 @@ import { appSelectors } from '../../../../common/store'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { AddNote } from '../add_note'; import { AssociateNote } from '../helpers'; -import { NoteCard } from '../note_card'; +import { NotePreviews, NotePreviewsContainer } from '../../open_timeline/note_previews'; +import { TimelineResultNote } from '../../open_timeline/types'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -22,23 +23,17 @@ const NoteContainer = styled.div` `; NoteContainer.displayName = 'NoteContainer'; -interface NoteCardsCompProps { - children: React.ReactNode; -} const NoteCardsCompContainer = styled(EuiPanel)` border: none; background-color: transparent; box-shadow: none; + + &.euiPanel--plain { + background-color: transparent; + } `; NoteCardsCompContainer.displayName = 'NoteCardsCompContainer'; -const NoteCardsComp = React.memo(({ children }) => ( - - {children} - -)); -NoteCardsComp.displayName = 'NoteCardsComp'; - const NotesContainer = styled(EuiFlexGroup)` margin-bottom: 5px; `; @@ -56,7 +51,6 @@ export const NoteCards = React.memo( ({ associateNote, noteIds, showAddNote, toggleShowAddNote }) => { const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); const [newNote, setNewNote] = useState(''); const associateNoteAndToggleShow = useCallback( @@ -67,16 +61,26 @@ export const NoteCards = React.memo( [associateNote, toggleShowAddNote] ); + const notes: TimelineResultNote[] = useMemo( + () => + appSelectors.getNotes(notesById, noteIds).map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })), + [notesById, noteIds] + ); + return ( - - {noteIds.length ? ( - - {items.map((note) => ( - - - - ))} - + + {notes.length ? ( + + + + + ) : null} {showAddNote ? ( @@ -89,7 +93,7 @@ export const NoteCards = React.memo( /> ) : null} - + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts index 4827481c7c5f3..e92b918a525d0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts @@ -50,3 +50,7 @@ export const COPY_TO_CLIPBOARD = i18n.translate( defaultMessage: 'Copy to Clipboard', } ); + +export const CREATED_BY = i18n.translate('xpack.securitySolution.notes.createdByLabel', { + defaultMessage: 'Created by', +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 6c76da44c8557..61b0c004dcb9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -1502,11 +1502,13 @@ describe('helpers', () => { notes: [ { created: new Date('2020-03-26T14:35:56.356Z'), + eventId: null, id: 'note-id', lastEdit: new Date('2020-03-26T14:35:56.356Z'), note: 'I am a note', user: 'unknown', saveObjectId: 'note-id', + timelineId: null, version: undefined, }, ], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 76eb9196e8c5c..37de75fd736af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -399,13 +399,15 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli to, ruleNote, }: UpdateTimeline): (() => void) => () => { - dispatch( - sourcererActions.initTimelineIndexPatterns({ - id: SourcererScopeName.timeline, - selectedPatterns: timeline.indexNames, - eventType: timeline.eventType, - }) - ); + if (!isEmpty(timeline.indexNames)) { + dispatch( + sourcererActions.initTimelineIndexPatterns({ + id: SourcererScopeName.timeline, + selectedPatterns: timeline.indexNames, + eventType: timeline.eventType, + }) + ); + } if ( timeline.status === TimelineStatus.immutable && timeline.timelineType === TimelineType.template @@ -462,6 +464,8 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli user: note.updatedBy || 'unknown', saveObjectId: note.noteId, version: note.version, + eventId: note.eventId ?? null, + timelineId: note.timelineId ?? null, })) : [], }) diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index 9ca5d0c7b438a..ffff6af3f1351 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -568,16 +568,8 @@ describe('StatefulOpenTimeline', () => { wrapper.update(); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true); - expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true); - - expect( - wrapper - .find('[data-test-subj="note-previews-container"]') - .find('[data-test-subj="updated-by"]') - .first() - .text() - ).toEqual('elastic'); + expect(wrapper.find('.euiCommentEvent__headerUsername').exists()).toEqual(true); + expect(wrapper.find('.euiCommentEvent__headerUsername').first().text()).toEqual('elastic'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index d791e6ebe4366..18e276a0914b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -104,7 +104,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find('.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); test('it filters-out null savedObjectIds', () => { @@ -135,7 +135,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); test('it filters-out undefined savedObjectIds', () => { @@ -165,6 +165,6 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 8c804dbe4b70d..7efa16d8168e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -5,46 +5,101 @@ */ import { uniqBy } from 'lodash/fp'; -import React from 'react'; +import { EuiAvatar, EuiButtonIcon, EuiCommentList } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { NotePreview } from './note_preview'; import { TimelineResultNote } from '../types'; +import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; +import { timelineActions } from '../../../store/timeline'; +import * as i18n from './translations'; -const NotePreviewsContainer = styled.section` - padding: ${(props) => - `${props.theme.eui.euiSizeS} 0 ${props.theme.eui.euiSizeS} ${props.theme.eui.euiSizeXXL}`}; +export const NotePreviewsContainer = styled.section` + padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; `; NotePreviewsContainer.displayName = 'NotePreviewsContainer'; +interface ToggleEventDetailsButtonProps { + eventId: string; + timelineId: string; +} + +const ToggleEventDetailsButtonComponent: React.FC = ({ + eventId, + timelineId, +}) => { + const dispatch = useDispatch(); + const handleClick = useCallback(() => { + dispatch( + timelineActions.toggleExpandedEvent({ + timelineId, + event: { + eventId, + // we don't store yet info about event index name in note + indexName: '', + }, + }) + ); + }, [dispatch, eventId, timelineId]); + + return ( + + ); +}; + +const ToggleEventDetailsButton = React.memo(ToggleEventDetailsButtonComponent); /** * Renders a preview of a note in the All / Open Timelines table */ -export const NotePreviews = React.memo<{ + +interface NotePreviewsProps { notes?: TimelineResultNote[] | null; -}>(({ notes }) => { + timelineId?: string; +} + +export const NotePreviews = React.memo(({ notes, timelineId }) => { + const notesList = useMemo( + () => + uniqBy('savedObjectId', notes).map((note) => ({ + 'data-test-subj': `note-preview-${note.savedObjectId}`, + username: defaultToEmptyTag(note.updatedBy), + event: 'added a comment', + timestamp: note.updated ? ( + + ) : ( + getEmptyValue() + ), + children: {note.note ?? ''}, + actions: + note.eventId && timelineId ? ( + + ) : null, + timelineIcon: ( + + ), + })), + [notes, timelineId] + ); + if (notes == null || notes.length === 0) { return null; } - const uniqueNotes = uniqBy('savedObjectId', notes); - - return ( - - {uniqueNotes.map(({ note, savedObjectId, updated, updatedBy }) => - savedObjectId != null ? ( - - ) : null - )} - - ); + return ; }); NotePreviews.displayName = 'NotePreviews'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx deleted file mode 100644 index 484b3e5a60015..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx +++ /dev/null @@ -1,154 +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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mountWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import '../../../../common/mock/formatted_relative'; - -import { getEmptyValue } from '../../../../common/components/empty_value'; -import { NotePreview } from './note_preview'; - -import * as i18n from '../translations'; - -describe('NotePreview', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - describe('Avatar', () => { - test('it renders an avatar with the expected initials when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('a'); - }); - - test('it renders an avatar with a "?" when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - - test('it renders an avatar with a "?" when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - }); - - describe('UpdatedBy', () => { - test('it renders the username when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual('admin'); - }); - - test('it renders placeholder text when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - - test('it renders placeholder text when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - }); - - describe('Updated', () => { - const updated = 1553300753 * 1000; - - test('it is always prefixed by "Posted:"', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text().startsWith(i18n.POSTED)).toBe( - true - ); - }); - - test('it renders the relative date when updated is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(true); - }); - - test('it does NOT render the relative date when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it does NOT render the relative date when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it renders placeholder text when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - - test('it renders placeholder text when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx deleted file mode 100644 index a8e7a2c465e0c..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx +++ /dev/null @@ -1,69 +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 { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; -import { FormattedDate } from '../../../../common/components/formatted_date'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import * as i18n from '../translations'; -import { TimelineResultNote } from '../types'; - -const NotePreviewGroup = styled.article` - & + & { - margin-top: ${(props) => props.theme.eui.euiSizeL}; - } -`; - -NotePreviewGroup.displayName = 'NotePreviewGroup'; - -const NotePreviewHeader = styled.header` - margin-bottom: ${(props) => props.theme.eui.euiSizeS}; -`; - -NotePreviewHeader.displayName = 'NotePreviewHeader'; - -/** - * Renders a preview of a note in the All / Open Timelines table - */ -export const NotePreview = React.memo>( - ({ note, updated, updatedBy }) => ( - - - - - - - - - -
{defaultToEmptyTag(updatedBy)}
-
- - -

- {i18n.POSTED}{' '} - {updated != null ? ( - }> - - - ) : ( - getEmptyValue() - )} -

-
-
- {note ?? ''} -
-
-
- ) -); - -NotePreview.displayName = 'NotePreview'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts new file mode 100644 index 0000000000000..9857e55e36570 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const TOGGLE_EXPAND_EVENT_DETAILS = i18n.translate( + 'xpack.securitySolution.timeline.toggleEventDetailsTitle', + { + defaultMessage: 'Expand event details', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index 3f391714bb058..268c874de7d50 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -6,13 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const ALL_ACTIONS = i18n.translate( - 'xpack.securitySolution.open.timeline.allActionsTooltip', - { - defaultMessage: 'All actions', - } -); - export const BATCH_ACTIONS = i18n.translate( 'xpack.securitySolution.open.timeline.batchActionsTitle', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 4e7e99a5d3e49..1556cd65ddd0a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -25,9 +25,11 @@ export interface FavoriteTimelineResult { } export interface TimelineResultNote { + eventId?: string | null; savedObjectId?: string | null; note?: string | null; noteId?: string | null; + timelineId?: string | null; updated?: number | null; updatedBy?: string | null; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 00cd5453e9669..2ded93377de93 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -19,16 +19,16 @@ import { EuiFlexItem, EuiInMemoryTable, } from '@elastic/eui'; -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { RowRendererId } from '../../../../common/types/timeline'; import { State } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; - +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../store/timeline/actions'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; import { renderers } from './catalog'; -import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../../timelines/store/timeline/actions'; import { RowRenderersBrowser } from './row_renderers_browser'; import * as i18n from './translations'; @@ -78,16 +78,14 @@ interface StatefulRowRenderersBrowserProps { timelineId: string; } -const emptyExcludedRowRendererIds: RowRendererId[] = []; - const StatefulRowRenderersBrowserComponent: React.FC = ({ timelineId, }) => { const tableRef = useRef>(); const dispatch = useDispatch(); - const excludedRowRendererIds = useShallowEqualSelector( - (state: State) => - state.timeline.timelineById[timelineId]?.excludedRowRendererIds || emptyExcludedRowRendererIds + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const excludedRowRendererIds = useDeepEqualSelector( + (state: State) => (getTimeline(state, timelineId) ?? timelineDefaults).excludedRowRendererIds ); const [show, setShow] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx index 12cfbbc04222f..fd4a7e91ddb79 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -12,7 +12,6 @@ import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; interface ActionIconItemProps { ariaLabel?: string; - id: string; width?: number; dataTestSubj?: string; content?: string; @@ -23,7 +22,6 @@ interface ActionIconItemProps { } const ActionIconItemComponent: React.FC = ({ - id, width = DEFAULT_ICON_BUTTON_WIDTH, dataTestSubj, content, @@ -33,7 +31,7 @@ const ActionIconItemComponent: React.FC = ({ onClick, children, }) => ( - + {children ?? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx index af8045bf624c3..3f9f680ee1913 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx @@ -6,38 +6,26 @@ import React from 'react'; -import { TimelineType, TimelineStatus } from '../../../../../../common/types/timeline'; -import { AssociateNote } from '../../../notes/helpers'; +import { TimelineType } from '../../../../../../common/types/timeline'; import * as i18n from '../translations'; import { NotesButton } from '../../properties/helpers'; import { ActionIconItem } from './action_icon_item'; interface AddEventNoteActionProps { - associateNote: AssociateNote; - noteIds: string[]; showNotes: boolean; - status: TimelineStatus; timelineType: TimelineType; toggleShowNotes: () => void; } const AddEventNoteActionComponent: React.FC = ({ - associateNote, - noteIds, showNotes, - status, timelineType, toggleShowNotes, }) => ( - + = ({ )} - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 66856f3bd6284..e3808514856e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -27,7 +27,10 @@ import { } from '../../../../../common/components/drag_and_drop/helpers'; import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations'; import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants'; -import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../../common/containers/use_full_screen'; import { TimelineId } from '../../../../../../common/types/timeline'; import { OnSelectAll } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; @@ -50,8 +53,16 @@ import * as i18n from './translations'; import { timelineActions } from '../../../../store/timeline'; const SortingColumnsContainer = styled.div` - .euiPopover .euiButtonEmpty .euiButtonContent .euiButtonEmpty__text { - display: none; + button { + color: ${({ theme }) => theme.eui.euiColorPrimary}; + } + + .euiPopover .euiButtonEmpty .euiButtonContent { + padding: 0; + + .euiButtonEmpty__text { + display: none; + } } `; @@ -115,12 +126,8 @@ export const ColumnHeadersComponent = ({ }: Props) => { const dispatch = useDispatch(); const [draggingIndex, setDraggingIndex] = useState(null); - const { - timelineFullScreen, - setTimelineFullScreen, - globalFullScreen, - setGlobalFullScreen, - } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const toggleFullScreen = useCallback(() => { if (timelineId === TimelineId.active) { @@ -278,6 +285,7 @@ export const ColumnHeadersComponent = ({ width={FIELD_BROWSER_WIDTH} /> + { - const origin = jest.requireActual('react-redux'); - return { - ...origin, - useSelector: jest.fn(), - }; -}); +jest.mock('../../../../../common/hooks/use_selector'); describe('EventColumnView', () => { - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); const props = { id: 'event-id', @@ -82,17 +76,14 @@ describe('EventColumnView', () => { }); test('it renders correct tooltip for NotesButton - timeline template', () => { - (useSelector as jest.Mock).mockReturnValue({ - ...mockTimelineModel, - timelineType: TimelineType.template, - }); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.template); const wrapper = mount(, { wrappingComponent: TestProviders }); expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( i18n.NOTES_DISABLE_TOOLTIP ); - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); }); test('it does NOT render a pin button when isEventViewer is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 584350f9f7b66..cbb7bb9d0c6a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -4,14 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pick } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { AssociateNote } from '../../../notes/helpers'; import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { EventsTrData } from '../../styles'; import { Actions } from '../actions'; @@ -29,12 +27,13 @@ import { AddEventNoteAction } from '../actions/add_note_icon_item'; import { PinEventAction } from '../actions/pin_event_action'; import { inputsModel } from '../../../../../common/store'; import { TimelineId } from '../../../../../../common/types/timeline'; +import { timelineSelectors } from '../../../../store/timeline'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action'; interface Props { id: string; actionsColumnWidth: number; - associateNote: AssociateNote; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; data: TimelineNonEcsData[]; @@ -63,7 +62,6 @@ export const EventColumnView = React.memo( ({ id, actionsColumnWidth, - associateNote, columnHeaders, columnRenderers, data, @@ -85,8 +83,9 @@ export const EventColumnView = React.memo( timelineId, toggleShowNotes, }) => { - const { timelineType, status } = useDeepEqualSelector((state) => - pick(['timelineType', 'status'], state.timeline.timelineById[timelineId]) + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineType = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType ); const handlePinClicked = useCallback( @@ -123,11 +122,8 @@ export const EventColumnView = React.memo( ? [ , ( />, ], [ - associateNote, data, ecsData, eventIdToNoteIds, @@ -174,7 +169,6 @@ export const EventColumnView = React.memo( refetch, onRuleChange, showNotes, - status, timelineId, timelineType, toggleShowNotes, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 3d3c87be42824..917b4a4e7a762 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -26,8 +26,9 @@ import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import { inputsModel } from '../../../../../common/store'; -import { timelineActions } from '../../../../store/timeline'; +import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; interface Props { actionsColumnWidth: number; @@ -77,8 +78,9 @@ const StatefulEventComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId].expandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent ); const divElement = useRef(null); @@ -112,13 +114,12 @@ const StatefulEventComponent: React.FC = ({ event: { eventId, indexName, - loading: false, }, }) ); if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent({ eventId, indexName, loading: false }); + activeTimeline.toggleExpandedEvent({ eventId, indexName }); } }, [dispatch, event._id, event._index, timelineId]); @@ -155,7 +156,6 @@ const StatefulEventComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index 10518141ebb25..7f3d86af7ca8a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -14,3 +14,4 @@ export const RULE_REFERENCE_FIELD_NAME = 'rule.reference'; export const REFERENCE_URL_FIELD_NAME = 'reference.url'; export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name'; +export const SIGNAL_STATUS_FIELD_NAME = 'signal.status'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 5bd928021fa0b..9c1169608ccae 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -5,19 +5,15 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { isNumber, isString, isEmpty } from 'lodash/fp'; +import { isNumber, isEmpty } from 'lodash/fp'; import React from 'react'; import { DefaultDraggable } from '../../../../../common/components/draggables'; import { Bytes, BYTES_FORMAT } from './bytes'; import { Duration, EVENT_DURATION_FIELD_NAME } from '../../../duration'; -import { - getOrEmptyTagFromValue, - getEmptyTagValue, -} from '../../../../../common/components/empty_value'; +import { getOrEmptyTagFromValue } from '../../../../../common/components/empty_value'; import { FormattedDate } from '../../../../../common/components/formatted_date'; import { FormattedIp } from '../../../../components/formatted_ip'; -import { HostDetailsLink } from '../../../../../common/components/links'; import { Port, PORT_NAMES } from '../../../../../network/components/port'; import { TruncatableText } from '../../../../../common/components/truncatable_text'; @@ -31,9 +27,12 @@ import { SIGNAL_RULE_NAME_FIELD_NAME, REFERENCE_URL_FIELD_NAME, EVENT_URL_FIELD_NAME, + SIGNAL_STATUS_FIELD_NAME, GEO_FIELD_TYPE, } from './constants'; import { RenderRuleName, renderEventModule, renderUrl } from './formatted_field_helpers'; +import { RuleStatus } from './rule_status'; +import { HostName } from './host_name'; // simple black-list to prevent dragging and dropping fields such as message name const columnNamesNotDraggable = [MESSAGE_FIELD_NAME]; @@ -80,22 +79,7 @@ const FormattedFieldValueComponent: React.FC<{ ); } else if (fieldName === HOST_NAME_FIELD_NAME) { - const hostname = `${value}`; - - return isString(value) && hostname.length > 0 ? ( - - - {value} - - - ) : ( - getEmptyTagValue() - ); + return ; } else if (fieldFormat === BYTES_FORMAT) { return ( @@ -113,6 +97,10 @@ const FormattedFieldValueComponent: React.FC<{ ); } else if (fieldName === EVENT_MODULE_FIELD_NAME) { return renderEventModule({ contextId, eventId, fieldName, linkValue, truncate, value }); + } else if (fieldName === SIGNAL_STATUS_FIELD_NAME) { + return ( + + ); } else if ( [RULE_REFERENCE_FIELD_NAME, REFERENCE_URL_FIELD_NAME, EVENT_URL_FIELD_NAME].includes(fieldName) ) { @@ -142,7 +130,6 @@ const FormattedFieldValueComponent: React.FC<{ } else { const contentValue = getOrEmptyTagFromValue(value); const content = truncate ? {contentValue} : contentValue; - return ( = ({ {content} + ) : value != null ? ( + + {value} + ) : ( getEmptyTagValue() ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx new file mode 100644 index 0000000000000..fbac27095d4f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { isString } from 'lodash/fp'; + +import { DefaultDraggable } from '../../../../../common/components/draggables'; +import { getEmptyTagValue } from '../../../../../common/components/empty_value'; +import { HostDetailsLink } from '../../../../../common/components/links'; +import { TruncatableText } from '../../../../../common/components/truncatable_text'; + +interface Props { + contextId: string; + eventId: string; + fieldName: string; + value: string | number | undefined | null; +} + +const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { + const hostname = `${value}`; + + return isString(value) && hostname.length > 0 ? ( + + + {value} + + + ) : ( + getEmptyTagValue() + ); +}; + +export const HostName = React.memo(HostNameComponent); +HostName.displayName = 'HostName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx new file mode 100644 index 0000000000000..4dc6d3b2e8e8d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { getOr } from 'lodash/fp'; + +import styled from 'styled-components'; +import { DefaultDraggable } from '../../../../../common/components/draggables'; + +const mapping = { + open: 'primary', + 'in-progress': 'warning', + closed: 'default', +}; + +const StyledEuiBadge = styled(EuiBadge)` + text-transform: capitalize; +`; + +interface Props { + contextId: string; + eventId: string; + fieldName: string; + value: string | number | undefined | null; +} + +const RuleStatusComponent: React.FC = ({ contextId, eventId, fieldName, value }) => { + const color = useMemo(() => getOr('default', `${value}`, mapping), [value]); + return ( + + {value} + + ); +}; + +export const RuleStatus = React.memo(RuleStatusComponent); +RuleStatus.displayName = 'RuleStatus'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index c57002023b79d..c934f50ba0aec 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -73,17 +73,17 @@ export const EXPAND = i18n.translate( } ); -export const COLLAPSE = i18n.translate( - 'xpack.securitySolution.timeline.body.actions.collapseAriaLabel', +export const EXPAND_EVENT = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.expandEventTooltip', { - defaultMessage: 'Collapse', + defaultMessage: 'Expand event', } ); -export const COLLAPSE_EVENT = i18n.translate( - 'xpack.securitySolution.timeline.body.actions.collapseEventTooltip', +export const COLLAPSE = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.collapseAriaLabel', { - defaultMessage: 'Collapse event', + defaultMessage: 'Collapse', } ); @@ -93,3 +93,10 @@ export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( defaultMessage: 'Analyze event', } ); + +export const INVESTIGATE_IN_RESOLVER_DISABLED = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateInResolverDisabledTooltip', + { + defaultMessage: 'This event cannot be analyzed since it has incompatible field mappings', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts index 58729f69402e1..ecd06faed7253 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts @@ -10,7 +10,7 @@ export const LOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerTooltip', { defaultMessage: - 'Disable syncing of date/time range between the currently viewed page and your timeline', + 'Disable syncing of date/time range bteween the currently viewed page and your timeline', } ); @@ -25,27 +25,27 @@ export const UNLOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( export const LOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.lockedDatePickerLabel', { - defaultMessage: 'Date picker is locked to global date picker', + defaultMessage: 'Global date picker is locked to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockedDatePickerLabel', { - defaultMessage: 'Date picker is NOT locked to global date picker', + defaultMessage: 'Global date picker NOT locked to timeline date picker', } ); export const LOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerDescription', { - defaultMessage: 'Lock date picker to global date picker', + defaultMessage: 'Lock global date picker to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockDatePickerDescription', { - defaultMessage: 'Unlock date picker to global date picker', + defaultMessage: 'Unlock global date picker from timeline date picker', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx index ed9b20f7a5e2d..9895f4eda0e6c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx @@ -10,40 +10,66 @@ * you may not use this file except in compliance with the Elastic License. */ +import { some } from 'lodash/fp'; import { EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle, + HandleOnEventClosed, } from '../../../timelines/components/timeline/expandable_event'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { useTimelineEventsDetails } from '../../containers/details'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; interface EventDetailsProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; timelineId: string; + handleOnEventClosed?: HandleOnEventClosed; } const EventDetailsComponent: React.FC = ({ browserFields, docValueFields, timelineId, + handleOnEventClosed, }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent + ); + + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent.indexName!, + eventId: expandedEvent.eventId!, + skip: !expandedEvent.eventId, + }); + + const isAlert = useMemo( + () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), + [detailsData] ); return ( <> - + @@ -55,5 +81,6 @@ export const EventDetails = React.memo( (prevProps, nextProps) => deepEqual(prevProps.browserFields, nextProps.browserFields) && deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId + prevProps.timelineId === nextProps.timelineId && + prevProps.handleOnEventClosed === nextProps.handleOnEventClosed ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index 77a37d8b9a929..df8e84b4e2a78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -5,45 +5,74 @@ */ import { find } from 'lodash/fp'; -import { EuiTextColor, EuiLoadingContent, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiTextColor, + EuiLoadingContent, + EuiTitle, + EuiSpacer, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; import { TimelineExpandedEvent } from '../../../../../common/types/timeline'; -import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, EventsViewType, View, } from '../../../../common/components/event_details/event_details'; import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; -import { useTimelineEventsDetails } from '../../../containers/details'; +import { LineClamp } from '../../../../common/components/line_clamp'; import * as i18n from './translations'; +export type HandleOnEventClosed = () => void; interface Props { browserFields: BrowserFields; - docValueFields: DocValueFields[]; + detailsData: TimelineEventsDetailsItem[] | null; event: TimelineExpandedEvent; + isAlert: boolean; + loading: boolean; timelineId: string; } -export const ExpandableEventTitle = React.memo(() => ( - -

{i18n.EVENT_DETAILS}

-
-)); +interface ExpandableEventTitleProps { + isAlert: boolean; + loading: boolean; + handleOnEventClosed?: HandleOnEventClosed; +} + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +export const ExpandableEventTitle = React.memo( + ({ isAlert, loading, handleOnEventClosed }) => ( + + + + {!loading ?

{isAlert ? i18n.ALERT_DETAILS : i18n.EVENT_DETAILS}

: <>} +
+
+ {handleOnEventClosed && ( + + + + )} +
+ ) +); ExpandableEventTitle.displayName = 'ExpandableEventTitle'; export const ExpandableEvent = React.memo( - ({ browserFields, docValueFields, event, timelineId }) => { - const [view, setView] = useState(EventsViewType.tableView); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: event.indexName!, - eventId: event.eventId!, - skip: !event.eventId, - }); + ({ browserFields, event, timelineId, isAlert, loading, detailsData }) => { + const [view, setView] = useState(EventsViewType.summaryView); const message = useMemo(() => { if (detailsData) { @@ -52,7 +81,9 @@ export const ExpandableEvent = React.memo( | undefined; if (messageField?.originalValue) { - return messageField?.originalValue; + return Array.isArray(messageField?.originalValue) + ? messageField?.originalValue.join() + : messageField?.originalValue; } } return null; @@ -68,12 +99,22 @@ export const ExpandableEvent = React.memo( return ( <> - {message} - + {message && ( + <> + + {i18n.MESSAGE} + + + + + + + )} { + const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ + serverSideEventCount, + ]); return (
- + - {serverSideEventCount} + {totalCount} {' '} {documentType} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx index e9dc312ee8d19..18b2ebc2ec253 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx @@ -3,13 +3,10 @@ * 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 { shallow, mount } from 'enzyme'; - +import { mount } from 'enzyme'; import { SaveTimelineButton } from './save_timeline_button'; -import { act } from '@testing-library/react-hooks'; - +import { TestProviders } from '../../../../common/mock'; jest.mock('react-redux', () => { const actual = jest.requireActual('react-redux'); return { @@ -17,60 +14,59 @@ jest.mock('react-redux', () => { useDispatch: jest.fn(), }; }); - +jest.mock('../../../../common/lib/kibana'); jest.mock('./title_and_description'); - describe('SaveTimelineButton', () => { const props = { + initialFocus: 'title' as const, timelineId: 'timeline-1', - showOverlay: false, toolTip: 'tooltip message', - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), }; test('Show tooltip', () => { - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); }); - test('Hide tooltip', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = mount(); + const component = mount( + + + + ); component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); - - act(() => { - expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual( - false - ); - }); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); }); - test('should show a button with pencil icon', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-button-icon"]').prop('iconType')).toEqual( - 'pencil' + const component = mount( + + + ); + expect( + component.find('[data-test-subj="save-timeline-button-icon"]').first().prop('iconType') + ).toEqual('pencil'); }); - test('should not show a modal when showOverlay equals false', () => { - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); }); - test('should show a modal when showOverlay equals true', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = mount(); + const component = mount( + + + + ); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); + expect(component.find('[data-test-subj="save-timeline-modal-comp"]').exists()).toEqual(false); component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); - act(() => { - expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(true); - }); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); + expect(component.find('[data-test-subj="save-timeline-modal-comp"]').exists()).toEqual(true); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx index f3bd4a88ca236..46898a8daaf89 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -4,53 +4,69 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiOverlayMask, EuiModal, EuiToolTip } from '@elastic/eui'; - +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; +import { useDispatch } from 'react-redux'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineActions } from '../../../store/timeline'; +import { getTimelineSaveModalByIdSelector } from './selectors'; import { TimelineTitleAndDescription } from './title_and_description'; import { EDIT } from './translations'; export interface SaveTimelineComponentProps { + initialFocus: 'title' | 'description'; timelineId: string; toolTip?: string; } export const SaveTimelineButton = React.memo( - ({ timelineId, toolTip }) => { + ({ initialFocus, timelineId, toolTip }) => { + const dispatch = useDispatch(); + const getTimelineSaveModal = useMemo(() => getTimelineSaveModalByIdSelector(), []); + const show = useDeepEqualSelector((state) => getTimelineSaveModal(state, timelineId)); const [showSaveTimelineOverlay, setShowSaveTimelineOverlay] = useState(false); - const onToggleSaveTimeline = useCallback(() => { - setShowSaveTimelineOverlay((prevShowSaveTimelineOverlay) => !prevShowSaveTimelineOverlay); + + const closeSaveTimeline = useCallback(() => { + setShowSaveTimelineOverlay(false); + if (show) { + dispatch( + timelineActions.toggleModalSaveTimeline({ + id: TimelineId.active, + showModalSaveTimeline: false, + }) + ); + } + }, [dispatch, setShowSaveTimelineOverlay, show]); + + const openSaveTimeline = useCallback(() => { + setShowSaveTimelineOverlay(true); }, [setShowSaveTimelineOverlay]); const saveTimelineButtonIcon = useMemo( () => ( ), - [onToggleSaveTimeline] + [openSaveTimeline] ); - return showSaveTimelineOverlay ? ( + return (initialFocus === 'title' && show) || showSaveTimelineOverlay ? ( <> {saveTimelineButtonIcon} - - - - - + ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts new file mode 100644 index 0000000000000..8aa895e68dc7e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createSelector } from 'reselect'; + +import { timelineSelectors } from '../../../store/timeline'; + +export const getTimelineSaveModalByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => timeline?.showSaveModal ?? false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx index cb31765bd9c37..2b8ec62199478 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { TimelineTitleAndDescription } from './title_and_description'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import * as i18n from './translations'; jest.mock('../../../../common/hooks/use_selector', () => ({ @@ -17,7 +16,7 @@ jest.mock('../../../../common/hooks/use_selector', () => ({ })); jest.mock('../properties/use_create_timeline', () => ({ - useCreateTimelineButton: jest.fn(), + useCreateTimeline: jest.fn(), })); jest.mock('react-redux', () => { @@ -31,8 +30,10 @@ jest.mock('react-redux', () => { describe('TimelineTitleAndDescription', () => { describe('save timeline', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', - toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), updateTitle: jest.fn(), updateDescription: jest.fn(), @@ -44,22 +45,18 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.default, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); - test('show proress bar while saving', () => { + test('show process bar while saving', () => { const component = shallow(); expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); }); @@ -75,7 +72,7 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.template, }); @@ -108,6 +105,9 @@ describe('TimelineTitleAndDescription', () => { describe('update timeline', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), @@ -121,22 +121,18 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: '1234', + status: TimelineStatus.active, title: 'my timeline', timelineType: TimelineType.default, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); - test('show proress bar while saving', () => { + test('show process bar while saving', () => { const component = shallow(); expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); }); @@ -152,7 +148,7 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: '1234', + status: TimelineStatus.active, title: 'my timeline', timelineType: TimelineType.template, }); @@ -180,6 +176,9 @@ describe('TimelineTitleAndDescription', () => { describe('showWarning', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), @@ -194,19 +193,15 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.default, showWarnging: true, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); @@ -217,34 +212,23 @@ describe('TimelineTitleAndDescription', () => { test('Show discardTimelineButton', () => { const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); - }); - - test('get discardTimelineButton with correct props', () => { - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }); + expect(component.find('[data-test-subj="close-button"]').dive().text()).toEqual( + 'Discard Timeline' + ); }); test('get discardTimelineTemplateButton with correct props', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.template, }); - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE_TEMPLATE, - outline: true, - iconType: '', - fill: false, - }); + const component = shallow(); + expect(component.find('[data-test-subj="close-button"]').dive().text()).toEqual( + 'Discard Timeline Template' + ); }); test('Show saveButton', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index 72e7778347f44..87d4fcdb7075f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -9,6 +9,8 @@ import { EuiFlexGroup, EuiFormRow, EuiFlexItem, + EuiOverlayMask, + EuiModal, EuiModalBody, EuiModalHeader, EuiSpacer, @@ -18,19 +20,23 @@ import { import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineType } from '../../../../../common/types/timeline'; + +import { TimelineId, TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { TimelineInput } from '../../../store/timeline/actions'; import { Description, Name } from '../properties/helpers'; +import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; import { TIMELINE_TITLE, DESCRIPTION, OPTIONAL } from '../properties/translations'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; +import { useCreateTimeline } from '../properties/use_create_timeline'; import * as i18n from './translations'; interface TimelineTitleAndDescriptionProps { - showWarning?: boolean; + closeSaveTimeline: () => void; + initialFocus: 'title' | 'description'; + openSaveTimeline: () => void; timelineId: string; - toggleSaveTimeline: () => void; + showWarning?: boolean; } const Wrapper = styled(EuiModalBody)` @@ -61,16 +67,18 @@ const usePrevious = (value: unknown) => { // the modal is used as a reminder for users to save / discard // the unsaved timeline / template export const TimelineTitleAndDescription = React.memo( - ({ timelineId, toggleSaveTimeline, showWarning }) => { + ({ closeSaveTimeline, initialFocus, openSaveTimeline, timelineId, showWarning }) => { // TODO: Refactor to use useForm() instead const [isFormSubmitted, setFormSubmitted] = useState(false); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const timeline = useDeepEqualSelector((state) => getTimeline(state, timelineId)); - - const { isSaving, savedObjectId, title, timelineType } = timeline; - + const { isSaving, status, title, timelineType } = timeline; const prevIsSaving = usePrevious(isSaving); const dispatch = useDispatch(); + const handleCreateNewTimeline = useCreateTimeline({ + timelineId: TimelineId.active, + timelineType: TimelineType.default, + }); const onSaveTimeline = useCallback( (args: TimelineInput) => dispatch(timelineActions.saveTimeline(args)), [dispatch] @@ -85,30 +93,30 @@ export const TimelineTitleAndDescription = React.memo - getButton({ - title: - timelineType === TimelineType.template - ? i18n.DISCARD_TIMELINE_TEMPLATE - : i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }), - [getButton, timelineType] - ); + const handleCancel = useCallback(() => { + if (showWarning) { + handleCreateNewTimeline(); + } + closeSaveTimeline(); + }, [closeSaveTimeline, handleCreateNewTimeline, showWarning]); + + const closeModalText = useMemo(() => { + if (status === TimelineStatus.draft && showWarning) { + return timelineType === TimelineType.template + ? i18n.DISCARD_TIMELINE_TEMPLATE + : i18n.DISCARD_TIMELINE; + } + return i18n.CLOSE_MODAL; + }, [showWarning, status, timelineType]); useEffect(() => { if (isFormSubmitted && !isSaving && prevIsSaving) { - toggleSaveTimeline(); + closeSaveTimeline(); } - }, [isFormSubmitted, isSaving, prevIsSaving, toggleSaveTimeline]); + }, [isFormSubmitted, isSaving, prevIsSaving, closeSaveTimeline]); const modalHeader = - savedObjectId == null + status === TimelineStatus.draft ? timelineType === TimelineType.template ? i18n.SAVE_TIMELINE_TEMPLATE : i18n.SAVE_TIMELINE @@ -117,7 +125,7 @@ export const TimelineTitleAndDescription = React.memo - {isSaving && ( - - )} - {modalHeader} - - - {showWarning && ( + + + {isSaving && ( + + )} + {modalHeader} + + + {showWarning && ( + + + + + )} - - + + + + - )} - - - - - - - - - - - - - - - - {savedObjectId == null && showWarning ? ( - discardTimelineButton - ) : ( + + + + + + + + + - {i18n.CLOSE_MODAL} + {closeModalText} - )} - - - - {saveButtonTitle} - - - - - - + + + + {saveButtonTitle} + + + + + + + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 4e6bca7fd9625..5a1d2ef7a1800 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -21,7 +21,8 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { activeTimeline } from '../../containers/active_timeline_context'; import * as i18n from './translations'; import { TabsContent } from './tabs_content'; -import { TimelineContainer } from './styles'; +import { HideShowContainer, TimelineContainer } from './styles'; +import { useTimelineFullScreen } from '../../../common/containers/use_full_screen'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -55,6 +56,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { getTimeline(state, timelineId) ?? timelineDefaults ) ); + const { timelineFullScreen } = useTimelineFullScreen(); useEffect(() => { if (!savedObjectId) { @@ -79,7 +81,9 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { )} - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index 9855a0124b8f5..20c528c701890 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -4,21 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pick } from 'lodash/fp'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiPanel } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import { filter, pick, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { TimelineStatus } from '../../../../../common/types/timeline'; import { appSelectors } from '../../../../common/store/app'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { AddNote } from '../../notes/add_note'; -import { InMemoryTable } from '../../notes'; -import { columns } from '../../notes/columns'; -import { search } from '../../notes/helpers'; +import { CREATED_BY, NOTES } from '../../notes/translations'; +import { PARTICIPANTS } from '../../../../cases/translations'; +import { NotePreviews } from '../../open_timeline/note_previews'; +import { TimelineResultNote } from '../../open_timeline/types'; +import { EventDetails } from '../event_details'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -27,7 +40,8 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)` `; const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; + overflow-x: hidden; + overflow-y: auto; `; const VerticalRule = styled.div` @@ -41,6 +55,66 @@ const StyledPanel = styled(EuiPanel)` box-shadow: none; `; +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const Username = styled(EuiText)` + font-weight: bold; +`; + +interface UsernameWithAvatar { + username: string; +} + +const UsernameWithAvatarComponent: React.FC = ({ username }) => ( + + + + + + {username} + + +); + +const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); + +interface ParticipantsProps { + users: TimelineResultNote[]; +} + +const ParticipantsComponent: React.FC = ({ users }) => { + const List = useMemo( + () => + users.map((user) => ( + + + + + )), + [users] + ); + + if (!users.length) { + return null; + } + + return ( + <> + +

{PARTICIPANTS}

+
+ + {List} + + ); +}; + +ParticipantsComponent.displayName = 'ParticipantsComponent'; + +const Participants = React.memo(ParticipantsComponent); + interface NotesTabContentProps { timelineId: string; } @@ -48,37 +122,77 @@ interface NotesTabContentProps { const NotesTabContentComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { status: timelineStatus, noteIds } = useDeepEqualSelector((state) => - pick(['noteIds', 'status'], getTimeline(state, timelineId) ?? timelineDefaults) + const { createdBy, expandedEvent, status: timelineStatus } = useDeepEqualSelector((state) => + pick( + ['createdBy', 'expandedEvent', 'status'], + getTimeline(state, timelineId) ?? timelineDefaults + ) ); - const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.timeline); + + const getNotesAsCommentsList = useMemo( + () => appSelectors.selectNotesAsCommentsListSelector(), + [] + ); const [newNote, setNewNote] = useState(''); const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); + const notes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); const associateNote = useCallback( (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), [dispatch, timelineId] ); + const handleOnEventClosed = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + }, [dispatch, timelineId]); + + const EventDetailsContent = useMemo( + () => + expandedEvent.eventId ? ( + + ) : null, + [browserFields, docValueFields, expandedEvent.eventId, handleOnEventClosed, timelineId] + ); + + const SidebarContent = useMemo( + () => ( + <> + {createdBy && ( + <> + + +

{CREATED_BY}

+
+ + + + + )} + + + ), + [createdBy, participants] + ); + return ( - + -

{'Notes'}

+

{NOTES}

- + {!isImmutable && ( @@ -86,7 +200,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId }
- {/* SIDEBAR PLACEHOLDER */} + {EventDetailsContent ?? SidebarContent}
); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index 6eb9286871b68..3a75922ab72bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -105,19 +105,6 @@ describe('Description', () => { ).toEqual(i18n.DESCRIPTION_TOOL_TIP); }); - test('should not render textarea if isTextArea is false', () => { - const component = mount( - - - - ); - expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( - false - ); - - expect(component.find('[data-test-subj="timeline-description-input"]').exists()).toEqual(true); - }); - test('should render textarea if isTextArea is true', () => { const testProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 494b3cefba6f1..d17399a0fb180 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -4,40 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiButton, - EuiButtonIcon, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiModal, - EuiOverlayMask, - EuiToolTip, - EuiTextArea, -} from '@elastic/eui'; +import { EuiBadge, EuiButton, EuiButtonIcon, EuiToolTip, EuiTextArea } from '@elastic/eui'; import { pick } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { - TimelineTypeLiteral, - TimelineType, - TimelineStatusLiteral, -} from '../../../../../common/types/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { useDeepEqualSelector, useShallowEqualSelector, } from '../../../../common/hooks/use_selector'; -import { Notes } from '../../notes'; -import { AssociateNote } from '../../notes/helpers'; - -import { NOTES_PANEL_WIDTH } from './notes_size'; -import { ButtonContainer, DescriptionContainer, LabelText, NameField, NameWrapper } from './styles'; +import { DescriptionContainer, NameField, NameWrapper } from './styles'; import * as i18n from './translations'; import { TimelineInput } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; @@ -90,8 +70,8 @@ AddToFavoritesButtonComponent.displayName = 'AddToFavoritesButtonComponent'; export const AddToFavoritesButton = React.memo(AddToFavoritesButtonComponent); interface DescriptionProps { + autoFocus?: boolean; timelineId: string; - isTextArea?: boolean; disableAutoSave?: boolean; disableTooltip?: boolean; disabled?: boolean; @@ -99,8 +79,8 @@ interface DescriptionProps { export const Description = React.memo( ({ + autoFocus = false, timelineId, - isTextArea = false, disableAutoSave = false, disableTooltip = false, disabled = false, @@ -126,28 +106,21 @@ export const Description = React.memo( ); const inputField = useMemo( - () => - isTextArea ? ( - - ) : ( - - ), - [description, isTextArea, onDescriptionChanged, disabled] + () => ( + + ), + [autoFocus, description, onDescriptionChanged, disabled] ); + return ( {disableTooltip ? ( @@ -183,7 +156,6 @@ export const Name = React.memo( timelineId, }) => { const dispatch = useDispatch(); - const timelineNameRef = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { title, timelineType } = useDeepEqualSelector((state) => @@ -198,15 +170,10 @@ export const Name = React.memo( [dispatch, timelineId, disableAutoSave] ); - useEffect(() => { - if (autoFocus && timelineNameRef && timelineNameRef.current) { - timelineNameRef.current.focus(); - } - }, [autoFocus]); - const nameField = useMemo( () => ( ( } spellCheck={true} value={title} - inputRef={timelineNameRef} /> ), - [handleChange, timelineType, title, disabled] + [autoFocus, handleChange, timelineType, title, disabled] ); return ( @@ -259,43 +225,12 @@ export const NewTimeline = React.memo( NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { - animate?: boolean; - associateNote: AssociateNote; - noteIds: string[]; - size: 's' | 'l'; - status: TimelineStatusLiteral; showNotes: boolean; toggleShowNotes: () => void; - text?: string; toolTip?: string; timelineType: TimelineTypeLiteral; } -interface LargeNotesButtonProps { - noteIds: string[]; - text?: string; - toggleShowNotes: () => void; -} - -const LargeNotesButton = React.memo(({ noteIds, text, toggleShowNotes }) => ( - - - - - - - {text && text.length ? {text} : null} - - - - {noteIds.length} - - - - -)); -LargeNotesButton.displayName = 'LargeNotesButton'; - interface SmallNotesButtonProps { toggleShowNotes: () => void; timelineType: TimelineTypeLiteral; @@ -316,83 +251,13 @@ const SmallNotesButton = React.memo(({ toggleShowNotes, t }); SmallNotesButton.displayName = 'SmallNotesButton'; -/** - * The internal implementation of the `NotesButton` - */ -const NotesButtonComponent = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - toggleShowNotes, - text, - timelineType, - }) => ( - - <> - {size === 'l' ? ( - - ) : ( - - )} - {size === 'l' && showNotes ? ( - - - - - - ) : null} - - - ) -); -NotesButtonComponent.displayName = 'NotesButtonComponent'; - export const NotesButton = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - timelineType, - toggleShowNotes, - toolTip, - text, - }) => + ({ showNotes, timelineType, toggleShowNotes, toolTip }) => showNotes ? ( - + ) : ( - + ) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx index 7dc5b8601955a..c1f9b18f05c60 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx @@ -5,12 +5,7 @@ */ import { EuiFieldText } from '@elastic/eui'; -import styled, { keyframes } from 'styled-components'; - -const fadeInEffect = keyframes` - from { opacity: 0; } - to { opacity: 1; } -`; +import styled from 'styled-components'; export const NameField = styled(EuiFieldText)` .euiToolTipAnchor { @@ -33,11 +28,6 @@ export const DescriptionContainer = styled.div` `; DescriptionContainer.displayName = 'DescriptionContainer'; -export const ButtonContainer = styled.div<{ animate: boolean }>` - animation: ${fadeInEffect} ${({ animate }) => (animate ? '0.3s' : '0s')}; -`; -ButtonContainer.displayName = 'ButtonContainer'; - export const LabelText = styled.div` margin-left: 10px; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx index 10b505da5c76f..ddf180f7d2286 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx @@ -145,6 +145,9 @@ describe('useCreateTimelineButton', () => { 'x-pack/security_solution/local/inputs/ADD_TIMELINE_LINK_TO' ); expect(mockDispatch.mock.calls[4][0].type).toEqual( + 'x-pack/security_solution/local/app/ADD_NOTE' + ); + expect(mockDispatch.mock.calls[5][0].type).toEqual( 'x-pack/security_solution/local/inputs/SET_RELATIVE_RANGE_DATE_PICKER' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index 4043ceeb85b7e..12845477e0f39 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { timelineActions } from '../../../store/timeline'; -import { useFullScreen } from '../../../../common/containers/use_full_screen'; +import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { TimelineId, TimelineType, @@ -19,6 +19,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { inputsActions, inputsSelectors } from '../../../../common/store/inputs'; import { sourcererActions, sourcererSelectors } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { appActions } from '../../../../common/store/app'; interface Props { timelineId?: string; @@ -33,7 +34,7 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P [] ); const existingIndexNames = useDeepEqualSelector(existingIndexNamesSelector); - const { timelineFullScreen, setTimelineFullScreen } = useFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); const createTimeline = useCallback( ({ id, show }) => { @@ -57,6 +58,7 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P ); dispatch(inputsActions.addGlobalLinkTo({ linkToId: 'timeline' })); dispatch(inputsActions.addTimelineLinkTo({ linkToId: 'global' })); + dispatch(appActions.addNotes({ notes: [] })); if (globalTimeRange.kind === 'absolute') { dispatch( inputsActions.setAbsoluteRangeDatePicker({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index c9355797193a0..20f5b61457e9f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -261,6 +261,7 @@ In other use cases the message field can be used to concatenate different values } end="2018-03-24T03:33:52.253Z" eventType="all" + expandedEvent={Object {}} filters={Array []} isLive={false} itemsPerPage={5} @@ -273,6 +274,7 @@ In other use cases the message field can be used to concatenate different values } kqlMode="search" kqlQueryExpression="" + onEventClosed={[MockFunction]} showCallOutUnauthorizedMsg={false} showEventDetails={false} sort={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 7e60461a01574..d045cc6160c9c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -94,6 +94,7 @@ describe('Timeline', () => { columns: defaultHeaders, dataProviders: mockDataProviders, end: endDate, + expandedEvent: {}, eventType: 'all', showEventDetails: false, filters: [], @@ -103,6 +104,7 @@ describe('Timeline', () => { itemsPerPageOptions: [5, 10, 20], kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], kqlQueryExpression: '', + onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, sort, start: startDate, @@ -143,7 +145,7 @@ describe('Timeline', () => { expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); }); - test('it does NOT render the timeline table when the source is loading', () => { + test('it does render the timeline table when the source is loading with no events', () => { (useSourcererScope as jest.Mock).mockReturnValue({ browserFields: {}, docValueFields: [], @@ -157,7 +159,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the timeline table when start is empty', () => { @@ -167,7 +170,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the timeline table when end is empty', () => { @@ -177,7 +181,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the paging footer when you do NOT have any data providers', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 69a7299b9833d..e93d23a816911 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -12,16 +12,18 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiSpacer, + EuiBadge, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import styled from 'styled-components'; import { Dispatch } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { InPortal } from 'react-reverse-portal'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { Direction } from '../../../../../common/search_strategy'; +import { Direction, TimelineItem } from '../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../containers/index'; import { useKibana } from '../../../../common/lib/kibana'; import { defaultHeaders } from '../body/column_headers/default_headers'; @@ -32,7 +34,7 @@ import { combineQueries } from '../helpers'; import { TimelineRefetch } from '../refetch_timeline'; import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../../manage_timeline'; -import { TimelineEventsType } from '../../../../../common/types/timeline'; +import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; @@ -42,9 +44,14 @@ import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; +import { HideShowContainer } from '../styles'; +import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; +import { activeTimeline } from '../../../containers/active_timeline_context'; +import { ToggleExpandedEvent } from '../../../store/timeline/actions'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -125,6 +132,10 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)` StyledEuiTabbedContent.displayName = 'StyledEuiTabbedContent'; +const EventsCountBadge = styled(EuiBadge)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + const isTimerangeSame = (prevProps: Props, nextProps: Props) => prevProps.end === nextProps.end && prevProps.start === nextProps.start && @@ -134,6 +145,8 @@ interface OwnProps { timelineId: string; } +const EMPTY_EVENTS: TimelineItem[] = []; + export type Props = OwnProps & PropsFromRedux; export const QueryTabContentComponent: React.FC = ({ @@ -141,6 +154,7 @@ export const QueryTabContentComponent: React.FC = ({ dataProviders, end, eventType, + expandedEvent, filters, timelineId, isLive, @@ -148,6 +162,7 @@ export const QueryTabContentComponent: React.FC = ({ itemsPerPageOptions, kqlMode, kqlQueryExpression, + onEventClosed, showCallOutUnauthorizedMsg, showEventDetails, start, @@ -156,18 +171,8 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, updateEventTypeAndIndexesName, }) => { - const [showEventDetailsColumn, setShowEventDetailsColumn] = useState(false); - - useEffect(() => { - // it should changed only once to true and then stay visible till the component umount - setShowEventDetailsColumn((current) => { - if (showEventDetails && !current) { - return true; - } - return current; - }); - }, [showEventDetails]); - + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + const { timelineFullScreen } = useTimelineFullScreen(); const { browserFields, docValueFields, @@ -182,6 +187,10 @@ export const QueryTabContentComponent: React.FC = ({ const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ kqlQueryExpression, ]); + + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = useMemo( () => combineQueries({ @@ -196,6 +205,11 @@ export const QueryTabContentComponent: React.FC = ({ [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] ); + const isBlankTimeline: boolean = useMemo( + () => isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query), + [dataProviders, filters, kqlQuery] + ); + const canQueryTimeline = useMemo( () => combinedQueries != null && @@ -247,12 +261,33 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); + const handleOnEventClosed = useCallback(() => { + onEventClosed({ timelineId }); + + if (timelineId === TimelineId.active) { + activeTimeline.toggleExpandedEvent({ + eventId: expandedEvent.eventId!, + indexName: expandedEvent.indexName!, + }); + } + }, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]); + useEffect(() => { setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); + useEffect(() => { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; + handleOnEventClosed(); + } + }, [combinedQueries, handleOnEventClosed]); + return ( <> + + {totalCount >= 0 ? {totalCount} : null} + = ({ /> - - - - - - - + + + + + + + + + +
+ + + +
+ + -
-
-
- - - -
- - +
+ + + + - - - {canQueryTimeline ? ( - - - - - + + + {!isBlankTimeline && (