diff --git a/.buildkite/scripts/steps/es_snapshots/build.sh b/.buildkite/scripts/steps/es_snapshots/build.sh index c11f041836413..cdc1750e59bfc 100755 --- a/.buildkite/scripts/steps/es_snapshots/build.sh +++ b/.buildkite/scripts/steps/es_snapshots/build.sh @@ -69,6 +69,7 @@ echo "--- Build Elasticsearch" :distribution:archives:darwin-aarch64-tar:assemble \ :distribution:archives:darwin-tar:assemble \ :distribution:docker:docker-export:assemble \ + :distribution:docker:cloud-docker-export:assemble \ :distribution:archives:linux-aarch64-tar:assemble \ :distribution:archives:linux-tar:assemble \ :distribution:archives:windows-zip:assemble \ @@ -79,11 +80,26 @@ find distribution -type f \( -name 'elasticsearch-*-*-*-*.tar.gz' -o -name 'elas ls -alh "$destination" -echo "--- Create docker image archives" +echo "--- Create docker default image archives" docker images "docker.elastic.co/elasticsearch/elasticsearch" docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 echo 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' +echo "--- Create kibana-ci docker cloud image archives" +ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") +ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") +KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" +KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" + +docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" + +echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co +trap 'docker logout docker.elastic.co' EXIT +docker image push "$KIBANA_ES_CLOUD_IMAGE" + +export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" +export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" + echo "--- Create checksums for snapshot files" cd "$destination" find ./* -exec bash -c "shasum -a 512 {} > {}.sha512" \; diff --git a/.buildkite/scripts/steps/es_snapshots/create_manifest.js b/.buildkite/scripts/steps/es_snapshots/create_manifest.js index cb4ea29a9c534..9357cd72fff06 100644 --- a/.buildkite/scripts/steps/es_snapshots/create_manifest.js +++ b/.buildkite/scripts/steps/es_snapshots/create_manifest.js @@ -16,6 +16,8 @@ const { BASE_BUCKET_DAILY } = require('./bucket_config.js'); const destination = process.argv[2] || __dirname + '/test'; const ES_BRANCH = process.env.ELASTICSEARCH_BRANCH; + const ES_CLOUD_IMAGE = process.env.ELASTICSEARCH_CLOUD_IMAGE; + const ES_CLOUD_IMAGE_CHECKSUM = process.env.ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM; const GIT_COMMIT = process.env.ELASTICSEARCH_GIT_COMMIT; const GIT_COMMIT_SHORT = process.env.ELASTICSEARCH_GIT_COMMIT_SHORT; @@ -59,6 +61,17 @@ const { BASE_BUCKET_DAILY } = require('./bucket_config.js'); }; }); + if (ES_CLOUD_IMAGE && ES_CLOUD_IMAGE_CHECKSUM) { + manifestEntries.push({ + checksum: ES_CLOUD_IMAGE_CHECKSUM, + url: ES_CLOUD_IMAGE, + version: VERSION, + platform: 'docker', + architecture: 'image', + license: 'default', + }); + } + const manifest = { id: SNAPSHOT_ID, bucket: `${BASE_BUCKET_DAILY}/${DESTINATION}`.toString(), diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index cadf0f91b01d6..eeb8ff3753f13 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -25,5 +25,5 @@ export interface ApplicationStart | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns the absolute path (or URL) to a given app, including the global base path.By default, it returns the absolute path of the application (e.g /basePath/app/my-app). Use the absolute option to generate an absolute url instead (e.g http://host:port/basePath/app/my-app)Note that when generating absolute urls, the origin (protocol, host and port) are determined from the browser's current location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | -| [navigateToUrl(url)](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | Navigate to given URL in a SPA friendly way when possible (when the URL will redirect to a valid application within the current basePath).The method resolves pathnames the same way browsers do when resolving a <a href> value. The provided url can be: - an absolute URL - an absolute path - a path relative to the current URL (window.location.href)If all these criteria are true for the given URL: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The resolved pathname of the provided URL/path starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app// or any application's appRoute configuration)Then a SPA navigation will be performed using navigateToApp using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using window.location.assign | +| [navigateToUrl(url, options)](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | Navigate to given URL in a SPA friendly way when possible (when the URL will redirect to a valid application within the current basePath).The method resolves pathnames the same way browsers do when resolving a <a href> value. The provided url can be: - an absolute URL - an absolute path - a path relative to the current URL (window.location.href)If all these criteria are true for the given URL: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The resolved pathname of the provided URL/path starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app// or any application's appRoute configuration)Then a SPA navigation will be performed using navigateToApp using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using window.location.assign | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md index 9e6644e2b1ca7..b7fbb12f12e29 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md @@ -15,7 +15,7 @@ Then a SPA navigation will be performed using `navigateToApp` using the correspo Signature: ```typescript -navigateToUrl(url: string): Promise; +navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise; ``` ## Parameters @@ -23,6 +23,7 @@ navigateToUrl(url: string): Promise; | Parameter | Type | Description | | --- | --- | --- | | url | string | an absolute URL, an absolute path or a relative path, to navigate to. | +| options | NavigateToUrlOptions | | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 2e51a036dfe9f..241cd378ebcda 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -85,6 +85,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | | [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) | Options for the [navigateToApp API](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | +| [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) | Options for the [navigateToUrl API](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | | | [OverlayBannersStart](./kibana-plugin-core-public.overlaybannersstart.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.md b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.md index c8ec5bdaf8c0d..337e9db1f80d2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.md @@ -20,5 +20,6 @@ export interface NavigateToAppOptions | [openInNewTab?](./kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md) | boolean | (Optional) if true, will open the app in new tab, will share session information via window.open if base | | [path?](./kibana-plugin-core-public.navigatetoappoptions.path.md) | string | (Optional) optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md) as default. | | [replace?](./kibana-plugin-core-public.navigatetoappoptions.replace.md) | boolean | (Optional) if true, will not create a new history entry when navigating (using replace instead of push) | +| [skipAppLeave?](./kibana-plugin-core-public.navigatetoappoptions.skipappleave.md) | boolean | (Optional) if true, will bypass the default onAppLeave behavior | | [state?](./kibana-plugin-core-public.navigatetoappoptions.state.md) | unknown | (Optional) optional state to forward to the application | diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.skipappleave.md b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.skipappleave.md new file mode 100644 index 0000000000000..553d557a92daa --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.skipappleave.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) > [skipAppLeave](./kibana-plugin-core-public.navigatetoappoptions.skipappleave.md) + +## NavigateToAppOptions.skipAppLeave property + +if true, will bypass the default onAppLeave behavior + +Signature: + +```typescript +skipAppLeave?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.forceredirect.md b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.forceredirect.md new file mode 100644 index 0000000000000..1603524322dd7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.forceredirect.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) > [forceRedirect](./kibana-plugin-core-public.navigatetourloptions.forceredirect.md) + +## NavigateToUrlOptions.forceRedirect property + +if true, will redirect directly to the url + +Signature: + +```typescript +forceRedirect?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.md b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.md new file mode 100644 index 0000000000000..ccf09e21189ef --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) + +## NavigateToUrlOptions interface + +Options for the [navigateToUrl API](./kibana-plugin-core-public.applicationstart.navigatetourl.md) + +Signature: + +```typescript +export interface NavigateToUrlOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [forceRedirect?](./kibana-plugin-core-public.navigatetourloptions.forceredirect.md) | boolean | (Optional) if true, will redirect directly to the url | +| [skipAppLeave?](./kibana-plugin-core-public.navigatetourloptions.skipappleave.md) | boolean | (Optional) if true, will bypass the default onAppLeave behavior | + diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.skipappleave.md b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.skipappleave.md new file mode 100644 index 0000000000000..f3685c02ff40d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetourloptions.skipappleave.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) > [skipAppLeave](./kibana-plugin-core-public.navigatetourloptions.skipappleave.md) + +## NavigateToUrlOptions.skipAppLeave property + +if true, will bypass the default onAppLeave behavior + +Signature: + +```typescript +skipAppLeave?: boolean; +``` diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 70500b26c16e6..81db72be0fb38 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -5,7 +5,7 @@ ServiceNow SecOps ++++ -The {sn} SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create {sn} security incidents. +The {sn} SecOps connector uses the https://developer.servicenow.com/dev.do#!/reference/api/sandiego/rest/c_ImportSetAPI[Import Set API] to create {sn} security incidents. [float] [[servicenow-sir-connector-prerequisites]] diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 73e3baaca2ad1..333a26c075c49 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -5,7 +5,7 @@ ServiceNow ITSM ++++ -The {sn} ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create {sn} incidents. +The {sn} ITSM connector uses the https://developer.servicenow.com/dev.do#!/reference/api/sandiego/rest/c_ImportSetAPI[Import Set API] to create {sn} incidents. [float] [[servicenow-itsm-connector-prerequisites]] diff --git a/package.json b/package.json index 76a84675c2eb2..bb5a1ac61bb5d 100644 --- a/package.json +++ b/package.json @@ -753,7 +753,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^99.0.0", + "chromedriver": "^100.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 1d9406ac37447..7d209035ab65a 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -242,6 +242,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`, deprecationLogging: `${ELASTICSEARCH_DOCS}logging.html#deprecation-logging`, + createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`, frozenIndices: `${ELASTICSEARCH_DOCS}frozen-indices.html`, gettingStarted: `${ELASTICSEARCH_DOCS}getting-started.html`, hiddenIndices: `${ELASTICSEARCH_DOCS}multi-index.html#hidden`, diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 9216f5b21d7f5..557ac980a14c6 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -95,6 +95,23 @@ export const LazyIconButtonGroup = React.lazy(() => */ export const IconButtonGroup = withSuspense(LazyIconButtonGroup); +/** + * The lazily loaded `KibanaPageTemplateSolutionNav` component that is wrapped by the `withSuspense` HOC. Consumers should use + * `React.Suspense` or `withSuspense` HOC to load this component. + */ +export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => + import('./page_template/solution_nav').then(({ KibanaPageTemplateSolutionNav }) => ({ + default: KibanaPageTemplateSolutionNav, + })) +); + +/** + * A `KibanaPageTemplateSolutionNav` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); + /** * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or * the withSuspense` HOC to load this component. diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap new file mode 100644 index 0000000000000..fce0e996d99cd --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KibanaPageTemplateSolutionNav accepts EuiSideNavProps 1`] = ` + + + + Solution + + + } + isOpenOnMobile={false} + items={ + Array [ + Object { + "id": "1", + "items": Array [ + Object { + "id": "1.1", + "items": undefined, + "name": "Ingest Node Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.2", + "items": undefined, + "name": "Logstash Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.3", + "items": undefined, + "name": "Beats Central Management", + "tabIndex": undefined, + }, + ], + "name": "Ingest", + "tabIndex": undefined, + }, + Object { + "id": "2", + "items": Array [ + Object { + "id": "2.1", + "items": undefined, + "name": "Index Management", + "tabIndex": undefined, + }, + Object { + "id": "2.2", + "items": undefined, + "name": "Index Lifecycle Policies", + "tabIndex": undefined, + }, + Object { + "id": "2.3", + "items": undefined, + "name": "Snapshot and Restore", + "tabIndex": undefined, + }, + ], + "name": "Data", + "tabIndex": undefined, + }, + ] + } + mobileTitle={ + + + + } + toggleOpenOnMobile={[Function]} + /> + +`; + +exports[`KibanaPageTemplateSolutionNav renders 1`] = ` + + + + Solution + + + } + isOpenOnMobile={false} + items={ + Array [ + Object { + "id": "1", + "items": Array [ + Object { + "id": "1.1", + "items": undefined, + "name": "Ingest Node Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.2", + "items": undefined, + "name": "Logstash Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.3", + "items": undefined, + "name": "Beats Central Management", + "tabIndex": undefined, + }, + ], + "name": "Ingest", + "tabIndex": undefined, + }, + Object { + "id": "2", + "items": Array [ + Object { + "id": "2.1", + "items": undefined, + "name": "Index Management", + "tabIndex": undefined, + }, + Object { + "id": "2.2", + "items": undefined, + "name": "Index Lifecycle Policies", + "tabIndex": undefined, + }, + Object { + "id": "2.3", + "items": undefined, + "name": "Snapshot and Restore", + "tabIndex": undefined, + }, + ], + "name": "Data", + "tabIndex": undefined, + }, + ] + } + mobileTitle={ + + + + } + toggleOpenOnMobile={[Function]} + /> + +`; + +exports[`KibanaPageTemplateSolutionNav renders with icon 1`] = ` + + + + + Solution + + + } + isOpenOnMobile={false} + items={ + Array [ + Object { + "id": "1", + "items": Array [ + Object { + "id": "1.1", + "items": undefined, + "name": "Ingest Node Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.2", + "items": undefined, + "name": "Logstash Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.3", + "items": undefined, + "name": "Beats Central Management", + "tabIndex": undefined, + }, + ], + "name": "Ingest", + "tabIndex": undefined, + }, + Object { + "id": "2", + "items": Array [ + Object { + "id": "2.1", + "items": undefined, + "name": "Index Management", + "tabIndex": undefined, + }, + Object { + "id": "2.2", + "items": undefined, + "name": "Index Lifecycle Policies", + "tabIndex": undefined, + }, + Object { + "id": "2.3", + "items": undefined, + "name": "Snapshot and Restore", + "tabIndex": undefined, + }, + ], + "name": "Data", + "tabIndex": undefined, + }, + ] + } + mobileTitle={ + + + + + } + toggleOpenOnMobile={[Function]} + /> + +`; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav_collapse_button.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav_collapse_button.test.tsx.snap new file mode 100644 index 0000000000000..d2548b3e8df43 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav_collapse_button.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KibanaPageTemplateSolutionNavCollapseButton isCollapsed 1`] = ` + +`; + +exports[`KibanaPageTemplateSolutionNavCollapseButton renders 1`] = ` + +`; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/index.ts b/packages/kbn-shared-ux-components/src/page_template/solution_nav/index.ts new file mode 100644 index 0000000000000..59ef2924b048d --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/index.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { KibanaPageTemplateSolutionNavProps } from './solution_nav'; +export { KibanaPageTemplateSolutionNav } from './solution_nav'; +export type { KibanaPageTemplateSolutionNavCollapseButtonProps } from './solution_nav_collapse_button'; +export { KibanaPageTemplateSolutionNavCollapseButton } from './solution_nav_collapse_button'; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss new file mode 100644 index 0000000000000..d0070cef729b7 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss @@ -0,0 +1,30 @@ +$euiSideNavEmphasizedBackgroundColor: transparentize($euiColorLightShade, .7); +@import '@elastic/eui/src/components/side_nav/mixins'; + +// Put the page background color in the flyout version too +.kbnPageTemplateSolutionNav__flyout { + background-color: $euiPageBackgroundColor; +} + +.kbnPageTemplateSolutionNav { + @include euiSideNavEmbellish; + @include euiYScroll; + + @include euiBreakpoint('m' ,'l', 'xl') { + width: 248px; + padding: $euiSizeL; + } + + .kbnPageTemplateSolutionNavAvatar { + margin-right: $euiSize; + } +} + +.kbnPageTemplateSolutionNav--hidden { + pointer-events: none; + opacity: 0; + + @include euiCanAnimate { + transition: opacity $euiAnimSpeedFast $euiAnimSlightResistance; + } +} diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx new file mode 100644 index 0000000000000..5ff1e2c07d9d8 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { KibanaPageTemplateSolutionNav, KibanaPageTemplateSolutionNavProps } from './solution_nav'; + +export default { + title: 'Page Template/Solution Nav/Solution Nav', + description: 'Solution-specific navigation for the sidebar', +}; + +type Params = Pick; + +const items: KibanaPageTemplateSolutionNavProps['items'] = [ + { + name:
Ingest
, + id: '1', + items: [ + { + name: 'Ingest Node Pipelines', + id: '1.1', + }, + { + name: 'Logstash Pipelines', + id: '1.2', + }, + { + name: 'Beats Central Management', + id: '1.3', + }, + ], + }, + { + name: 'Data', + id: '2', + items: [ + { + name: 'Index Management', + id: '2.1', + }, + { + name: 'Index Lifecycle Policies', + id: '2.2', + }, + { + name: 'Snapshot and Restore', + id: '2.3', + }, + ], + }, +]; + +export const PureComponent = (params: Params) => { + return ; +}; + +PureComponent.argTypes = { + name: { + control: 'text', + defaultValue: 'Kibana', + }, + icon: { + control: { type: 'radio' }, + options: ['logoKibana', 'logoObservability', 'logoSecurity'], + defaultValue: 'logoKibana', + }, +}; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx new file mode 100644 index 0000000000000..ed90894289169 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { KibanaPageTemplateSolutionNav, KibanaPageTemplateSolutionNavProps } from './solution_nav'; + +jest.mock('@elastic/eui', () => ({ + useIsWithinBreakpoints: (args: string[]) => { + return args[0] === 'xs'; + }, +})); + +const items: KibanaPageTemplateSolutionNavProps['items'] = [ + { + name: 'Ingest', + id: '1', + items: [ + { + name: 'Ingest Node Pipelines', + id: '1.1', + }, + { + name: 'Logstash Pipelines', + id: '1.2', + }, + { + name: 'Beats Central Management', + id: '1.3', + }, + ], + }, + { + name: 'Data', + id: '2', + items: [ + { + name: 'Index Management', + id: '2.1', + }, + { + name: 'Index Lifecycle Policies', + id: '2.2', + }, + { + name: 'Snapshot and Restore', + id: '2.3', + }, + ], + }, +]; + +describe('KibanaPageTemplateSolutionNav', () => { + test('renders', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + test('renders with icon', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('accepts EuiSideNavProps', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx new file mode 100644 index 0000000000000..8bc91789c7054 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import './solution_nav.scss'; + +import React, { FunctionComponent, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiAvatarProps, + EuiFlyout, + EuiSideNav, + EuiSideNavItemType, + EuiSideNavProps, + useIsWithinBreakpoints, +} from '@elastic/eui'; + +import classNames from 'classnames'; +import { KibanaSolutionAvatar } from '../../solution_avatar'; +import { KibanaPageTemplateSolutionNavCollapseButton } from './solution_nav_collapse_button'; + +export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { + /** + * Name of the solution, i.e. "Observability" + */ + name: EuiAvatarProps['name']; + /** + * Solution logo, i.e. "logoObservability" + */ + icon?: EuiAvatarProps['iconType']; + /** + * Control the collapsed state + */ + isOpenOnDesktop?: boolean; + onCollapse?: () => void; +}; + +const FLYOUT_SIZE = 248; + +const setTabIndex = (items: Array>, isHidden: boolean) => { + return items.map((item) => { + // @ts-ignore-next-line Can be removed on close of https://github.com/elastic/eui/issues/4925 + item.tabIndex = isHidden ? -1 : undefined; + item.items = item.items && setTabIndex(item.items, isHidden); + return item; + }); +}; + +/** + * A wrapper around EuiSideNav but also creates the appropriate title with optional solution logo + */ +export const KibanaPageTemplateSolutionNav: FunctionComponent< + KibanaPageTemplateSolutionNavProps +> = ({ name, icon, items, isOpenOnDesktop = false, onCollapse, ...rest }) => { + const isSmallerBreakpoint = useIsWithinBreakpoints(['xs', 's']); + const isMediumBreakpoint = useIsWithinBreakpoints(['m']); + const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); + + // This is used for both the EuiSideNav and EuiFlyout toggling + const [isSideNavOpenOnMobile, setIsSideNavOpenOnMobile] = useState(false); + const toggleOpenOnMobile = () => { + setIsSideNavOpenOnMobile(!isSideNavOpenOnMobile); + }; + + const isHidden = isLargerBreakpoint && !isOpenOnDesktop; + + /** + * Create the avatar + */ + const solutionAvatar = icon ? ( + + ) : null; + + /** + * Create the titles + */ + const titleText = ( + <> + {solutionAvatar} + {name} + + ); + const mobileTitleText = ( + + ); + + /** + * Create the side nav component + */ + + const sideNav = () => { + if (!items) { + return null; + } + const sideNavClasses = classNames('kbnPageTemplateSolutionNav', { + 'kbnPageTemplateSolutionNav--hidden': isHidden, + }); + return ( + + {solutionAvatar} + {mobileTitleText} + + } + toggleOpenOnMobile={toggleOpenOnMobile} + isOpenOnMobile={isSideNavOpenOnMobile} + items={setTabIndex(items, isHidden)} + {...rest} + /> + ); + }; + + return ( + <> + {isSmallerBreakpoint && sideNav()} + {isMediumBreakpoint && ( + <> + {isSideNavOpenOnMobile && ( + setIsSideNavOpenOnMobile(false)} + side="left" + size={FLYOUT_SIZE} + closeButtonPosition="outside" + className="kbnPageTemplateSolutionNav__flyout" + > + {sideNav()} + + )} + + + )} + {isLargerBreakpoint && ( + <> + {sideNav()} + + + )} + + ); +}; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.scss b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.scss new file mode 100644 index 0000000000000..61cea7962d956 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.scss @@ -0,0 +1,47 @@ +.kbnPageTemplateSolutionNavCollapseButton { + position: absolute; + opacity: 0; + left: 248px - $euiSize; + top: $euiSizeL; + z-index: 2; + + @include euiCanAnimate { + transition: opacity $euiAnimSpeedFast, left $euiAnimSpeedFast, background $euiAnimSpeedFast; + } + + &:hover, + &:focus { + transition-delay: 0s !important; + } + + .kbnPageTemplate__pageSideBar:hover &, + &:hover, + &:focus { + opacity: 1; + left: 248px - $euiSizeL; + } + + .kbnPageTemplate__pageSideBar:hover & { + transition-delay: $euiAnimSpeedSlow * 2; + } + + &:not(&-isCollapsed) { + background-color: $euiColorEmptyShade !important; // Override all states + } +} + +// Make the button take up the entire area of the collapsed navigation +.kbnPageTemplateSolutionNavCollapseButton-isCollapsed { + opacity: 1 !important; + transition-delay: 0s !important; + left: 0 !important; + right: 0; + top: 0; + bottom: 0; + height: 100%; + width: 100%; + border-radius: 0; + // Keep the icon at the top instead of it getting shifted to the center of the page + padding-top: $euiSizeL + $euiSizeS; + align-items: flex-start; +} diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.test.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.test.tsx new file mode 100644 index 0000000000000..e7df2ddd54582 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { KibanaPageTemplateSolutionNavCollapseButton } from './solution_nav_collapse_button'; + +describe('KibanaPageTemplateSolutionNavCollapseButton', () => { + test('renders', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + expect(component.find('.kbnPageTemplateSolutionNavCollapseButton').prop('title')).toBe( + 'Collapse side navigation' + ); + }); + + test('isCollapsed', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + expect(component.find('.kbnPageTemplateSolutionNavCollapseButton').prop('title')).toBe( + 'Open side navigation' + ); + }); +}); diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.tsx new file mode 100644 index 0000000000000..35890b935ad3e --- /dev/null +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav_collapse_button.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import './solution_nav_collapse_button.scss'; + +import React from 'react'; +import classNames from 'classnames'; + +import { EuiButtonIcon, EuiButtonIconPropsForButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export type KibanaPageTemplateSolutionNavCollapseButtonProps = + Partial & { + /** + * Boolean state of current collapsed status + */ + isCollapsed: boolean; + }; + +const collapseLabel = i18n.translate('sharedUXComponents.solutionNav.collapsibleLabel', { + defaultMessage: 'Collapse side navigation', +}); + +const openLabel = i18n.translate('sharedUXComponents.solutionNav.openLabel', { + defaultMessage: 'Open side navigation', +}); + +/** + * Creates the styled icon button for showing/hiding solution nav + */ +export const KibanaPageTemplateSolutionNavCollapseButton = ({ + className, + isCollapsed, + ...rest +}: KibanaPageTemplateSolutionNavCollapseButtonProps) => { + const classes = classNames( + 'kbnPageTemplateSolutionNavCollapseButton', + { + 'kbnPageTemplateSolutionNavCollapseButton-isCollapsed': isCollapsed, + }, + className + ); + + return ( + + ); +}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx index db31c0fd5a3d4..efc597cbdcb13 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx +++ b/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx @@ -5,5 +5,5 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - export { KibanaSolutionAvatar } from './solution_avatar'; +export type { KibanaSolutionAvatarProps } from './solution_avatar'; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx index 78459b90e4b3b..deb71affc9c1a 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx +++ b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx @@ -8,9 +8,9 @@ import './solution_avatar.scss'; import React from 'react'; -import classNames from 'classnames'; import { DistributiveOmit, EuiAvatar, EuiAvatarProps } from '@elastic/eui'; +import classNames from 'classnames'; export type KibanaSolutionAvatarProps = DistributiveOmit & { /** @@ -20,7 +20,7 @@ export type KibanaSolutionAvatarProps = DistributiveOmit }; /** - * Applies extra styling to a typical EuiAvatar; + * Applies extra styling to a typical EuiAvatar. * The `name` value will be appended to 'logo' to configure the `iconType` unless `iconType` is provided. */ export const KibanaSolutionAvatar = ({ className, size, ...rest }: KibanaSolutionAvatarProps) => { @@ -34,9 +34,9 @@ export const KibanaSolutionAvatar = ({ className, size, ...rest }: KibanaSolutio }, className )} - color="plain" size={size === 'xxl' ? 'xl' : size} iconSize={size} + color="plain" iconType={`logo${rest.name}`} {...rest} /> diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index ccb0b220e0243..bb7378ff1f0f3 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -898,10 +898,9 @@ 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 shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate'); const { navigateToApp } = await service.start(startDeps); - await navigateToApp('myTestApp'); expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myTestApp'); @@ -909,6 +908,14 @@ describe('#start()', () => { expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myOtherApp'); }); + it('should call private function shouldNavigate with overlays, nextAppId and skipAppLeave', async () => { + service.setup(setupDeps); + const shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate'); + const { navigateToApp } = await service.start(startDeps); + await navigateToApp('myTestApp', { skipAppLeave: true }); + expect(shouldNavigateSpy).not.toHaveBeenCalledWith(startDeps.overlays, 'myTestApp'); + }); + describe('when `replace` option is true', () => { it('use `history.replace` instead of `history.push`', async () => { service.setup(setupDeps); @@ -1117,6 +1124,63 @@ describe('#start()', () => { expect(MockHistory.push).toHaveBeenCalledWith('/app/foo/some-path', undefined); expect(setupDeps.redirectTo).not.toHaveBeenCalled(); }); + + describe('navigateToUrl with options', () => { + let addListenerSpy: jest.SpyInstance; + let removeListenerSpy: jest.SpyInstance; + beforeEach(() => { + addListenerSpy = jest.spyOn(window, 'addEventListener'); + removeListenerSpy = jest.spyOn(window, 'removeEventListener'); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('calls `navigateToApp` with `skipAppLeave` option', async () => { + parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' }); + service.setup(setupDeps); + const { navigateToUrl } = await service.start(startDeps); + + await navigateToUrl('/an-app-path', { skipAppLeave: true }); + + expect(MockHistory.push).toHaveBeenCalledWith('/app/foo/some-path', undefined); + expect(setupDeps.redirectTo).not.toHaveBeenCalled(); + }); + + it('calls `redirectTo` when `forceRedirect` option is true', async () => { + parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' }); + service.setup(setupDeps); + + const { navigateToUrl } = await service.start(startDeps); + + await navigateToUrl('/an-app-path', { forceRedirect: true }); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/an-app-path'); + expect(MockHistory.push).not.toHaveBeenCalled(); + }); + + it('removes the beforeunload listener and calls `redirectTo` when `forceRedirect` and `skipAppLeave` option are both true', async () => { + parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' }); + service.setup(setupDeps); + + const { navigateToUrl } = await service.start(startDeps); + + await navigateToUrl('/an-app-path', { skipAppLeave: true, forceRedirect: true }); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + const handler = addListenerSpy.mock.calls[0][1]; + + expect(MockHistory.push).toHaveBeenCalledTimes(0); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/an-app-path'); + + expect(removeListenerSpy).toHaveBeenCalledTimes(1); + expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', handler); + }); + }); }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 1cfae598f67c8..d49a33e3f1371 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -31,6 +31,7 @@ import { InternalApplicationStart, Mounter, NavigateToAppOptions, + NavigateToUrlOptions, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; import { getUserConfirmationHandler } from './navigation_confirm'; @@ -234,13 +235,19 @@ export class ApplicationService { const navigateToApp: InternalApplicationStart['navigateToApp'] = async ( appId, - { deepLinkId, path, state, replace = false, openInNewTab = false }: NavigateToAppOptions = {} + { + deepLinkId, + path, + state, + replace = false, + openInNewTab = false, + skipAppLeave = false, + }: NavigateToAppOptions = {} ) => { const currentAppId = this.currentAppId$.value; const navigatingToSameApp = currentAppId === appId; - const shouldNavigate = navigatingToSameApp - ? true - : await this.shouldNavigate(overlays, appId); + const shouldNavigate = + navigatingToSameApp || skipAppLeave ? true : await this.shouldNavigate(overlays, appId); const targetApp = applications$.value.get(appId); @@ -304,13 +311,20 @@ export class ApplicationService { return absolute ? relativeToAbsolute(relUrl) : relUrl; }, navigateToApp, - navigateToUrl: async (url) => { + navigateToUrl: async ( + url: string, + { skipAppLeave = false, forceRedirect = false }: NavigateToUrlOptions = {} + ) => { const appInfo = parseAppUrl(url, http.basePath, this.apps); - if (appInfo) { - return navigateToApp(appInfo.app, { path: appInfo.path }); - } else { + if ((forceRedirect || !appInfo) === true) { + if (skipAppLeave) { + window.removeEventListener('beforeunload', this.onBeforeUnload); + } return this.redirectTo!(url); } + if (appInfo) { + return navigateToApp(appInfo.app, { path: appInfo.path, skipAppLeave }); + } }, getComponent: () => { if (!this.history) { diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 882555fcd60e0..55ac8f47becfa 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -28,6 +28,7 @@ export type { AppLeaveDefaultAction, AppLeaveConfirmAction, NavigateToAppOptions, + NavigateToUrlOptions, PublicAppInfo, PublicAppDeepLinkInfo, // Internal types diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index dda029c66f4c3..99e6d86b6a941 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -170,7 +170,28 @@ describe('ApplicationService', () => { '/app/app1/deep-link', ]); }); - //// + + it('handles `skipOnAppLeave` option', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('app1', { path: '/foo' }); + await navigateToApp('app1', { path: '/bar', skipAppLeave: true }); + expect(history.entries.map((entry) => entry.pathname)).toEqual([ + '/', + '/app/app1/foo', + '/app/app1/bar', + ]); + }); }); }); @@ -249,6 +270,38 @@ describe('ApplicationService', () => { expect(history.entries[2].pathname).toEqual('/app/app2'); }); + it('does not trigger the action if `skipAppLeave` is true', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave((actions) => actions.confirm('confirmation-message', 'confirmation-title')); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2', { skipAppLeave: true }); + }); + expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(0); + expect(history.entries.length).toEqual(3); + expect(history.entries[1].pathname).toEqual('/app/app1'); + }); + it('blocks navigation to the new app if action is confirm and user declined', async () => { startDeps.overlays.openConfirm.mockResolvedValue(false); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 659145a9958f1..4e96e96505083 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -740,6 +740,26 @@ export interface NavigateToAppOptions { * if true, will open the app in new tab, will share session information via window.open if base */ openInNewTab?: boolean; + + /** + * if true, will bypass the default onAppLeave behavior + */ + skipAppLeave?: boolean; +} + +/** + * Options for the {@link ApplicationStart.navigateToUrl | navigateToUrl API} + * @public + */ +export interface NavigateToUrlOptions { + /** + * if true, will bypass the default onAppLeave behavior + */ + skipAppLeave?: boolean; + /** + * if true will force a full page reload/refresh/assign, overriding the outcome of other url checks against current the location (effectively using `window.location.assign` instead of `push`) + */ + forceRedirect?: boolean; } /** @public */ @@ -781,7 +801,7 @@ export interface ApplicationStart { * - The pathname segment after the basePath matches any known application route (eg. /app// or any application's `appRoute` configuration) * * Then a SPA navigation will be performed using `navigateToApp` using the corresponding application and path. - * Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign` + * Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign`. * * @example * ```ts @@ -802,8 +822,7 @@ export interface ApplicationStart { * * @param url - an absolute URL, an absolute path or a relative path, to navigate to. */ - navigateToUrl(url: string): Promise; - + navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise; /** * Returns the absolute path (or URL) to a given app, including the global base path. * diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 498efcfd9076e..54adb34550462 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -89,7 +89,7 @@ const overviewIDsToHide = ['kibanaOverview', 'enterpriseSearch']; const overviewIDs = [ ...overviewIDsToHide, 'observability-overview', - 'securitySolutionUI:overview', + 'securitySolutionUI:get_started', 'management', ]; diff --git a/src/core/public/i18n/i18n_eui_mapping.test.ts b/src/core/public/i18n/i18n_eui_mapping.test.ts index aa78345a86de1..a2d35b37ac569 100644 --- a/src/core/public/i18n/i18n_eui_mapping.test.ts +++ b/src/core/public/i18n/i18n_eui_mapping.test.ts @@ -17,6 +17,7 @@ import { getEuiContextMapping } from './i18n_eui_mapping'; const VALUES_REGEXP = /\{\w+\}/; describe('@elastic/eui i18n tokens', () => { + const i18nTranslateActual = jest.requireActual('@kbn/i18n').i18n.translate; const i18nTranslateMock = jest .fn() .mockImplementation((id, { defaultMessage }) => defaultMessage); @@ -74,34 +75,9 @@ describe('@elastic/eui i18n tokens', () => { }); test('defaultMessage is in sync with defString', () => { - // Certain complex tokens (e.g. ones that have a function as a defaultMessage) - // need custom i18n handling, and can't be checked for basic defString equality - const tokensToSkip = [ - 'euiColumnSorting.buttonActive', - 'euiSelectable.searchResults', - 'euiPrettyDuration.lastDurationSeconds', - 'euiPrettyDuration.nextDurationSeconds', - 'euiPrettyDuration.lastDurationMinutes', - 'euiPrettyDuration.nextDurationMinutes', - 'euiPrettyDuration.lastDurationHours', - 'euiPrettyDuration.nextDurationHours', - 'euiPrettyDuration.lastDurationDays', - 'euiPrettyDuration.nexttDurationDays', - 'euiPrettyDuration.lastDurationWeeks', - 'euiPrettyDuration.nextDurationWeeks', - 'euiPrettyDuration.lastDurationMonths', - 'euiPrettyDuration.nextDurationMonths', - 'euiPrettyDuration.lastDurationYears', - 'euiPrettyDuration.nextDurationYears', - 'euiPrettyInterval.seconds', - 'euiPrettyInterval.minutes', - 'euiPrettyInterval.hours', - 'euiPrettyInterval.days', - 'euiPrettyInterval.weeks', - 'euiPrettyInterval.months', - 'euiPrettyInterval.years', - ]; - if (tokensToSkip.includes(token)) return; + const isDefFunction = defString.includes('}) =>'); + const isPluralizationDefFunction = + defString.includes(' === 1 ?') || defString.includes(' > 1 ?'); // Clean up typical errors from the `@elastic/eui` extraction token tool const normalizedDefString = defString @@ -114,7 +90,38 @@ describe('@elastic/eui i18n tokens', () => { .replace(/\s{2,}/g, ' ') .trim(); - expect(i18nTranslateCall[1].defaultMessage).toBe(normalizedDefString); + if (!isDefFunction) { + expect(i18nTranslateCall[1].defaultMessage).toBe(normalizedDefString); + } else { + // Certain EUI defStrings are actually functions (that currently primarily handle + // pluralization). To check EUI's pluralization against Kibana's pluralization, we + // need to eval the defString and then actually i18n.translate & compare the 2 outputs + const defFunction = eval(defString); // eslint-disable-line no-eval + const defFunctionArg = normalizedDefString.split('({ ')[1].split('})')[0]; // TODO: All EUI pluralization fns currently only pass 1 arg. If this changes in the future and 2 args are passed, we'll need to do some extra splitting by ',' + + if (isPluralizationDefFunction) { + const singularValue = { [defFunctionArg]: 1 }; + expect( + i18nTranslateActual(token, { + defaultMessage: i18nTranslateCall[1].defaultMessage, + values: singularValue, + }) + ).toEqual(defFunction(singularValue)); + + const pluralValue = { [defFunctionArg]: 2 }; + expect( + i18nTranslateActual(token, { + defaultMessage: i18nTranslateCall[1].defaultMessage, + values: pluralValue, + }) + ).toEqual(defFunction(pluralValue)); + } else { + throw new Error( + `We currently only have logic written for EUI pluralization def functions. + This is a new type of def function that will need custom logic written for it.` + ); + } + } }); test('values should match', () => { diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index b58780db3087d..9969f4ee23f57 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -1268,7 +1268,7 @@ export const getEuiContextMapping = (): EuiTokensObject => { ), 'euiSelectable.searchResults': ({ resultsLength }: EuiValues) => i18n.translate('core.euiSelectable.searchResults', { - defaultMessage: '{resultsLength, plural, one {# result} other {# results}}', + defaultMessage: '{resultsLength, plural, one {# result} other {# results}} available', values: { resultsLength }, }), 'euiSelectable.placeholderName': i18n.translate('core.euiSelectable.placeholderName', { diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 3b3bccd7ec18b..d62df68cf827d 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -91,6 +91,7 @@ export type { PublicAppInfo, PublicAppDeepLinkInfo, NavigateToAppOptions, + NavigateToUrlOptions, } from './application'; export { SimpleSavedObject } from './saved_objects'; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 44224e6fcaea7..b60e26d282dc3 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -160,7 +160,7 @@ export interface ApplicationStart { deepLinkId?: string; }): string; navigateToApp(appId: string, options?: NavigateToAppOptions): Promise; - navigateToUrl(url: string): Promise; + navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise; } // @public @@ -778,9 +778,16 @@ export interface NavigateToAppOptions { openInNewTab?: boolean; path?: string; replace?: boolean; + skipAppLeave?: boolean; state?: unknown; } +// @public +export interface NavigateToUrlOptions { + forceRedirect?: boolean; + skipAppLeave?: boolean; +} + // Warning: (ae-missing-release-tag) "NavType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts b/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts index 0f4522b156fe7..ea70478d6ce7b 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts @@ -113,8 +113,8 @@ describe('unsupported_cluster_routing_allocation', () => { await root.preboot(); await root.setup(); - await expect(root.start()).rejects.toMatchInlineSnapshot( - `[Error: Unable to complete saved object migrations for the [.kibana] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue. To proceed, please remove the cluster routing allocation settings with PUT /_cluster/settings {"transient": {"cluster.routing.allocation.enable": null}, "persistent": {"cluster.routing.allocation.enable": null}}]` + await expect(root.start()).rejects.toThrowError( + /Unable to complete saved object migrations for the \[\.kibana.*\] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {"transient": {"cluster\.routing\.allocation\.enable": null}, "persistent": {"cluster\.routing\.allocation\.enable": null}}/ ); await retryAsync( @@ -126,8 +126,8 @@ describe('unsupported_cluster_routing_allocation', () => { .map((str) => JSON5.parse(str)) as LogRecord[]; expect( records.find((rec) => - rec.message.startsWith( - `Unable to complete saved object migrations for the [.kibana] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.` + /^Unable to complete saved object migrations for the \[\.kibana.*\] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\./.test( + rec.message ) ) ).toBeDefined(); @@ -148,8 +148,8 @@ describe('unsupported_cluster_routing_allocation', () => { await root.preboot(); await root.setup(); - await expect(root.start()).rejects.toMatchInlineSnapshot( - `[Error: Unable to complete saved object migrations for the [.kibana] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue. To proceed, please remove the cluster routing allocation settings with PUT /_cluster/settings {"transient": {"cluster.routing.allocation.enable": null}, "persistent": {"cluster.routing.allocation.enable": null}}]` + await expect(root.start()).rejects.toThrowError( + /Unable to complete saved object migrations for the \[\.kibana.*\] index: The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {"transient": {"cluster\.routing\.allocation\.enable": null}, "persistent": {"cluster\.routing\.allocation\.enable": null}}/ ); }); }); diff --git a/src/dev/build/tasks/package_json/find_used_dependencies.ts b/src/dev/build/tasks/package_json/find_used_dependencies.ts index 6fb0c0060ecc7..e49d57c6f794f 100644 --- a/src/dev/build/tasks/package_json/find_used_dependencies.ts +++ b/src/dev/build/tasks/package_json/find_used_dependencies.ts @@ -58,7 +58,7 @@ export async function findUsedDependencies(listedPkgDependencies: any, baseDir: const listedDependencies = Object.keys(listedPkgDependencies); const filteredListedDependencies = listedDependencies.filter((entry) => { - return whiteListedModules.some((nonEntry) => entry.includes(nonEntry)); + return whiteListedModules.some((nonEntry) => entry === nonEntry); }); return filteredListedDependencies.reduce((foundUsedDeps: any, usedDep) => { diff --git a/src/plugins/console/server/lib/proxy_request.test.ts b/src/plugins/console/server/lib/proxy_request.test.ts index 3740d7e16a3c9..2bb5e481fbb26 100644 --- a/src/plugins/console/server/lib/proxy_request.test.ts +++ b/src/plugins/console/server/lib/proxy_request.test.ts @@ -9,7 +9,7 @@ import http, { ClientRequest } from 'http'; import * as sinon from 'sinon'; import { proxyRequest } from './proxy_request'; -import { URL, URLSearchParams } from 'url'; +import { URL } from 'url'; import { fail } from 'assert'; describe(`Console's send request`, () => { @@ -102,38 +102,52 @@ describe(`Console's send request`, () => { }); }); - it('should decode percent-encoded uri and encode it correctly', async () => { - fakeRequest = { - abort: sinon.stub(), - on() {}, - once(event: string, fn: (v: string) => void) { - if (event === 'response') { - return fn('done'); - } - }, - } as any; + describe('with percent-encoded uri pathname', () => { + beforeEach(() => { + fakeRequest = { + abort: sinon.stub(), + on() {}, + once(event: string, fn: (v: string) => void) { + if (event === 'response') { + return fn('done'); + } + }, + } as any; + }); - const uri = new URL( - `http://noone.nowhere.none/%{[@metadata][beat]}-%{[@metadata][version]}-2020.08.23` - ); + it('should decode percent-encoded uri pathname and encode it correctly', async () => { + const uri = new URL( + `http://noone.nowhere.none/%{[@metadata][beat]}-%{[@metadata][version]}-2020.08.23` + ); + const result = await proxyRequest({ + agent: null as any, + headers: {}, + method: 'get', + payload: null as any, + timeout: 30000, + uri, + }); - const result = await proxyRequest({ - agent: null as any, - headers: {}, - method: 'get', - payload: null as any, - timeout: 30000, - uri, + expect(result).toEqual('done'); + const [httpRequestOptions] = stub.firstCall.args; + expect((httpRequestOptions as any).path).toEqual( + '/%25%7B%5B%40metadata%5D%5Bbeat%5D%7D-%25%7B%5B%40metadata%5D%5Bversion%5D%7D-2020.08.23' + ); }); - expect(result).toEqual('done'); + it('should issue request with date-math format', async () => { + const result = await proxyRequest({ + agent: null as any, + headers: {}, + method: 'get', + payload: null as any, + timeout: 30000, + uri: new URL(`http://noone.nowhere.none/%3Cmy-index-%7Bnow%2Fd%7D%3E`), + }); - const decoded = new URLSearchParams(`path=${uri.pathname}`).get('path'); - const encoded = decoded - ?.split('/') - .map((str) => encodeURIComponent(str)) - .join('/'); - const [httpRequestOptions] = stub.firstCall.args; - expect((httpRequestOptions as any).path).toEqual(encoded); + expect(result).toEqual('done'); + const [httpRequestOptions] = stub.firstCall.args; + expect((httpRequestOptions as any).path).toEqual('/%3Cmy-index-%7Bnow%2Fd%7D%3E'); + }); }); }); diff --git a/src/plugins/console/server/lib/proxy_request.ts b/src/plugins/console/server/lib/proxy_request.ts index a1b34e08b916e..c4fbfd315da4e 100644 --- a/src/plugins/console/server/lib/proxy_request.ts +++ b/src/plugins/console/server/lib/proxy_request.ts @@ -12,6 +12,7 @@ import net from 'net'; import stream from 'stream'; import Boom from '@hapi/boom'; import { URL, URLSearchParams } from 'url'; +import { trimStart } from 'lodash'; interface Args { method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; @@ -38,10 +39,12 @@ const sanitizeHostname = (hostName: string): string => const encodePathname = (pathname: string) => { const decodedPath = new URLSearchParams(`path=${pathname}`).get('path') ?? ''; - return decodedPath - .split('/') - .map((str) => encodeURIComponent(str)) - .join('/'); + // Skip if it is valid + if (pathname === decodedPath) { + return pathname; + } + + return `/${encodeURIComponent(trimStart(decodedPath, '/'))}`; }; // We use a modified version of Hapi's Wreck because Hapi, Axios, and Superagent don't support GET requests diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 9f02ff3e970d0..7ae0416feac2e 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -16,6 +16,7 @@ import { ControlGroupStrings } from '../control_group_strings'; import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; import { toMountPoint } from '../../../../kibana_react/public'; import { DEFAULT_CONTROL_WIDTH } from '../../../common/control_group/control_group_constants'; +import { setFlyoutRef } from '../embeddable/control_group_container'; export type CreateControlButtonTypes = 'toolbar' | 'callout'; export interface CreateControlButtonProps { @@ -99,9 +100,13 @@ export const CreateControlButton = ({ ), { outsideClickCloses: false, - onClose: (flyout) => onCancel(flyout), + onClose: (flyout) => { + onCancel(flyout); + setFlyoutRef(undefined); + }, } ); + setFlyoutRef(flyoutInstance); }); initialInputPromise.then( diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index 5b7177a64c633..7c114350f3679 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -20,7 +20,7 @@ import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public'; import { useReduxContainerContext } from '../../../../presentation_util/public'; -import { ControlGroupContainer } from '../embeddable/control_group_container'; +import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Controls Services Context @@ -127,9 +127,13 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => ), { outsideClickCloses: false, - onClose: (flyout) => onCancel(flyout), + onClose: (flyout) => { + setFlyoutRef(undefined); + onCancel(flyout); + }, } ); + setFlyoutRef(flyoutInstance); }; return ( diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index dcf955666657f..8d9f81637ef65 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -15,6 +15,7 @@ import { ControlGroupEditor } from './control_group_editor'; import { OverlayRef } from '../../../../../core/public'; import { pluginServices } from '../../services'; import { ControlGroupContainer } from '..'; +import { setFlyoutRef } from '../embeddable/control_group_container'; export interface EditControlGroupButtonProps { controlGroupContainer: ControlGroupContainer; @@ -60,9 +61,13 @@ export const EditControlGroup = ({ ), { outsideClickCloses: false, - onClose: () => flyoutInstance.close(), + onClose: () => { + flyoutInstance.close(); + setFlyoutRef(undefined); + }, } ); + setFlyoutRef(flyoutInstance); }; const commonButtonProps = { diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 7abcfbb5af6a3..5ee41946a14aa 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -46,11 +46,17 @@ import { Container, EmbeddableFactory } from '../../../../embeddable/public'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; import { ControlGroupChainingSystems } from './control_group_chaining_system'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; +import { OverlayRef } from '../../../../../core/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); +let flyoutRef: OverlayRef | undefined; +export const setFlyoutRef = (newRef: OverlayRef | undefined) => { + flyoutRef = newRef; +}; + export interface ChildEmbeddableOrderCache { IdsToOrder: { [key: string]: number }; idsInOrder: string[]; @@ -96,6 +102,11 @@ export class ControlGroupContainer extends Container< return this.lastUsedDataViewId ?? this.relevantDataViewId; }; + public closeAllFlyouts() { + flyoutRef?.close(); + flyoutRef = undefined; + } + /** * Returns a button that allows controls to be created externally using the embeddable * @param buttonType Controls the button styling @@ -367,6 +378,7 @@ export class ControlGroupContainer extends Container< public destroy() { super.destroy(); + this.closeAllFlyouts(); this.subscriptions.unsubscribe(); if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); } 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 index e66525398b86b..23bc301f788c0 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -210,16 +210,17 @@ export function DashboardTopNav({ [stateTransferService, data.search.session, trackUiMetric] ); - const clearAddPanel = useCallback(() => { + const closeAllFlyouts = useCallback(() => { + dashboardAppState.dashboardContainer.controlGroup?.closeAllFlyouts(); if (state.addPanelOverlay) { state.addPanelOverlay.close(); setState((s) => ({ ...s, addPanelOverlay: undefined })); } - }, [state.addPanelOverlay]); + }, [state.addPanelOverlay, dashboardAppState.dashboardContainer.controlGroup]); const onChangeViewMode = useCallback( (newMode: ViewMode) => { - clearAddPanel(); + closeAllFlyouts(); const willLoseChanges = newMode === ViewMode.VIEW && dashboardAppState.hasUnsavedChanges; if (!willLoseChanges) { @@ -231,7 +232,7 @@ export function DashboardTopNav({ dashboardAppState.resetToLastSavedState?.() ); }, - [clearAddPanel, core.overlays, dashboardAppState, dispatchDashboardStateChange] + [closeAllFlyouts, core.overlays, dashboardAppState, dispatchDashboardStateChange] ); const runSaveAs = useCallback(async () => { @@ -296,7 +297,7 @@ export function DashboardTopNav({ showCopyOnSave={lastDashboardId ? true : false} /> ); - clearAddPanel(); + closeAllFlyouts(); showSaveModal(dashboardSaveModal, core.i18n.Context); }, [ dispatchDashboardStateChange, @@ -305,7 +306,7 @@ export function DashboardTopNav({ dashboardAppState, core.i18n.Context, chrome.docTitle, - clearAddPanel, + closeAllFlyouts, kibanaVersion, timefilter, redirectTo, @@ -468,7 +469,7 @@ export function DashboardTopNav({ ]); UseUnmount(() => { - clearAddPanel(); + closeAllFlyouts(); setMounted(false); }); diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index a97b8025426f2..93aeb918bc53a 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -139,7 +139,6 @@ export { DEFAULT_ASSETS_TO_IGNORE, META_FIELDS, DATA_VIEW_SAVED_OBJECT_TYPE, - INDEX_PATTERN_SAVED_OBJECT_TYPE, isFilterable, fieldList, DataViewField, diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 02480aded9655..77f17d3a63eee 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -20,7 +20,12 @@ export * from './deprecated'; export { getEsQueryConfig, FilterStateStore } from '../common'; export { FilterLabel, FilterItem } from './ui'; -export { getDisplayValueFromFilter, generateFilters, extractTimeRange } from './query'; +export { + getDisplayValueFromFilter, + generateFilters, + extractTimeRange, + getIndexPatternFromFilter, +} from './query'; /** * Exporters (CSV) diff --git a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss index 4873989cde638..1c9cea7291770 100644 --- a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss +++ b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss @@ -88,3 +88,8 @@ .globalFilterItem__popoverAnchor { display: block; } + +.globalFilterItem__readonlyPanel { + min-width: auto; + padding: $euiSizeM; +} diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index 9bc64eb1f6919..00557dfab0e98 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -30,7 +30,7 @@ import { IDataPluginServices, IIndexPattern } from '../..'; import { UI_SETTINGS } from '../../../common'; -interface Props { +export interface Props { filters: Filter[]; onFiltersUpdated?: (filters: Filter[]) => void; className: string; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx index 1a272a5d79f37..d0924258831cb 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx @@ -17,11 +17,17 @@ export interface FilterLabelProps { filter: Filter; valueLabel?: string; filterLabelStatus?: FilterLabelStatus; + hideAlias?: boolean; } // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: FilterLabelProps) { +export default function FilterLabel({ + filter, + valueLabel, + filterLabelStatus, + hideAlias, +}: FilterLabelProps) { const prefixText = filter.meta.negate ? ` ${i18n.translate('data.filter.filterBar.negatedFilterPrefix', { defaultMessage: 'NOT ', @@ -38,7 +44,7 @@ export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: F return {text}; }; - if (filter.meta.alias !== null) { + if (!hideAlias && filter.meta.alias !== null) { return ( {prefix} 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 9e535513aa014..5f57072425844 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiContextMenu, EuiPopover } from '@elastic/eui'; +import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n-react'; import { Filter, @@ -16,7 +16,7 @@ import { toggleFilterDisabled, } from '@kbn/es-query'; import classNames from 'classnames'; -import React, { MouseEvent, useState, useEffect } from 'react'; +import React, { MouseEvent, useState, useEffect, HTMLAttributes } from 'react'; import { IUiSettingsClient } from 'src/core/public'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; @@ -37,8 +37,11 @@ export interface FilterItemProps { uiSettings: IUiSettingsClient; hiddenPanelOptions?: PanelOptions[]; timeRangeForSuggestionsOverride?: boolean; + readonly?: boolean; } +type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; + interface LabelOptions { title: string; status: FilterLabelStatus; @@ -349,32 +352,44 @@ export function FilterItem(props: FilterItemProps) { return null; } - const badge = ( - props.onRemove()} - onClick={handleBadgeClick} - data-test-subj={getDataTestSubj(valueLabelConfig)} - /> - ); + const filterViewProps = { + filter, + valueLabel: valueLabelConfig.title, + filterLabelStatus: valueLabelConfig.status, + errorMessage: valueLabelConfig.message, + className: getClasses(!!filter.meta.negate, valueLabelConfig), + iconOnClick: props.onRemove, + onClick: handleBadgeClick, + 'data-test-subj': getDataTestSubj(valueLabelConfig), + readonly: props.readonly, + }; + + const popoverProps: FilterPopoverProps = { + id: `popoverFor_filter${id}`, + className: `globalFilterItem__popover`, + anchorClassName: `globalFilterItem__popoverAnchor`, + isOpen: isPopoverOpen, + closePopover: () => { + setIsPopoverOpen(false); + }, + button: , + panelPaddingSize: 'none', + }; + + if (props.readonly) { + return ( + + + + ); + } return ( - { - setIsPopoverOpen(false); - }} - button={badge} - anchorPosition="downLeft" - panelPaddingSize="none" - > + ); diff --git a/src/plugins/data/public/ui/filter_bar/filter_view/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_view/index.tsx index d551af87c7279..e5345462b7df2 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_view/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_view/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiBadge, useInnerText } from '@elastic/eui'; +import { EuiBadge, EuiBadgeProps, useInnerText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { FC } from 'react'; import { Filter, isFilterPinned } from '@kbn/es-query'; @@ -18,6 +18,8 @@ interface Props { valueLabel: string; filterLabelStatus: FilterLabelStatus; errorMessage?: string; + readonly?: boolean; + hideAlias?: boolean; [propName: string]: any; } @@ -28,6 +30,8 @@ export const FilterView: FC = ({ valueLabel, errorMessage, filterLabelStatus, + readonly, + hideAlias, ...rest }: Props) => { const [ref, innerText] = useInnerText(); @@ -50,33 +54,45 @@ export const FilterView: FC = ({ })} ${title}`; } + const badgeProps: EuiBadgeProps = readonly + ? { + title, + color: 'hollow', + onClick, + onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', { + defaultMessage: 'Filter entry', + }), + iconOnClick, + } + : { + title, + color: 'hollow', + iconType: 'cross', + iconSide: 'right', + closeButtonProps: { + // Removing tab focus on close button because the same option can be obtained through the context menu + // Also, we may want to add a `DEL` keyboard press functionality + tabIndex: -1, + }, + iconOnClick, + iconOnClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeIconAriaLabel', { + defaultMessage: 'Delete {filter}', + values: { filter: innerText }, + }), + onClick, + onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeAriaLabel', { + defaultMessage: 'Filter actions', + }), + }; + return ( - + diff --git a/src/plugins/data_views/common/constants.ts b/src/plugins/data_views/common/constants.ts index 42f869908ec25..d6a9def882a1b 100644 --- a/src/plugins/data_views/common/constants.ts +++ b/src/plugins/data_views/common/constants.ts @@ -37,10 +37,4 @@ export const META_FIELDS = 'metaFields'; /** @public **/ export const DATA_VIEW_SAVED_OBJECT_TYPE = 'index-pattern'; -/** - * @deprecated Use DATA_VIEW_SAVED_OBJECT_TYPE. All index pattern interfaces were renamed. - */ - -export const INDEX_PATTERN_SAVED_OBJECT_TYPE = DATA_VIEW_SAVED_OBJECT_TYPE; - export const PLUGIN_NAME = 'DataViews'; diff --git a/src/plugins/data_views/common/index.ts b/src/plugins/data_views/common/index.ts index 954d3ed7e3590..13842b62a9d53 100644 --- a/src/plugins/data_views/common/index.ts +++ b/src/plugins/data_views/common/index.ts @@ -11,7 +11,6 @@ export { DEFAULT_ASSETS_TO_IGNORE, META_FIELDS, DATA_VIEW_SAVED_OBJECT_TYPE, - INDEX_PATTERN_SAVED_OBJECT_TYPE, } from './constants'; export type { IFieldType, IIndexPatternFieldList } from './fields'; export { diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 015cb6ddaf285..2b09f2bef4896 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -16,7 +16,7 @@ "dataViewFieldEditor", "dataViewEditor" ], - "optionalPlugins": ["home", "share", "usageCollection", "spaces"], + "optionalPlugins": ["home", "share", "usageCollection", "spaces", "triggersActionsUi"], "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "dataViews"], "extraPublicDirs": ["common"], "owner": { diff --git a/src/plugins/discover/public/application/discover_router.tsx b/src/plugins/discover/public/application/discover_router.tsx index 16ff443d15d24..0270af2383488 100644 --- a/src/plugins/discover/public/application/discover_router.tsx +++ b/src/plugins/discover/public/application/discover_router.tsx @@ -16,6 +16,7 @@ import { SingleDocRoute } from './doc'; import { DiscoverMainRoute } from './main'; import { NotFoundRoute } from './not_found'; import { DiscoverServices } from '../build_services'; +import { ViewAlertRoute } from './view_alert'; export const discoverRouter = (services: DiscoverServices, history: History) => ( @@ -36,6 +37,9 @@ export const discoverRouter = (services: DiscoverServices, history: History) => + + + diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx similarity index 89% rename from src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.ts rename to src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index a5627cc1d19d9..abf9a790a2417 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -18,6 +18,7 @@ import { onSaveSearch } from './on_save_search'; import { GetStateReturn } from '../../services/discover_state'; import { openOptionsPopover } from './open_options_popover'; import type { TopNavMenuData } from '../../../../../../navigation/public'; +import { openAlertsPopover } from './open_alerts_popover'; /** * Helper function to build the top nav links @@ -59,6 +60,25 @@ export const getTopNavLinks = ({ testId: 'discoverOptionsButton', }; + const alerts = { + id: 'alerts', + label: i18n.translate('discover.localMenu.localMenu.alertsTitle', { + defaultMessage: 'Alerts', + }), + description: i18n.translate('discover.localMenu.alertsDescription', { + defaultMessage: 'Alerts', + }), + run: (anchorElement: HTMLElement) => { + openAlertsPopover({ + I18nContext: services.core.i18n.Context, + anchorElement, + searchSource: savedSearch.searchSource, + services, + }); + }, + testId: 'discoverAlertsButton', + }; + const newSearch = { id: 'new', label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { @@ -162,6 +182,7 @@ export const getTopNavLinks = ({ ...(services.capabilities.advancedSettings.save ? [options] : []), newSearch, openSearch, + ...(services.triggersActionsUi ? [alerts] : []), shareSearch, inspectSearch, ...(services.capabilities.discover.save ? [saveSearch] : []), diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx new file mode 100644 index 0000000000000..21d560ccb539d --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useState, useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { I18nStart } from 'kibana/public'; +import { EuiWrappingPopover, EuiLink, EuiContextMenu, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ISearchSource } from '../../../../../../data/common'; +import { KibanaContextProvider } from '../../../../../../kibana_react/public'; +import { DiscoverServices } from '../../../../build_services'; +import { updateSearchSource } from '../../utils/update_search_source'; +import { useDiscoverServices } from '../../../../utils/use_discover_services'; + +const container = document.createElement('div'); +let isOpen = false; + +const ALERT_TYPE_ID = '.es-query'; + +interface AlertsPopoverProps { + onClose: () => void; + anchorElement: HTMLElement; + searchSource: ISearchSource; +} + +export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) { + const dataView = searchSource.getField('index')!; + const services = useDiscoverServices(); + const { triggersActionsUi } = services; + const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + + /** + * Provides the default parameters used to initialize the new rule + */ + const getParams = useCallback(() => { + const nextSearchSource = searchSource.createCopy(); + updateSearchSource(nextSearchSource, true, { + indexPattern: searchSource.getField('index')!, + services, + sort: [], + useNewFieldsApi: true, + }); + + return { + searchType: 'searchSource', + searchConfiguration: nextSearchSource.getSerializedFields(), + }; + }, [searchSource, services]); + + const SearchThresholdAlertFlyout = useMemo(() => { + if (!alertFlyoutVisible) { + return; + } + return triggersActionsUi?.getAddAlertFlyout({ + consumer: 'discover', + onClose, + canChangeTrigger: false, + ruleTypeId: ALERT_TYPE_ID, + initialValues: { + params: getParams(), + }, + }); + }, [getParams, onClose, triggersActionsUi, alertFlyoutVisible]); + + const hasTimeFieldName = dataView.timeFieldName; + let createSearchThresholdRuleLink = ( + setAlertFlyoutVisibility(true)} + disabled={!hasTimeFieldName} + > + + + ); + + if (!hasTimeFieldName) { + const toolTipContent = ( + + ); + createSearchThresholdRuleLink = ( + + {createSearchThresholdRuleLink} + + ); + } + + const panels = [ + { + id: 'mainPanel', + name: 'Alerting', + items: [ + { + name: ( + <> + {SearchThresholdAlertFlyout} + {createSearchThresholdRuleLink} + + ), + icon: 'bell', + disabled: !hasTimeFieldName, + }, + { + name: ( + + + + ), + icon: 'tableOfContents', + }, + ], + }, + ]; + + return ( + <> + {SearchThresholdAlertFlyout} + + + + + ); +} + +function closeAlertsPopover() { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + isOpen = false; +} + +export function openAlertsPopover({ + I18nContext, + anchorElement, + searchSource, + services, +}: { + I18nContext: I18nStart['Context']; + anchorElement: HTMLElement; + searchSource: ISearchSource; + services: DiscoverServices; +}) { + if (isOpen) { + closeAlertsPopover(); + return; + } + + isOpen = true; + document.body.appendChild(container); + + const element = ( + + + + + + ); + ReactDOM.render(element, container); +} diff --git a/src/plugins/discover/public/application/view_alert/index.ts b/src/plugins/discover/public/application/view_alert/index.ts new file mode 100644 index 0000000000000..9b3e4f5d3bf7e --- /dev/null +++ b/src/plugins/discover/public/application/view_alert/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ViewAlertRoute } from './view_alert_route'; diff --git a/src/plugins/discover/public/application/view_alert/view_alert_route.tsx b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx new file mode 100644 index 0000000000000..82481660d339c --- /dev/null +++ b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEffect, useMemo } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { sha256 } from 'js-sha256'; +import type { Alert } from '../../../../../../x-pack/plugins/alerting/common'; +import { getTime, IndexPattern } from '../../../../data/common'; +import type { Filter } from '../../../../data/public'; +import { DiscoverAppLocatorParams } from '../../locator'; +import { useDiscoverServices } from '../../utils/use_discover_services'; +import { getAlertUtils, QueryParams, SearchThresholdAlertParams } from './view_alert_utils'; + +type NonNullableEntry = { [K in keyof T]: NonNullable }; + +const getCurrentChecksum = (params: SearchThresholdAlertParams) => + sha256.create().update(JSON.stringify(params)).hex(); + +const isActualAlert = (queryParams: QueryParams): queryParams is NonNullableEntry => { + return Boolean(queryParams.from && queryParams.to && queryParams.checksum); +}; + +const buildTimeRangeFilter = ( + dataView: IndexPattern, + fetchedAlert: Alert, + timeFieldName: string +) => { + const filter = getTime(dataView, { + from: `now-${fetchedAlert.params.timeWindowSize}${fetchedAlert.params.timeWindowUnit}`, + to: 'now', + }); + return { + from: filter?.query.range[timeFieldName].gte, + to: filter?.query.range[timeFieldName].lte, + }; +}; + +const DISCOVER_MAIN_ROUTE = '/'; + +export function ViewAlertRoute() { + const { core, data, locator, toastNotifications } = useDiscoverServices(); + const { id } = useParams<{ id: string }>(); + const history = useHistory(); + const { search } = useLocation(); + + const query = useMemo(() => new URLSearchParams(search), [search]); + + const queryParams: QueryParams = useMemo( + () => ({ + from: query.get('from'), + to: query.get('to'), + checksum: query.get('checksum'), + }), + [query] + ); + + const openActualAlert = useMemo(() => isActualAlert(queryParams), [queryParams]); + + useEffect(() => { + const { + fetchAlert, + fetchSearchSource, + displayRuleChangedWarn, + displayPossibleDocsDiffInfoAlert, + showDataViewFetchError, + } = getAlertUtils(toastNotifications, core, data); + + const navigateToResults = async () => { + const fetchedAlert = await fetchAlert(id); + if (!fetchedAlert) { + history.push(DISCOVER_MAIN_ROUTE); + return; + } + + const calculatedChecksum = getCurrentChecksum(fetchedAlert.params); + if (openActualAlert && calculatedChecksum !== queryParams.checksum) { + displayRuleChangedWarn(); + } else if (openActualAlert && calculatedChecksum === queryParams.checksum) { + displayPossibleDocsDiffInfoAlert(); + } + + const fetchedSearchSource = await fetchSearchSource(fetchedAlert); + if (!fetchedSearchSource) { + history.push(DISCOVER_MAIN_ROUTE); + return; + } + + const dataView = fetchedSearchSource.getField('index'); + const timeFieldName = dataView?.timeFieldName; + if (!dataView || !timeFieldName) { + showDataViewFetchError(fetchedAlert.id); + history.push(DISCOVER_MAIN_ROUTE); + return; + } + + const timeRange = openActualAlert + ? { from: queryParams.from, to: queryParams.to } + : buildTimeRangeFilter(dataView, fetchedAlert, timeFieldName); + const state: DiscoverAppLocatorParams = { + query: fetchedSearchSource.getField('query') || data.query.queryString.getDefaultQuery(), + indexPatternId: dataView.id, + timeRange, + }; + + const filters = fetchedSearchSource.getField('filter'); + if (filters) { + state.filters = filters as Filter[]; + } + + await locator.navigate(state); + }; + + navigateToResults(); + }, [ + toastNotifications, + data.query.queryString, + data.search.searchSource, + core.http, + locator, + id, + queryParams, + history, + openActualAlert, + core, + data, + ]); + + return null; +} diff --git a/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx b/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx new file mode 100644 index 0000000000000..b61f0c9a8720c --- /dev/null +++ b/src/plugins/discover/public/application/view_alert/view_alert_utils.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart, ToastsStart } from 'kibana/public'; +import type { Alert } from '../../../../../../x-pack/plugins/alerting/common'; +import type { AlertTypeParams } from '../../../../../../x-pack/plugins/alerting/common'; +import { SerializedSearchSourceFields } from '../../../../data/common'; +import type { DataPublicPluginStart } from '../../../../data/public'; +import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public'; + +export interface SearchThresholdAlertParams extends AlertTypeParams { + searchConfiguration: SerializedSearchSourceFields; +} + +export interface QueryParams { + from: string | null; + to: string | null; + checksum: string | null; +} + +const LEGACY_BASE_ALERT_API_PATH = '/api/alerts'; + +export const getAlertUtils = ( + toastNotifications: ToastsStart, + core: CoreStart, + data: DataPublicPluginStart +) => { + const showDataViewFetchError = (alertId: string) => { + const errorTitle = i18n.translate('discover.viewAlert.dataViewErrorTitle', { + defaultMessage: 'Error fetching data view', + }); + toastNotifications.addDanger({ + title: errorTitle, + text: toMountPoint( + + {new Error(`Data view failure of the alert rule with id ${alertId}.`).message} + + ), + }); + }; + + const displayRuleChangedWarn = () => { + const warnTitle = i18n.translate('discover.viewAlert.alertRuleChangedWarnTitle', { + defaultMessage: 'Alert rule has changed', + }); + const warnDescription = i18n.translate('discover.viewAlert.alertRuleChangedWarnDescription', { + defaultMessage: `The displayed documents might not match the documents that triggered the alert + because the rule configuration changed.`, + }); + + toastNotifications.addWarning({ + title: warnTitle, + text: toMountPoint({warnDescription}), + }); + }; + + const displayPossibleDocsDiffInfoAlert = () => { + const infoTitle = i18n.translate('discover.viewAlert.documentsMayVaryInfoTitle', { + defaultMessage: 'Displayed documents may vary', + }); + const infoDescription = i18n.translate('discover.viewAlert.documentsMayVaryInfoDescription', { + defaultMessage: `The displayed documents might differ from the documents that triggered the alert. + Some documents might have been added or deleted.`, + }); + + toastNotifications.addInfo({ + title: infoTitle, + text: toMountPoint({infoDescription}), + }); + }; + + const fetchAlert = async (id: string) => { + try { + return await core.http.get>( + `${LEGACY_BASE_ALERT_API_PATH}/alert/${id}` + ); + } catch (error) { + const errorTitle = i18n.translate('discover.viewAlert.alertRuleFetchErrorTitle', { + defaultMessage: 'Error fetching alert rule', + }); + toastNotifications.addDanger({ + title: errorTitle, + text: toMountPoint({error.message}), + }); + } + }; + + const fetchSearchSource = async (fetchedAlert: Alert) => { + try { + return await data.search.searchSource.create(fetchedAlert.params.searchConfiguration); + } catch (error) { + const errorTitle = i18n.translate('discover.viewAlert.searchSourceErrorTitle', { + defaultMessage: 'Error fetching search source', + }); + toastNotifications.addDanger({ + title: errorTitle, + text: toMountPoint({error.message}), + }); + } + }; + + return { + displayRuleChangedWarn, + displayPossibleDocsDiffInfoAlert, + showDataViewFetchError, + fetchAlert, + fetchSearchSource, + }; +}; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index f3c697d400a93..100235cb95a0d 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -18,6 +18,8 @@ import { IUiSettingsClient, PluginInitializerContext, HttpStart, + NotificationsStart, + ApplicationStart, } from 'kibana/public'; import { FilterManager, @@ -38,15 +40,18 @@ import { NavigationPublicPluginStart } from '../../navigation/public'; import { IndexPatternFieldEditorStart } from '../../data_view_field_editor/public'; import { FieldFormatsStart } from '../../field_formats/public'; import { EmbeddableStart } from '../../embeddable/public'; +import { DiscoverAppLocator } from './locator'; import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; import { DataViewEditorStart } from '../../../plugins/data_view_editor/public'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public'; export interface HistoryLocationState { referrer: string; } export interface DiscoverServices { + application: ApplicationStart; addBasePath: (path: string) => string; capabilities: Capabilities; chrome: ChromeStart; @@ -66,6 +71,7 @@ export interface DiscoverServices { urlForwarding: UrlForwardingStart; timefilter: TimefilterContract; toastNotifications: ToastsStart; + notifications: NotificationsStart; uiSettings: IUiSettingsClient; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; dataViewFieldEditor: IndexPatternFieldEditorStart; @@ -73,17 +79,21 @@ export interface DiscoverServices { http: HttpStart; storage: Storage; spaces?: SpacesApi; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + locator: DiscoverAppLocator; } export const buildServices = memoize(function ( core: CoreStart, plugins: DiscoverStartPlugins, - context: PluginInitializerContext + context: PluginInitializerContext, + locator: DiscoverAppLocator ): DiscoverServices { const { usageCollection } = plugins; const storage = new Storage(localStorage); return { + application: core.application, addBasePath: core.http.basePath.prepend, capabilities: core.application.capabilities, chrome: core.chrome, @@ -105,6 +115,7 @@ export const buildServices = memoize(function ( urlForwarding: plugins.urlForwarding, timefilter: plugins.data.query.timefilter.timefilter, toastNotifications: core.notifications.toasts, + notifications: core.notifications, uiSettings: core.uiSettings, storage, trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'), @@ -112,5 +123,7 @@ export const buildServices = memoize(function ( http: core.http, spaces: plugins.spaces, dataViewEditor: plugins.dataViewEditor, + triggersActionsUi: plugins.triggersActionsUi, + locator, }; }); diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 09042fda9a38a..0f7be875a4f21 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -53,6 +53,7 @@ import { injectTruncateStyles } from './utils/truncate_styles'; import { DOC_TABLE_LEGACY, TRUNCATE_MAX_HEIGHT } from '../common'; import { DataViewEditorStart } from '../../../plugins/data_view_editor/public'; import { useDiscoverServices } from './utils/use_discover_services'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public'; import { initializeKbnUrlTracking } from './utils/initialize_kbn_url_tracking'; const DocViewerLegacyTable = React.lazy( @@ -170,6 +171,7 @@ export interface DiscoverStartPlugins { usageCollection?: UsageCollectionSetup; dataViewFieldEditor: IndexPatternFieldEditorStart; spaces?: SpacesPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } /** @@ -274,7 +276,12 @@ export class DiscoverPlugin window.dispatchEvent(new HashChangeEvent('hashchange')); }); - const services = buildServices(coreStart, discoverStartPlugins, this.initializerContext); + const services = buildServices( + coreStart, + discoverStartPlugins, + this.initializerContext, + this.locator! + ); // make sure the index pattern list is up to date await discoverStartPlugins.data.indexPatterns.clearCache(); @@ -364,7 +371,7 @@ export class DiscoverPlugin const getDiscoverServices = async () => { const [coreStart, discoverStartPlugins] = await core.getStartServices(); - return buildServices(coreStart, discoverStartPlugins, this.initializerContext); + return buildServices(coreStart, discoverStartPlugins, this.initializerContext, this.locator!); }; const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServices); diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 6dad573a272fb..817e73f16617e 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../field_formats/tsconfig.json" }, { "path": "../data_views/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, - { "path": "../data_view_editor/tsconfig.json" } + { "path": "../data_view_editor/tsconfig.json" }, + { "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" } ] } diff --git a/test/common/services/index_patterns.ts b/test/common/services/index_patterns.ts index 549137c79e9a2..1e7e998ae24d9 100644 --- a/test/common/services/index_patterns.ts +++ b/test/common/services/index_patterns.ts @@ -16,13 +16,14 @@ export class IndexPatternsService extends FtrService { * Create a new index pattern */ async create( - indexPattern: { title: string }, - { override = false }: { override: boolean } = { override: false } + indexPattern: { title: string; timeFieldName?: string }, + { override = false }: { override: boolean }, + spaceId = '' ): Promise { const response = await this.kibanaServer.request<{ index_pattern: DataViewSpec; }>({ - path: '/api/index_patterns/index_pattern', + path: `${spaceId}/api/index_patterns/index_pattern`, method: 'POST', body: { override, diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index ffda165443337..3ca09bba99cea 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -99,5 +99,33 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.deleteAllControls(); }); }); + + describe('control group settings flyout closes', async () => { + it('on save', async () => { + await dashboardControls.openControlGroupSettingsFlyout(); + await dashboard.saveDashboard('Test Control Group Settings', { + saveAsNew: false, + exitFromEditMode: false, + }); + await testSubjects.missingOrFail('control-group-settings-flyout'); + }); + + it('on view mode change', async () => { + await dashboardControls.openControlGroupSettingsFlyout(); + await dashboard.clickCancelOutOfEditMode(); + await testSubjects.missingOrFail('control-group-settings-flyout'); + }); + + it('when navigating away from dashboard', async () => { + await dashboard.switchToEditMode(); + await dashboardControls.openControlGroupSettingsFlyout(); + await dashboard.gotoDashboardLandingPage(); + await testSubjects.missingOrFail('control-group-settings-flyout'); + }); + + after(async () => { + await dashboard.loadSavedDashboard('Test Control Group Settings'); + }); + }); }); } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 06d34094e614c..af4bf6fc40299 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -47,6 +47,9 @@ export class VisualizePageObject extends FtrService { LOGSTASH_NON_TIME_BASED: 'logstash*', }; + remoteEsPrefix = 'ftr-remote:'; + defaultIndexString = 'logstash-*'; + public async initTests(isNewLibrary = false) { await this.kibanaServer.savedObjects.clean({ types: ['visualization'] }); await this.kibanaServer.importExport.load( @@ -54,7 +57,7 @@ export class VisualizePageObject extends FtrService { ); await this.kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', + defaultIndex: this.defaultIndexString, [FORMATS_UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', 'visualization:visualize:legacyPieChartsLibrary': !isNewLibrary, 'visualization:visualize:legacyHeatmapChartsLibrary': !isNewLibrary, diff --git a/test/package/roles/assert_kibana_available/tasks/main.yml b/test/package/roles/assert_kibana_available/tasks/main.yml index db3805f7ad7fb..29b82aa7b39f8 100644 --- a/test/package/roles/assert_kibana_available/tasks/main.yml +++ b/test/package/roles/assert_kibana_available/tasks/main.yml @@ -4,6 +4,6 @@ status_code: [200, 401] timeout: 120 register: result - until: result.status != 503 + until: result.status not in [503, -1] retries: 3 delay: 30 diff --git a/versions.json b/versions.json new file mode 100644 index 0000000000000..6dfe620c9fd7e --- /dev/null +++ b/versions.json @@ -0,0 +1,27 @@ +{ + "notice": "This file is not maintained outside of the main branch and should only be used for tooling.", + "versions": [ + { + "version": "8.3.0", + "branch": "main", + "currentMajor": true, + "currentMinor": true + }, + { + "version": "8.2.0", + "branch": "8.2", + "currentMajor": true, + "previousMinor": true + }, + { + "version": "8.1.3", + "branch": "8.1", + "currentMajor": true + }, + { + "version": "7.17.3", + "branch": "7.17", + "previousMajor": true + } + ] +} diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index e5047aae9f154..df74c46ad9b43 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -36,7 +36,21 @@ export interface IExecutionLog { timed_out: boolean; } +export interface IExecutionErrors { + id: string; + timestamp: string; + type: string; + message: string; +} + +export interface IExecutionErrorsResult { + totalErrors: number; + errors: IExecutionErrors[]; +} + export interface IExecutionLogResult { total: number; data: IExecutionLog[]; } + +export type IExecutionLogWithErrorsResult = IExecutionLogResult & IExecutionErrorsResult; diff --git a/x-pack/plugins/alerting/kibana.json b/x-pack/plugins/alerting/kibana.json index 6bfc420a89e52..fc45f22d9c9a6 100644 --- a/x-pack/plugins/alerting/kibana.json +++ b/x-pack/plugins/alerting/kibana.json @@ -11,6 +11,7 @@ "configPath": ["xpack", "alerting"], "requiredPlugins": [ "actions", + "data", "encryptedSavedObjects", "eventLog", "features", diff --git a/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts b/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts index a169640c4fc83..ef5b931310f6a 100644 --- a/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts +++ b/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts @@ -7,6 +7,7 @@ import { get } from 'lodash'; import { QueryEventsBySavedObjectResult, IValidatedEvent } from '../../../event_log/server'; +import { IExecutionErrors, IExecutionErrorsResult } from '../../common'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const TIMESTAMP_FIELD = '@timestamp'; @@ -14,18 +15,6 @@ const PROVIDER_FIELD = 'event.provider'; const MESSAGE_FIELD = 'message'; const ERROR_MESSAGE_FIELD = 'error.message'; -export interface IExecutionErrors { - id: string; - timestamp: string; - type: string; - message: string; -} - -export interface IExecutionErrorsResult { - totalErrors: number; - errors: IExecutionErrors[]; -} - export const EMPTY_EXECUTION_ERRORS_RESULT = { totalErrors: 0, errors: [], diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index 75022427bea27..80090effca9d1 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -11,6 +11,7 @@ import { formatExecutionLogResult, formatSortForBucketSort, formatSortForTermSort, + ExecutionUuidAggResult, } from './get_execution_log_aggregation'; describe('formatSortForBucketSort', () => { @@ -128,92 +129,111 @@ describe('getExecutionLogAggregation', () => { sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }], }) ).toEqual({ - executionUuidCardinality: { cardinality: { field: 'kibana.alert.rule.execution.uuid' } }, - executionUuid: { - terms: { - field: 'kibana.alert.rule.execution.uuid', - size: 1000, - order: [ - { 'ruleExecution>executeStartTime': 'asc' }, - { 'ruleExecution>executionDuration': 'desc' }, - ], + excludeExecuteStart: { + filter: { + bool: { + must_not: [ + { + term: { + 'event.action': 'execute-start', + }, + }, + ], + }, }, aggs: { - executionUuidSorted: { - bucket_sort: { - sort: [ - { 'ruleExecution>executeStartTime': { order: 'asc' } }, - { 'ruleExecution>executionDuration': { order: 'desc' } }, + executionUuidCardinality: { cardinality: { field: 'kibana.alert.rule.execution.uuid' } }, + executionUuid: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 1000, + order: [ + { 'ruleExecution>executeStartTime': 'asc' }, + { 'ruleExecution>executionDuration': 'desc' }, ], - from: 10, - size: 10, - gap_policy: 'insert_zeros', - }, - }, - alertCounts: { - filters: { - filters: { - newAlerts: { match: { 'event.action': 'new-instance' } }, - activeAlerts: { match: { 'event.action': 'active-instance' } }, - recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, - }, - }, - }, - actionExecution: { - filter: { - bool: { - must: [ - { match: { 'event.action': 'execute' } }, - { match: { 'event.provider': 'actions' } }, - ], - }, - }, - aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } }, - }, - ruleExecution: { - filter: { - bool: { - must: [ - { match: { 'event.action': 'execute' } }, - { match: { 'event.provider': 'alerting' } }, - ], - }, }, aggs: { - executeStartTime: { min: { field: 'event.start' } }, - scheduleDelay: { - max: { - field: 'kibana.task.schedule_delay', + executionUuidSorted: { + bucket_sort: { + sort: [ + { 'ruleExecution>executeStartTime': { order: 'asc' } }, + { 'ruleExecution>executionDuration': { order: 'desc' } }, + ], + from: 10, + size: 10, + gap_policy: 'insert_zeros', }, }, - totalSearchDuration: { - max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, - }, - esSearchDuration: { - max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' }, - }, - numTriggeredActions: { - max: { field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions' }, + alertCounts: { + filters: { + filters: { + newAlerts: { match: { 'event.action': 'new-instance' } }, + activeAlerts: { match: { 'event.action': 'active-instance' } }, + recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, + }, + }, }, - numScheduledActions: { - max: { field: 'kibana.alert.rule.execution.metrics.number_of_scheduled_actions' }, + actionExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'actions' } }, + ], + }, + }, + aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } }, }, - executionDuration: { max: { field: 'event.duration' } }, - outcomeAndMessage: { - top_hits: { - size: 1, - _source: { includes: ['event.outcome', 'message', 'error.message'] }, + ruleExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + aggs: { + executeStartTime: { min: { field: 'event.start' } }, + scheduleDelay: { + max: { + field: 'kibana.task.schedule_delay', + }, + }, + totalSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, + }, + esSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' }, + }, + numTriggeredActions: { + max: { + field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions', + }, + }, + numScheduledActions: { + max: { + field: 'kibana.alert.rule.execution.metrics.number_of_scheduled_actions', + }, + }, + executionDuration: { max: { field: 'event.duration' } }, + outcomeAndMessage: { + top_hits: { + size: 1, + _source: { includes: ['event.outcome', 'message', 'error.message'] }, + }, + }, }, }, - }, - }, - timeoutMessage: { - filter: { - bool: { - must: [ - { match: { 'event.action': 'execute-timeout' } }, - { match: { 'event.provider': 'alerting' } }, - ], + timeoutMessage: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute-timeout' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, }, }, }, @@ -230,188 +250,202 @@ describe('formatExecutionLogResult', () => { data: [], }); }); + test('should return empty results if aggregations.excludeExecuteStart are undefined', () => { + expect( + formatExecutionLogResult({ + aggregations: { excludeExecuteStart: undefined as unknown as ExecutionUuidAggResult }, + }) + ).toEqual({ + total: 0, + data: [], + }); + }); test('should format results correctly', () => { const results = { aggregations: { - executionUuid: { + excludeExecuteStart: { meta: {}, - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', - doc_count: 27, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, }, - newAlerts: { - doc_count: 5, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 0, + numScheduledActions: { + value: 5.0, }, - }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ - { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'S4wIZX8B8TGQpG7XQZns', - _score: 1.0, - _source: { - event: { - outcome: 'success', + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.074e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.056e9, - }, - executeStartTime: { - value: 1.646667512617e12, - value_as_string: '2022-03-07T15:38:32.617Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { doc_count: 5, }, - ], + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, }, - }, - }, - { - key: '41b2755e-765a-4044-9745-b03875d5e79a', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - newAlerts: { - doc_count: 5, + numScheduledActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 5, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', }, }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'a4wIZX8B8TGQpG7Xwpnz', - _score: 1.0, - _source: { - event: { - outcome: 'success', - }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - }, + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.126e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.165e9, - }, - executeStartTime: { - value: 1.646667545604e12, - value_as_string: '2022-03-07T15:39:05.604Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', - doc_count: 5, - }, - ], - }, - }, - }, - ], - }, - executionUuidCardinality: { - value: 374, + ], + }, + executionUuidCardinality: { + value: 374, + }, }, }, }; @@ -463,188 +497,192 @@ describe('formatExecutionLogResult', () => { test('should format results correctly with rule execution errors', () => { const results = { aggregations: { - executionUuid: { + excludeExecuteStart: { meta: {}, - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', - doc_count: 27, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, }, - newAlerts: { - doc_count: 5, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 0, + numScheduledActions: { + value: 5.0, }, - }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ - { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'S4wIZX8B8TGQpG7XQZns', - _score: 1.0, - _source: { - event: { - outcome: 'failure', - }, - message: - "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - error: { - message: 'I am erroring in rule execution!!', + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'failure', + }, + message: + "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + error: { + message: 'I am erroring in rule execution!!', + }, }, }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.074e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.056e9, - }, - executeStartTime: { - value: 1.646667512617e12, - value_as_string: '2022-03-07T15:38:32.617Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { doc_count: 5, }, - ], + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, }, - }, - }, - { - key: '41b2755e-765a-4044-9745-b03875d5e79a', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - newAlerts: { - doc_count: 5, + numScheduledActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 5, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', }, }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'a4wIZX8B8TGQpG7Xwpnz', - _score: 1.0, - _source: { - event: { - outcome: 'success', - }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - }, + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.126e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.165e9, - }, - executeStartTime: { - value: 1.646667545604e12, - value_as_string: '2022-03-07T15:39:05.604Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', - doc_count: 5, - }, - ], - }, - }, - }, - ], - }, - executionUuidCardinality: { - value: 374, + ], + }, + executionUuidCardinality: { + value: 374, + }, }, }, }; @@ -696,180 +734,184 @@ describe('formatExecutionLogResult', () => { test('should format results correctly when execution timeouts occur', () => { const results = { aggregations: { - executionUuid: { + excludeExecuteStart: { meta: {}, - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', - doc_count: 3, - timeoutMessage: { - meta: {}, - doc_count: 1, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 0, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + doc_count: 3, + timeoutMessage: { + meta: {}, + doc_count: 1, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 0, + }, + newAlerts: { + doc_count: 0, + }, + recoveredAlerts: { + doc_count: 0, + }, }, - newAlerts: { - doc_count: 0, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 0.0, }, - recoveredAlerts: { - doc_count: 0, + numScheduledActions: { + value: 0.0, }, - }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 0.0, - }, - numScheduledActions: { - value: 0.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ - { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'dJkWa38B1ylB1EvsAckB', - _score: 1.0, - _source: { - event: { - outcome: 'success', + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'dJkWa38B1ylB1EvsAckB', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, - }, - ], + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.0279e10, + }, + executeStartTime: { + value: 1.646769067607e12, + value_as_string: '2022-03-08T19:51:07.607Z', }, }, - scheduleDelay: { - value: 3.074e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.0279e10, - }, - executeStartTime: { - value: 1.646769067607e12, - value_as_string: '2022-03-08T19:51:07.607Z', + actionExecution: { + meta: {}, + doc_count: 0, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, }, }, - actionExecution: { - meta: {}, - doc_count: 0, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, }, - }, - }, - { - key: '41b2755e-765a-4044-9745-b03875d5e79a', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - newAlerts: { - doc_count: 5, + numScheduledActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 5, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', }, }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'a4wIZX8B8TGQpG7Xwpnz', - _score: 1.0, - _source: { - event: { - outcome: 'success', - }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - }, + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.126e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.165e9, - }, - executeStartTime: { - value: 1.646667545604e12, - value_as_string: '2022-03-07T15:39:05.604Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', - doc_count: 5, - }, - ], - }, - }, - }, - ], - }, - executionUuidCardinality: { - value: 374, + ], + }, + executionUuidCardinality: { + value: 374, + }, }, }, }; @@ -921,185 +963,189 @@ describe('formatExecutionLogResult', () => { test('should format results correctly when action errors occur', () => { const results = { aggregations: { - executionUuid: { + excludeExecuteStart: { meta: {}, - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, }, - newAlerts: { - doc_count: 5, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 5, + numScheduledActions: { + value: 5.0, }, - }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ - { - _index: '.kibana-event-log-8.2.0-000001', - _id: '7xKcb38BcntAq5ycFwiu', - _score: 1.0, - _source: { - event: { - outcome: 'success', + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.126e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.374e9, - }, - executeStartTime: { - value: 1.646844973039e12, - value_as_string: '2022-03-09T16:56:13.039Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'failure', + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { doc_count: 5, }, - ], + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, }, - }, - }, - { - key: '61bb867b-661a-471f-bf92-23471afa10b3', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - newAlerts: { - doc_count: 5, + numScheduledActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 5, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', }, }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'zRKbb38BcntAq5ycOwgk', - _score: 1.0, - _source: { - event: { - outcome: 'success', - }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - }, + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.133e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 4.18e8, - }, - executeStartTime: { - value: 1.646844917518e12, - value_as_string: '2022-03-09T16:55:17.518Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', - doc_count: 5, - }, - ], - }, - }, - }, - ], - }, - executionUuidCardinality: { - value: 417, + ], + }, + executionUuidCardinality: { + value: 417, + }, }, }, }; diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 6f8d0d8059b69..fbe72508dab2b 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -68,10 +68,15 @@ interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketK }; } -interface ExecutionUuidAggResult +export interface ExecutionUuidAggResult extends estypes.AggregationsAggregateBase { buckets: TBucket[]; } + +interface ExcludeExecuteStartAggResult extends estypes.AggregationsAggregateBase { + executionUuid: ExecutionUuidAggResult; + executionUuidCardinality: estypes.AggregationsCardinalityAggregate; +} export interface IExecutionLogAggOptions { page: number; perPage: number; @@ -112,104 +117,119 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo } return { - // Get total number of executions - executionUuidCardinality: { - cardinality: { - field: EXECUTION_UUID_FIELD, - }, - }, - executionUuid: { - // Bucket by execution UUID - terms: { - field: EXECUTION_UUID_FIELD, - size: DEFAULT_MAX_BUCKETS_LIMIT, - order: formatSortForTermSort(sort), + excludeExecuteStart: { + filter: { + bool: { + must_not: [ + { + term: { + [ACTION_FIELD]: 'execute-start', + }, + }, + ], + }, }, aggs: { - // Bucket sort to allow paging through executions - executionUuidSorted: { - bucket_sort: { - sort: formatSortForBucketSort(sort), - from: (page - 1) * perPage, - size: perPage, - gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, + // Get total number of executions + executionUuidCardinality: { + cardinality: { + field: EXECUTION_UUID_FIELD, }, }, - // Get counts for types of alerts and whether there was an execution timeout - alertCounts: { - filters: { - filters: { - newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, - activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, - recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, - }, + executionUuid: { + // Bucket by execution UUID + terms: { + field: EXECUTION_UUID_FIELD, + size: DEFAULT_MAX_BUCKETS_LIMIT, + order: formatSortForTermSort(sort), }, - }, - // Filter by action execute doc and get information from this event - actionExecution: { - filter: getProviderAndActionFilter('actions', 'execute'), aggs: { - actionOutcomes: { - terms: { - field: OUTCOME_FIELD, - size: 2, - }, - }, - }, - }, - // Filter by rule execute doc and get information from this event - ruleExecution: { - filter: getProviderAndActionFilter('alerting', 'execute'), - aggs: { - executeStartTime: { - min: { - field: START_FIELD, - }, - }, - scheduleDelay: { - max: { - field: SCHEDULE_DELAY_FIELD, - }, - }, - totalSearchDuration: { - max: { - field: TOTAL_SEARCH_DURATION_FIELD, - }, - }, - esSearchDuration: { - max: { - field: ES_SEARCH_DURATION_FIELD, - }, - }, - numTriggeredActions: { - max: { - field: NUMBER_OF_TRIGGERED_ACTIONS_FIELD, + // Bucket sort to allow paging through executions + executionUuidSorted: { + bucket_sort: { + sort: formatSortForBucketSort(sort), + from: (page - 1) * perPage, + size: perPage, + gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, }, }, - numScheduledActions: { - max: { - field: NUMBER_OF_SCHEDULED_ACTIONS_FIELD, + // Get counts for types of alerts and whether there was an execution timeout + alertCounts: { + filters: { + filters: { + newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, + activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, + recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, + }, }, }, - executionDuration: { - max: { - field: DURATION_FIELD, + // Filter by action execute doc and get information from this event + actionExecution: { + filter: getProviderAndActionFilter('actions', 'execute'), + aggs: { + actionOutcomes: { + terms: { + field: OUTCOME_FIELD, + size: 2, + }, + }, }, }, - outcomeAndMessage: { - top_hits: { - size: 1, - _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + // Filter by rule execute doc and get information from this event + ruleExecution: { + filter: getProviderAndActionFilter('alerting', 'execute'), + aggs: { + executeStartTime: { + min: { + field: START_FIELD, + }, + }, + scheduleDelay: { + max: { + field: SCHEDULE_DELAY_FIELD, + }, + }, + totalSearchDuration: { + max: { + field: TOTAL_SEARCH_DURATION_FIELD, + }, + }, + esSearchDuration: { + max: { + field: ES_SEARCH_DURATION_FIELD, + }, + }, + numTriggeredActions: { + max: { + field: NUMBER_OF_TRIGGERED_ACTIONS_FIELD, + }, + }, + numScheduledActions: { + max: { + field: NUMBER_OF_SCHEDULED_ACTIONS_FIELD, + }, + }, + executionDuration: { + max: { + field: DURATION_FIELD, + }, + }, + outcomeAndMessage: { + top_hits: { + size: 1, + _source: { + includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + }, + }, }, }, }, + // If there was a timeout, this filter will return non-zero doc count + timeoutMessage: { + filter: getProviderAndActionFilter('alerting', 'execute-timeout'), + }, }, }, - // If there was a timeout, this filter will return non-zero doc count - timeoutMessage: { - filter: getProviderAndActionFilter('alerting', 'execute-timeout'), - }, }, }, }; @@ -280,13 +300,14 @@ export function formatExecutionLogResult( ): IExecutionLogResult { const { aggregations } = results; - if (!aggregations) { + if (!aggregations || !aggregations.excludeExecuteStart) { return EMPTY_EXECUTION_LOG_RESULT; } - const total = (aggregations.executionUuidCardinality as estypes.AggregationsCardinalityAggregate) - .value; - const buckets = (aggregations.executionUuid as ExecutionUuidAggResult).buckets; + const aggs = aggregations.excludeExecuteStart as ExcludeExecuteStartAggResult; + + const total = aggs.executionUuidCardinality.value; + const buckets = aggs.executionUuid.buckets; return { total, diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index a94b30b59104c..c952e9182190c 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -12,7 +12,9 @@ import { elasticsearchServiceMock, savedObjectsClientMock, uiSettingsServiceMock, + httpServerMock, } from '../../../../src/core/server/mocks'; +import { dataPluginMock } from '../../../../src/plugins/data/server/mocks'; import { AlertInstanceContext, AlertInstanceState } from './types'; export { rulesClientMock }; @@ -111,6 +113,11 @@ const createAlertServicesMock = < shouldWriteAlerts: () => true, shouldStopExecution: () => true, search: createAbortableSearchServiceMock(), + searchSourceClient: Promise.resolve( + dataPluginMock + .createStartContract() + .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) + ), }; }; export type AlertServicesMock = ReturnType; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 450c177d72473..8eb73aa25954b 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -19,6 +19,7 @@ import { AlertingConfig } from './config'; import { RuleType } from './types'; import { eventLogMock } from '../../event_log/server/mocks'; import { actionsMock } from '../../actions/server/mocks'; +import { dataPluginMock } from '../../../../src/plugins/data/server/mocks'; import { monitoringCollectionMock } from '../../monitoring_collection/server/mocks'; const generateAlertingConfig = (): AlertingConfig => ({ @@ -276,6 +277,7 @@ describe('Alerting Plugin', () => { licensing: licensingMock.createStart(), eventLog: eventLogMock.createStart(), taskManager: taskManagerMock.createStart(), + data: dataPluginMock.createStartContract(), }); expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false); @@ -313,6 +315,7 @@ describe('Alerting Plugin', () => { licensing: licensingMock.createStart(), eventLog: eventLogMock.createStart(), taskManager: taskManagerMock.createStart(), + data: dataPluginMock.createStartContract(), }); const fakeRequest = { @@ -361,6 +364,7 @@ describe('Alerting Plugin', () => { licensing: licensingMock.createStart(), eventLog: eventLogMock.createStart(), taskManager: taskManagerMock.createStart(), + data: dataPluginMock.createStartContract(), }); const fakeRequest = { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 8fa394445fe50..47e2450b7a85c 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -63,6 +63,7 @@ import { getHealth } from './health/get_health'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; import { AlertingAuthorization } from './authorization'; import { getSecurityHealth, SecurityHealth } from './lib/get_security_health'; +import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; import { MonitoringCollectionSetup } from '../../monitoring_collection/server'; import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring'; import { getExecutionConfigForRuleType } from './lib/get_rules_config'; @@ -139,6 +140,7 @@ export interface AlertingPluginsStart { licensing: LicensingPluginStart; spaces?: SpacesPluginStart; security?: SecurityPluginStart; + data: DataPluginStart; } export class AlertingPlugin { @@ -407,6 +409,7 @@ export class AlertingPlugin { taskRunnerFactory.initialize({ logger, + data: plugins.data, savedObjects: core.savedObjects, uiSettings: core.uiSettings, elasticsearch: core.elasticsearch, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index f304c7be86131..2394e159a9f19 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -11,7 +11,7 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { rulesClientMock } from '../rules_client.mock'; -import { IExecutionLogWithErrorsResult } from '../rules_client'; +import { IExecutionLogWithErrorsResult } from '../../common'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 901d7102f40c6..5377ec562847f 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -89,13 +89,10 @@ import { formatExecutionLogResult, getExecutionLogAggregation, } from '../lib/get_execution_log_aggregation'; -import { IExecutionLogResult } from '../../common'; +import { IExecutionLogWithErrorsResult } from '../../common'; import { validateSnoozeDate } from '../lib/validate_snooze_date'; import { RuleMutedError } from '../lib/errors/rule_muted'; -import { - formatExecutionErrorsResult, - IExecutionErrorsResult, -} from '../lib/format_execution_log_errors'; +import { formatExecutionErrorsResult } from '../lib/format_execution_log_errors'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -263,7 +260,6 @@ export interface GetExecutionLogByIdParams { sort: estypes.Sort; } -export type IExecutionLogWithErrorsResult = IExecutionLogResult & IExecutionErrorsResult; interface ScheduleRuleOptions { id: string; consumer: string; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 8a16bcb2d2fd7..df5f9252a98a0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -96,185 +96,189 @@ const BaseRuleSavedObject: SavedObject = { const aggregateResults = { aggregations: { - executionUuid: { + excludeExecuteStart: { meta: {}, - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', - doc_count: 27, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, }, - newAlerts: { - doc_count: 5, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, }, - recoveredAlerts: { - doc_count: 0, + numScheduledActions: { + value: 5.0, }, - }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ - { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'S4wIZX8B8TGQpG7XQZns', - _score: 1.0, - _source: { - event: { - outcome: 'success', + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.126e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.056e9, - }, - executeStartTime: { - value: 1.646667512617e12, - value_as_string: '2022-03-07T15:38:32.617Z', - }, }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { doc_count: 5, }, - ], + }, }, - }, - }, - { - key: '41b2755e-765a-4044-9745-b03875d5e79a', - doc_count: 32, - timeoutMessage: { - meta: {}, - doc_count: 0, - }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + numScheduledActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, }, - newAlerts: { - doc_count: 5, + scheduleDelay: { + value: 3.345e9, }, - recoveredAlerts: { - doc_count: 5, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', }, }, - }, - ruleExecution: { - meta: {}, - doc_count: 1, - numTriggeredActions: { - value: 5.0, - }, - numScheduledActions: { - value: 5.0, - }, - outcomeAndMessage: { - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: 1.0, - hits: [ + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ { - _index: '.kibana-event-log-8.2.0-000001', - _id: 'a4wIZX8B8TGQpG7Xwpnz', - _score: 1.0, - _source: { - event: { - outcome: 'success', - }, - message: - "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", - }, + key: 'success', + doc_count: 5, }, ], }, }, - scheduleDelay: { - value: 3.345e9, - }, - totalSearchDuration: { - value: 0.0, - }, - esSearchDuration: { - value: 0.0, - }, - executionDuration: { - value: 1.165e9, - }, - executeStartTime: { - value: 1.646667545604e12, - value_as_string: '2022-03-07T15:39:05.604Z', - }, - }, - actionExecution: { - meta: {}, - doc_count: 5, - actionOutcomes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'success', - doc_count: 5, - }, - ], - }, }, - }, - ], - }, - executionUuidCardinality: { - value: 374, + ], + }, + executionUuidCardinality: { + value: 374, + }, }, }, }; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 3eed02bb10e31..c261b4ddbba25 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -70,6 +70,7 @@ import { import { EVENT_LOG_ACTIONS } from '../plugin'; import { IN_MEMORY_METRICS } from '../monitoring'; import { translations } from '../constants/translations'; +import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -101,6 +102,7 @@ describe('Task Runner', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const dataPlugin = dataPluginMock.createStartContract(); const uiSettingsService = uiSettingsServiceMock.createStartContract(); const inMemoryMetrics = inMemoryMetricsMock.create(); @@ -113,6 +115,7 @@ describe('Task Runner', () => { type EnqueueFunction = (options: ExecuteOptions) => Promise; const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { + data: dataPlugin, savedObjects: savedObjectsService, uiSettings: uiSettingsService, elasticsearch: elasticsearchService, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index d3be5e3e6623d..0e69131711067 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -391,6 +391,7 @@ export class TaskRunner< savedObjectsClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), + searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest), alertFactory: createAlertFactory< InstanceState, InstanceContext, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 39d2aa8418394..68c005cc4b765 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -35,6 +35,7 @@ import { IEventLogger } from '../../../event_log/server'; import { Alert, RecoveredActionGroup } from '../../common'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; +import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; jest.mock('uuid', () => ({ @@ -104,6 +105,7 @@ describe('Task Runner Cancel', () => { const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); const uiSettingsService = uiSettingsServiceMock.createStartContract(); + const dataPlugin = dataPluginMock.createStartContract(); const inMemoryMetrics = inMemoryMetricsMock.create(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { @@ -113,6 +115,7 @@ describe('Task Runner Cancel', () => { }; const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { + data: dataPlugin, savedObjects: savedObjectsService, uiSettings: uiSettingsService, elasticsearch: elasticsearchService, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 123f5d46e62ad..8cda4f9567d3f 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -24,6 +24,7 @@ import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { executionContextServiceMock } from '../../../../../src/core/server/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; const inMemoryMetrics = inMemoryMetricsMock.create(); @@ -33,6 +34,7 @@ const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const uiSettingsService = uiSettingsServiceMock.createStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); +const dataPlugin = dataPluginMock.createStartContract(); const ruleType: UntypedNormalizedRuleType = { id: 'test', name: 'My test alert', @@ -80,6 +82,7 @@ describe('Task Runner Factory', () => { const rulesClient = rulesClientMock.create(); const taskRunnerFactoryInitializerParams: jest.Mocked = { + data: dataPlugin, savedObjects: savedObjectsService, uiSettings: uiSettingsService, elasticsearch: elasticsearchService, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index f6f80e66ce9c3..0ceced10e799b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -32,10 +32,12 @@ import { TaskRunner } from './task_runner'; import { IEventLogger } from '../../../event_log/server'; import { RulesClient } from '../rules_client'; import { NormalizedRuleType } from '../rule_type_registry'; +import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server'; import { InMemoryMetrics } from '../monitoring'; export interface TaskRunnerContext { logger: Logger; + data: DataPluginStart; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; elasticsearch: ElasticsearchServiceStart; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 37df778b6185a..10d191d9b43e4 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -42,6 +42,7 @@ import { AlertExecutionStatusWarningReasons, } from '../common'; import { LicenseType } from '../../licensing/server'; +import { ISearchStartSearchSource } from '../../../../src/plugins/data/common'; import { RuleTypeConfig } from './config'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -73,6 +74,7 @@ export interface AlertServices< InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never > { + searchSourceClient: Promise; savedObjectsClient: SavedObjectsClientContract; uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts b/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts index 3bb64ad00a194..61383656e67d5 100644 --- a/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts @@ -15,6 +15,7 @@ import { getExecutionsPerDayCount, getExecutionTimeoutsPerDayCount, getFailedAndUnrecognizedTasksPerDay, + parsePercentileAggsByRuleType, } from './alerting_telemetry'; describe('alerting telemetry', () => { @@ -181,6 +182,41 @@ Object { avgTotalSearchDuration: { value: 30.642857142857142, }, + percentileScheduledActions: { + values: { + '50.0': 4.0, + '90.0': 26.0, + '99.0': 26.0, + }, + }, + aggsByType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.index-threshold', + doc_count: 149, + percentileScheduledActions: { + values: { + '50.0': 4.0, + '90.0': 26.0, + '99.0': 26.0, + }, + }, + }, + { + key: 'logs.alert.document.count', + doc_count: 1, + percentileScheduledActions: { + values: { + '50.0': 10.0, + '90.0': 10.0, + '99.0': 10.0, + }, + }, + }, + ], + }, }, hits: { hits: [], @@ -228,6 +264,25 @@ Object { }, countTotal: 4, countTotalFailures: 4, + scheduledActionsPercentiles: { + p50: 4, + p90: 26, + p99: 26, + }, + scheduledActionsPercentilesByType: { + p50: { + '__index-threshold': 4, + logs__alert__document__count: 10, + }, + p90: { + '__index-threshold': 26, + logs__alert__document__count: 10, + }, + p99: { + '__index-threshold': 26, + logs__alert__document__count: 10, + }, + }, }); }); @@ -316,4 +371,150 @@ Object { countTotal: 5, }); }); + + test('parsePercentileAggsByRuleType', () => { + const aggsByType = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.index-threshold', + doc_count: 149, + percentileScheduledActions: { + values: { + '50.0': 4.0, + '90.0': 26.0, + '99.0': 26.0, + }, + }, + }, + { + key: 'logs.alert.document.count', + doc_count: 1, + percentileScheduledActions: { + values: { + '50.0': 10.0, + '90.0': 10.0, + '99.0': 10.0, + }, + }, + }, + { + key: 'document.test.', + doc_count: 1, + percentileScheduledActions: { + values: { + '50.0': null, + '90.0': null, + '99.0': null, + }, + }, + }, + ], + }; + expect( + parsePercentileAggsByRuleType(aggsByType.buckets, 'percentileScheduledActions.values') + ).toEqual({ + p50: { + '__index-threshold': 4, + document__test__: 0, + logs__alert__document__count: 10, + }, + p90: { + '__index-threshold': 26, + document__test__: 0, + logs__alert__document__count: 10, + }, + p99: { + '__index-threshold': 26, + document__test__: 0, + logs__alert__document__count: 10, + }, + }); + }); + + test('parsePercentileAggsByRuleType handles unknown path', () => { + const aggsByType = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.index-threshold', + doc_count: 149, + percentileScheduledActions: { + values: { + '50.0': 4.0, + '90.0': 26.0, + '99.0': 26.0, + }, + }, + }, + { + key: 'logs.alert.document.count', + doc_count: 1, + percentileScheduledActions: { + values: { + '50.0': 10.0, + '90.0': 10.0, + '99.0': 10.0, + }, + }, + }, + ], + }; + expect(parsePercentileAggsByRuleType(aggsByType.buckets, 'foo.values')).toEqual({ + p50: {}, + p90: {}, + p99: {}, + }); + }); + + test('parsePercentileAggsByRuleType handles unrecognized percentiles', () => { + const aggsByType = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.index-threshold', + doc_count: 149, + percentileScheduledActions: { + values: { + '50.0': 4.0, + '75.0': 8.0, + '90.0': 26.0, + '99.0': 26.0, + }, + }, + }, + { + key: 'logs.alert.document.count', + doc_count: 1, + percentileScheduledActions: { + values: { + '50.0': 10.0, + '75.0': 10.0, + '90.0': 10.0, + '99.0': 10.0, + }, + }, + }, + ], + }; + expect( + parsePercentileAggsByRuleType(aggsByType.buckets, 'percentileScheduledActions.values') + ).toEqual({ + p50: { + '__index-threshold': 4, + logs__alert__document__count: 10, + }, + p90: { + '__index-threshold': 26, + logs__alert__document__count: 10, + }, + p99: { + '__index-threshold': 26, + logs__alert__document__count: 10, + }, + }); + }); }); diff --git a/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts index 4fbad593d1600..2e360374faa42 100644 --- a/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts @@ -5,8 +5,17 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ElasticsearchClient } from 'kibana/server'; +import { get, merge } from 'lodash'; import { AlertingUsage } from './types'; +import { NUM_ALERTING_RULE_TYPES } from './alerting_usage_collector'; + +const percentileFieldNameMapping: Record = { + '50.0': 'p50', + '90.0': 'p90', + '99.0': 'p99', +}; const ruleTypeMetric = { scripted_metric: { @@ -38,6 +47,13 @@ const ruleTypeMetric = { }, }; +const scheduledActionsPercentilesAgg = { + percentiles: { + field: 'kibana.alert.rule.execution.metrics.number_of_scheduled_actions', + percents: [50, 90, 99], + }, +}; + const ruleTypeExecutionsWithDurationMetric = { scripted_metric: { init_script: @@ -409,6 +425,16 @@ export async function getExecutionsPerDayCount( avgTotalSearchDuration: { avg: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, }, + percentileScheduledActions: scheduledActionsPercentilesAgg, + aggsByType: { + terms: { + field: 'rule.category', + size: NUM_ALERTING_RULE_TYPES, + }, + aggs: { + percentileScheduledActions: scheduledActionsPercentilesAgg, + }, + }, }, }, }); @@ -439,6 +465,14 @@ export async function getExecutionsPerDayCount( searchResult.aggregations.avgTotalSearchDuration.value ); + const aggsScheduledActionsPercentiles = + // @ts-expect-error aggegation type is not specified + searchResult.aggregations.percentileScheduledActions.values; + + const aggsByTypeBuckets = + // @ts-expect-error aggegation type is not specified + searchResult.aggregations.aggsByType.buckets; + const executionFailuresAggregations = searchResult.aggregations as { failuresByReason: { value: { reasons: Record> } }; }; @@ -537,6 +571,21 @@ export async function getExecutionsPerDayCount( }), {} ), + scheduledActionsPercentiles: Object.keys(aggsScheduledActionsPercentiles).reduce( + // ES DSL aggregations are returned as `any` by esClient.search + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc: any, curr: string) => ({ + ...acc, + ...(percentileFieldNameMapping[curr] + ? { [percentileFieldNameMapping[curr]]: aggsScheduledActionsPercentiles[curr] } + : {}), + }), + {} + ), + scheduledActionsPercentilesByType: parsePercentileAggsByRuleType( + aggsByTypeBuckets, + 'percentileScheduledActions.values' + ), }; } @@ -701,3 +750,30 @@ function replaceDotSymbolsInRuleTypeIds(ruleTypeIdObj: Record) { {} ); } + +export function parsePercentileAggsByRuleType( + aggsByType: estypes.AggregationsStringTermsBucketKeys[], + path: string +) { + return (aggsByType ?? []).reduce( + (acc, curr) => { + const percentiles = get(curr, path, {}); + return merge( + acc, + Object.keys(percentiles).reduce((pacc, pcurr) => { + return { + ...pacc, + ...(percentileFieldNameMapping[pcurr] + ? { + [percentileFieldNameMapping[pcurr]]: { + [replaceDotSymbols(curr.key)]: percentiles[pcurr] ?? 0, + }, + } + : {}), + }; + }, {}) + ); + }, + { p50: {}, p90: {}, p99: {} } + ); +} diff --git a/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts index f375e758a8c9b..b0990bab9491d 100644 --- a/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts @@ -56,6 +56,8 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { xpack__ml__anomaly_detection_jobs_health: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention }; +export const NUM_ALERTING_RULE_TYPES = Object.keys(byTypeSchema).length; + const byReasonSchema: MakeSchemaFrom['count_rules_executions_failured_by_reason_per_day'] = { // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) @@ -66,6 +68,20 @@ const byReasonSchema: MakeSchemaFrom['count_rules_executions_fail unknown: { type: 'long' }, }; +const byPercentileSchema: MakeSchemaFrom['percentile_num_scheduled_actions_per_day'] = + { + p50: { type: 'long' }, + p90: { type: 'long' }, + p99: { type: 'long' }, + }; + +const byPercentileSchemaByType: MakeSchemaFrom['percentile_num_scheduled_actions_by_type_per_day'] = + { + p50: byTypeSchema, + p90: byTypeSchema, + p99: byTypeSchema, + }; + const byReasonSchemaByType: MakeSchemaFrom['count_rules_executions_failured_by_reason_by_type_per_day'] = { // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) @@ -160,6 +176,16 @@ export function createAlertingUsageCollector( avg_es_search_duration_by_type_per_day: {}, avg_total_search_duration_per_day: 0, avg_total_search_duration_by_type_per_day: {}, + percentile_num_scheduled_actions_per_day: { + p50: 0, + p90: 0, + p99: 0, + }, + percentile_num_scheduled_actions_by_type_per_day: { + p50: {}, + p90: {}, + p99: {}, + }, }; } }, @@ -211,6 +237,8 @@ export function createAlertingUsageCollector( avg_es_search_duration_by_type_per_day: byTypeSchema, avg_total_search_duration_per_day: { type: 'long' }, avg_total_search_duration_by_type_per_day: byTypeSchema, + percentile_num_scheduled_actions_per_day: byPercentileSchema, + percentile_num_scheduled_actions_by_type_per_day: byPercentileSchemaByType, }, }); } diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts index 7aee043653806..0d0d2d802a3fb 100644 --- a/x-pack/plugins/alerting/server/usage/task.ts +++ b/x-pack/plugins/alerting/server/usage/task.ts @@ -144,6 +144,10 @@ export function telemetryTaskRunner( avg_total_search_duration_per_day: dailyExecutionCounts.avgTotalSearchDuration, avg_total_search_duration_by_type_per_day: dailyExecutionCounts.avgTotalSearchDurationByType, + percentile_num_scheduled_actions_per_day: + dailyExecutionCounts.scheduledActionsPercentiles, + percentile_num_scheduled_actions_by_type_per_day: + dailyExecutionCounts.scheduledActionsPercentilesByType, }, runAt: getNextMidnight(), }; diff --git a/x-pack/plugins/alerting/server/usage/types.ts b/x-pack/plugins/alerting/server/usage/types.ts index a03483bd54007..00bd3b46f91b1 100644 --- a/x-pack/plugins/alerting/server/usage/types.ts +++ b/x-pack/plugins/alerting/server/usage/types.ts @@ -25,6 +25,16 @@ export interface AlertingUsage { string, Record >; + percentile_num_scheduled_actions_per_day: { + p50: number; + p90: number; + p99: number; + }; + percentile_num_scheduled_actions_by_type_per_day: { + p50: Record; + p90: Record; + p99: Record; + }; avg_execution_time_per_day: number; avg_execution_time_by_type_per_day: Record; avg_es_search_duration_per_day: number; diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx index 628ba40f20efd..392adb9c589a4 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx @@ -60,7 +60,7 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { ...ruleParams, }, { - windowSize: 15, + windowSize: 30, windowUnit: 'm', anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, environment: ENVIRONMENT_ALL.value, diff --git a/x-pack/plugins/apm/scripts/test/api.js b/x-pack/plugins/apm/scripts/test/api.js index 5769224f90ac2..01e0198360bc3 100644 --- a/x-pack/plugins/apm/scripts/test/api.js +++ b/x-pack/plugins/apm/scripts/test/api.js @@ -57,6 +57,7 @@ const { argv } = yargs(process.argv.slice(2)) const { trial, server, runner, grep, inspect } = argv; const license = trial ? 'trial' : 'basic'; + console.log(`License: ${license}`); let ftrScript = 'functional_tests'; diff --git a/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts index 04d1fb775cea0..5affecb3541cc 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts @@ -4,44 +4,48 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { schema } from '@kbn/config-schema'; -import { compact } from 'lodash'; -import { ESSearchResponse } from 'src/core/types/elasticsearch'; +import datemath from '@elastic/datemath'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { schema } from '@kbn/config-schema'; import { ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, - ALERT_SEVERITY, ALERT_REASON, + ALERT_SEVERITY, } from '@kbn/rule-data-utils'; -import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { getSeverity } from '../../../common/anomaly_detection'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_TYPE, - SERVICE_ENVIRONMENT, -} from '../../../common/elasticsearch_fieldnames'; -import { getAlertUrlTransaction } from '../../../common/utils/formatters'; -import { asMutableArray } from '../../../common/utils/as_mutable_array'; -import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; +import { compact } from 'lodash'; +import { ESSearchResponse } from 'src/core/types/elasticsearch'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { termQuery } from '../../../../observability/server'; +import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; import { AlertType, ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, formatAnomalyReason, } from '../../../common/alert_types'; -import { getMLJobs } from '../service_map/get_service_anomalies'; -import { apmActionVariables } from './action_variables'; -import { RegisterRuleDependencies } from './register_apm_alerts'; +import { getSeverity } from '../../../common/anomaly_detection'; +import { + ApmMlDetectorType, + getApmMlDetectorIndex, +} from '../../../common/anomaly_detection/apm_ml_detectors'; +import { + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; -import { termQuery } from '../../../../observability/server'; +import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; +import { getMLJobs } from '../service_map/get_service_anomalies'; +import { apmActionVariables } from './action_variables'; +import { RegisterRuleDependencies } from './register_apm_alerts'; const paramsSchema = schema.object({ serviceName: schema.maybe(schema.string()), @@ -130,6 +134,14 @@ export function registerAnomalyAlertType({ return {}; } + // start time must be at least 30, does like this to support rules created before this change where default was 15 + const startTime = Math.min( + datemath.parse('now-30m')!.valueOf(), + datemath + .parse(`now-${ruleParams.windowSize}${ruleParams.windowUnit}`) + ?.valueOf() || 0 + ); + const jobIds = mlJobs.map((job) => job.jobId); const anomalySearchParams = { body: { @@ -143,13 +155,17 @@ export function registerAnomalyAlertType({ { range: { timestamp: { - gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`, + gte: startTime, format: 'epoch_millis', }, }, }, ...termQuery('partition_field_value', ruleParams.serviceName), ...termQuery('by_field_value', ruleParams.transactionType), + ...termQuery( + 'detector_index', + getApmMlDetectorIndex(ApmMlDetectorType.txLatency) + ), ] as QueryDslQueryContainer[], }, }, diff --git a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx index 18821d24e3053..1666557ec2648 100644 --- a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx @@ -18,19 +18,15 @@ const AllCasesSelectorModalLazy: React.FC = lazy( export const getAllCasesSelectorModalLazy = ({ owner, userCanCrud, - alertData, hiddenStatuses, onRowClick, - updateCase, onClose, }: GetAllCasesSelectorModalProps) => ( }> @@ -42,20 +38,14 @@ export const getAllCasesSelectorModalLazy = ({ * cases provider. to be further refactored https://github.com/elastic/kibana/issues/123183 */ export const getAllCasesSelectorModalNoProviderLazy = ({ - alertData, - attachments, hiddenStatuses, onRowClick, - updateCase, onClose, }: AllCasesSelectorModalProps) => ( }> diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 88aad5fb64408..c8e656b8117eb 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -17,7 +17,7 @@ import { TestProviders } from '../../common/mock'; import { casesStatus, useGetCasesMockState, mockCase, connectorsMock } from '../../containers/mock'; import { StatusAll } from '../../../common/ui/types'; -import { CaseStatuses, CommentType } from '../../../common/api'; +import { CaseStatuses } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -515,46 +515,6 @@ describe('AllCasesListGeneric', () => { }); }); - it('should call postComment when a case is selected in isSelectorView=true and has attachments', async () => { - const postCommentMockedValue = { status: { isLoading: false }, postComment: jest.fn() }; - usePostCommentMock.mockReturnValueOnce(postCommentMockedValue); - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click'); - await waitFor(() => { - expect(postCommentMockedValue.postComment).toHaveBeenCalledWith({ - caseId: '1', - data: { - alertId: 'alert-id-201', - index: 'index-id-1', - owner: 'test', - rule: { - id: 'rule-id-1', - name: 'Awesome myrule', - }, - type: 'alert', - }, - }); - }); - }); - it('should call onRowClick with no cases and isSelectorView=true', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index ffcb7a1abe416..5eac485e24c7b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -16,14 +16,8 @@ import { FilterOptions, SortFieldCase, } from '../../../common/ui/types'; -import { - CaseStatuses, - CommentRequestAlertType, - caseStatuses, - CommentType, -} from '../../../common/api'; +import { CaseStatuses, caseStatuses } from '../../../common/api'; import { useGetCases } from '../../containers/use_get_cases'; -import { usePostComment } from '../../containers/use_post_comment'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './columns'; @@ -33,7 +27,6 @@ import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { CaseAttachments } from '../../types'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -52,28 +45,14 @@ const getSortField = (field: string): SortFieldCase => field === SortFieldCase.closedAt ? SortFieldCase.closedAt : SortFieldCase.createdAt; export interface AllCasesListProps { - /** - * @deprecated Use the attachments prop instead - */ - alertData?: Omit; hiddenStatuses?: CaseStatusWithAllStatus[]; isSelectorView?: boolean; onRowClick?: (theCase?: Case) => void; - updateCase?: (newCase: Case) => void; doRefresh?: () => void; - attachments?: CaseAttachments; } export const AllCasesList = React.memo( - ({ - alertData, - attachments, - hiddenStatuses = [], - isSelectorView = false, - onRowClick, - updateCase, - doRefresh, - }) => { + ({ hiddenStatuses = [], isSelectorView = false, onRowClick, doRefresh }) => { const { owner, userCanCrud } = useCasesContext(); const hasOwner = !!owner.length; const availableSolutions = useAvailableCasesOwners(); @@ -97,8 +76,6 @@ export const AllCasesList = React.memo( setSelectedCases, } = useGetCases({ initialFilterOptions }); - // Post Comment to Case - const { postComment, isLoading: isCommentUpdating } = usePostComment(); const { connectors } = useConnectors(); const sorting = useMemo( @@ -181,19 +158,6 @@ export const AllCasesList = React.memo( const showActions = userCanCrud && !isSelectorView; - // TODO remove the deprecated alertData field when cleaning up - // code https://github.com/elastic/kibana/issues/123183 - // This code is to support the deprecated alertData prop - const toAttach = useMemo((): CaseAttachments | undefined => { - if (attachments !== undefined || alertData !== undefined) { - const _toAttach = attachments ?? []; - if (alertData !== undefined) { - _toAttach.push({ ...alertData, type: CommentType.alert }); - } - return _toAttach; - } - }, [alertData, attachments]); - const columns = useCasesColumns({ dispatchUpdateCaseProperty, filterStatus: filterOptions.status, @@ -204,9 +168,6 @@ export const AllCasesList = React.memo( userCanCrud, connectors, onRowClick, - attachments: toAttach, - postComment, - updateCase, showSolutionColumn: !hasOwner && availableSolutions.length > 1, }); @@ -243,7 +204,7 @@ export const AllCasesList = React.memo( size="xs" color="accent" className="essentialAnimation" - $isShow={(isCasesLoading || isLoading || isCommentUpdating) && !isDataEmpty} + $isShow={(isCasesLoading || isLoading) && !isDataEmpty} /> ( goToCreateCase={onRowClick} handleIsLoading={handleIsLoading} isCasesLoading={isCasesLoading} - isCommentUpdating={isCommentUpdating} + isCommentUpdating={isCasesLoading} isDataEmpty={isDataEmpty} isSelectorView={isSelectorView} onChange={tableOnChangeCallback} diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index a05673d3e095a..543e6ef6f4871 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -38,8 +38,6 @@ import { useApplicationCapabilities, useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; -import { PostComment } from '../../containers/use_post_comment'; -import { CaseAttachments } from '../../types'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesFeatures } from '../cases_context/use_cases_features'; @@ -73,9 +71,6 @@ export interface GetCasesColumn { userCanCrud: boolean; connectors?: ActionConnector[]; onRowClick?: (theCase: Case) => void; - attachments?: CaseAttachments; - postComment?: (args: PostComment) => Promise; - updateCase?: (newCase: Case) => void; showSolutionColumn?: boolean; } @@ -89,9 +84,6 @@ export const useCasesColumns = ({ userCanCrud, connectors = [], onRowClick, - attachments, - postComment, - updateCase, showSolutionColumn, }: GetCasesColumn): CasesColumns[] => { // Delete case @@ -141,24 +133,11 @@ export const useCasesColumns = ({ const assignCaseAction = useCallback( async (theCase: Case) => { - // TODO currently the API only supports to add a comment at the time - // once the API is updated we should use bulk post comment #124814 - // this operation is intentionally made in sequence - if (attachments !== undefined && attachments.length > 0) { - for (const attachment of attachments) { - await postComment?.({ - caseId: theCase.id, - data: attachment, - }); - } - updateCase?.(theCase); - } - if (onRowClick) { onRowClick(theCase); } }, - [attachments, onRowClick, postComment, updateCase] + [onRowClick] ); useEffect(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx index ef01ead1cb07d..eba8888f3367a 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx @@ -11,7 +11,6 @@ import { mount } from 'enzyme'; import { AllCasesSelectorModal } from '.'; import { TestProviders } from '../../../common/mock'; import { AllCasesList } from '../all_cases_list'; -import { SECURITY_SOLUTION_OWNER } from '../../../../common/constants'; jest.mock('../all_cases_list'); @@ -19,7 +18,6 @@ const onRowClick = jest.fn(); const defaultProps = { onRowClick, }; -const updateCase = jest.fn(); describe('AllCasesSelectorModal', () => { beforeEach(() => { @@ -50,17 +48,7 @@ describe('AllCasesSelectorModal', () => { it('pass the correct props to getAllCases method', () => { const fullProps = { ...defaultProps, - alertData: { - rule: { - id: 'rule-id', - name: 'rule', - }, - index: 'index-id', - alertId: 'alert-id', - owner: SECURITY_SOLUTION_OWNER, - }, hiddenStatuses: [], - updateCase, }; mount( @@ -72,10 +60,8 @@ describe('AllCasesSelectorModal', () => { // @ts-ignore idk what this mock style is but it works ¯\_(ツ)_/¯ expect(AllCasesList.type.mock.calls[0][0]).toEqual( expect.objectContaining({ - alertData: fullProps.alertData, hiddenStatuses: fullProps.hiddenStatuses, isSelectorView: true, - updateCase, }) ); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx index ba553b28a34e0..581ecef47ad88 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx @@ -16,20 +16,13 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; import { Case, CaseStatusWithAllStatus } from '../../../../common/ui/types'; -import { CommentRequestAlertType } from '../../../../common/api'; import * as i18n from '../../../common/translations'; import { AllCasesList } from '../all_cases_list'; -import { CaseAttachments } from '../../../types'; + export interface AllCasesSelectorModalProps { - /** - * @deprecated Use the attachments prop instead - */ - alertData?: Omit; hiddenStatuses?: CaseStatusWithAllStatus[]; onRowClick?: (theCase?: Case) => void; - updateCase?: (newCase: Case) => void; onClose?: () => void; - attachments?: CaseAttachments; } const Modal = styled(EuiModal)` @@ -40,7 +33,7 @@ const Modal = styled(EuiModal)` `; export const AllCasesSelectorModal = React.memo( - ({ alertData, attachments, hiddenStatuses, onRowClick, updateCase, onClose }) => { + ({ hiddenStatuses, onRowClick, onClose }) => { const [isModalOpen, setIsModalOpen] = useState(true); const closeModal = useCallback(() => { if (onClose) { @@ -66,12 +59,9 @@ export const AllCasesSelectorModal = React.memo( diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index b0e316e891744..25360800554b2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -5,42 +5,82 @@ * 2.0. */ -/* eslint-disable react/display-name */ - -import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/dom'; +import { act, renderHook } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { CaseStatuses, StatusAll } from '../../../../common'; +import AllCasesSelectorModal from '.'; +import { Case, CaseStatuses, StatusAll } from '../../../../common'; +import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import { alertComment } from '../../../containers/mock'; +import { usePostComment } from '../../../containers/use_post_comment'; +import { SupportedCaseAttachment } from '../../../types'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; + jest.mock('../../../common/use_cases_toast'); +jest.mock('../../../containers/use_post_comment'); +// dummy mock, will call onRowclick when rendering +jest.mock('./all_cases_selector_modal', () => { + return { + AllCasesSelectorModal: jest.fn(), + }; +}); + +const useCasesToastMock = useCasesToast as jest.Mock; + +const AllCasesSelectorModalMock = AllCasesSelectorModal as unknown as jest.Mock; + +// test component to test the hook integration +const TestComponent: React.FC = () => { + const hook = useCasesAddToExistingCaseModal({ + attachments: [alertComment as SupportedCaseAttachment], + }); + + const onClick = () => { + hook.open(); + }; + + return