diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml
index a236f9c37b313..1335866675564 100644
--- a/.buildkite/pipelines/hourly.yml
+++ b/.buildkite/pipelines/hourly.yml
@@ -19,24 +19,28 @@ steps:
label: 'Default CI Group'
parallelism: 27
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 250
key: default-cigroup
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: CI_GROUP=Docker .buildkite/scripts/steps/functional/xpack_cigroup.sh
label: 'Docker CI Group'
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
key: default-cigroup-docker
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
@@ -44,78 +48,92 @@ steps:
label: 'OSS CI Group'
parallelism: 11
agents:
- queue: ci-group-4d
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
key: oss-cigroup
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/oss_accessibility.sh
label: 'OSS Accessibility Tests'
agents:
- queue: ci-group-4d
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/xpack_accessibility.sh
label: 'Default Accessibility Tests'
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/oss_firefox.sh
label: 'OSS Firefox Tests'
agents:
- queue: ci-group-4d
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/xpack_firefox.sh
label: 'Default Firefox Tests'
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/oss_misc.sh
label: 'OSS Misc Functional Tests'
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
- command: .buildkite/scripts/steps/functional/xpack_saved_object_field_metrics.sh
label: 'Saved Object Field Metrics'
agents:
- queue: n2-4
+ queue: n2-4-spot
depends_on: build
timeout_in_minutes: 120
retry:
automatic:
+ - exit_status: '-1'
+ limit: 3
- exit_status: '*'
limit: 1
@@ -123,31 +141,47 @@ steps:
label: 'Jest Tests'
parallelism: 8
agents:
- queue: n2-4
+ queue: n2-4-spot
timeout_in_minutes: 90
key: jest
+ retry:
+ automatic:
+ - exit_status: '-1'
+ limit: 3
- command: .buildkite/scripts/steps/test/jest_integration.sh
label: 'Jest Integration Tests'
parallelism: 3
agents:
- queue: n2-4
+ queue: n2-4-spot
timeout_in_minutes: 120
key: jest-integration
+ retry:
+ automatic:
+ - exit_status: '-1'
+ limit: 3
- command: .buildkite/scripts/steps/test/api_integration.sh
label: 'API Integration Tests'
agents:
- queue: n2-2
+ queue: n2-2-spot
timeout_in_minutes: 120
key: api-integration
+ retry:
+ automatic:
+ - exit_status: '-1'
+ limit: 3
- command: .buildkite/scripts/steps/lint.sh
label: 'Linting'
agents:
- queue: n2-2
+ queue: n2-2-spot
key: linting
timeout_in_minutes: 90
+ retry:
+ automatic:
+ - exit_status: '-1'
+ limit: 3
- command: .buildkite/scripts/steps/lint_with_types.sh
label: 'Linting (with types)'
@@ -166,9 +200,13 @@ steps:
- command: .buildkite/scripts/steps/storybooks/build_and_upload.sh
label: 'Build Storybooks'
agents:
- queue: c2-4
+ queue: n2-4-spot
key: storybooks
timeout_in_minutes: 60
+ retry:
+ automatic:
+ - exit_status: '-1'
+ limit: 3
- wait: ~
continue_on_failure: true
diff --git a/docs/api/cases.asciidoc b/docs/api/cases.asciidoc
index 45186a4e7d489..5aa837d35676e 100644
--- a/docs/api/cases.asciidoc
+++ b/docs/api/cases.asciidoc
@@ -6,9 +6,8 @@ these APIs:
* {security-guide}/cases-api-add-comment.html[Add comment]
* <>
-* {security-guide}/cases-api-delete-case.html[Delete case]
-* {security-guide}/cases-api-delete-all-comments.html[Delete all comments]
-* {security-guide}/cases-api-delete-comment.html[Delete comment]
+* <>
+* <>
* {security-guide}/cases-api-find-alert.html[Find all alerts attached to a case]
* <>
* {security-guide}/cases-api-find-cases-by-alert.html[Find cases by alert]
@@ -29,8 +28,11 @@ these APIs:
//CREATE
include::cases/cases-api-create.asciidoc[leveloffset=+1]
+//DELETE
+include::cases/cases-api-delete-cases.asciidoc[leveloffset=+1]
+include::cases/cases-api-delete-comments.asciidoc[leveloffset=+1]
//FIND
include::cases/cases-api-find-cases.asciidoc[leveloffset=+1]
include::cases/cases-api-find-connectors.asciidoc[leveloffset=+1]
//UPDATE
-include::cases/cases-api-update.asciidoc[leveloffset=+1]
\ No newline at end of file
+include::cases/cases-api-update.asciidoc[leveloffset=+1]
diff --git a/docs/api/cases/cases-api-delete-cases.asciidoc b/docs/api/cases/cases-api-delete-cases.asciidoc
new file mode 100644
index 0000000000000..5e4436806f14f
--- /dev/null
+++ b/docs/api/cases/cases-api-delete-cases.asciidoc
@@ -0,0 +1,52 @@
+[[cases-api-delete-cases]]
+== Delete cases API
+++++
+Delete cases
+++++
+
+Deletes one or more cases.
+
+=== Request
+
+`DELETE :/api/cases?ids=["",""]`
+
+`DELETE :/s//api/cases?ids=["",""]`
+
+=== Prerequisite
+
+You must have `all` privileges for the *Cases* feature in the *Management*,
+*{observability}*, or *Security* section of the
+<>, depending on the
+`owner` of the cases you're deleting.
+
+=== Path parameters
+
+``::
+(Optional, string) An identifier for the space. If it is not specified, the
+default space is used.
+
+=== Query parameters
+
+`ids`::
+(Required, string) The cases that you want to remove. To retrieve case IDs, use
+<>.
++
+NOTE: All non-ASCII characters must be URL encoded.
+
+==== Response code
+
+`204`::
+ Indicates a successful call.
+
+=== Example
+
+Delete cases with these IDs:
+
+* `2e3a54f0-6754-11ea-a1c2-e3a8bc9f7aca`
+* `40b9a450-66a0-11ea-be1b-2bd3fef48984`
+
+[source,console]
+--------------------------------------------------
+DELETE api/cases?ids=%5B%222e3a54f0-6754-11ea-a1c2-e3a8bc9f7aca%22%2C%2240b9a450-66a0-11ea-be1b-2bd3fef48984%22%5D
+--------------------------------------------------
+// KIBANA
diff --git a/docs/api/cases/cases-api-delete-comments.asciidoc b/docs/api/cases/cases-api-delete-comments.asciidoc
new file mode 100644
index 0000000000000..66421944ac1be
--- /dev/null
+++ b/docs/api/cases/cases-api-delete-comments.asciidoc
@@ -0,0 +1,63 @@
+[[cases-api-delete-comments]]
+== Delete comments from case API
+++++
+Delete comments
+++++
+
+Deletes one or all comments from a case.
+
+=== Request
+
+`DELETE :/api/cases//comments`
+
+`DELETE :/api/cases//comments/`
+
+`DELETE :/s//api/cases//comments`
+
+`DELETE :/s//api/cases//comments/`
+
+=== Prerequisite
+
+You must have `all` privileges for the *Cases* feature in the *Management*,
+*{observability}*, or *Security* section of the
+<>, depending on the
+`owner` of the cases you're updating.
+
+=== Path parameters
+
+``::
+(Required, string) The identifier for the case. To retrieve case IDs, use
+<>.
+
+``::
+(Optional, string) The identifier for the comment.
+//To retrieve comment IDs, use <>.
+If it is not specified, all comments are deleted.
+
+::
+(Optional, string) An identifier for the space. If it is not specified, the
+default space is used.
+
+=== Response code
+
+`204`::
+ Indicates a successful call.
+
+=== Example
+
+Delete all comments from case ID `9c235210-6834-11ea-a78c-6ffb38a34414`:
+
+[source,console]
+--------------------------------------------------
+DELETE api/cases/a18b38a0-71b0-11ea-a0b2-c51ea50a58e2/comments
+--------------------------------------------------
+// KIBANA
+
+Delete comment ID `71ec1870-725b-11ea-a0b2-c51ea50a58e2` from case ID
+`a18b38a0-71b0-11ea-a0b2-c51ea50a58e2`:
+
+[source,sh]
+--------------------------------------------------
+DELETE api/cases/a18b38a0-71b0-11ea-a0b2-c51ea50a58e2/comments/71ec1870-725b-11ea-a0b2-c51ea50a58e2
+--------------------------------------------------
+// KIBANA
diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc
index aefaf4eab40fa..b6cac30c1bc88 100644
--- a/docs/developer/plugin-list.asciidoc
+++ b/docs/developer/plugin-list.asciidoc
@@ -432,7 +432,7 @@ security and spaces filtering.
|{kib-repo}blob/{branch}/x-pack/plugins/event_log/README.md[eventLog]
|The event log plugin provides a persistent history of alerting and action
-actitivies.
+activities.
|{kib-repo}blob/{branch}/x-pack/plugins/features/README.md[features]
diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc
index 58677141ab0c8..caa6512955f67 100644
--- a/docs/user/security/audit-logging.asciidoc
+++ b/docs/user/security/audit-logging.asciidoc
@@ -155,6 +155,15 @@ Refer to the corresponding {es} logs for potential write errors.
| `unknown` | User is updating an alert.
| `failure` | User is not authorized to update an alert.
+.2+| `rule_snooze`
+| `unknown` | User is snoozing a rule.
+| `failure` | User is not authorized to snooze a rule.
+
+.2+| `rule_unsnooze`
+| `unknown` | User is unsnoozing a rule.
+| `failure` | User is not authorized to unsnooze a rule.
+
+
3+a|
====== Type: deletion
diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx
index c77c3c24be147..9bd5bb0f3f8a2 100644
--- a/examples/search_examples/public/application.tsx
+++ b/examples/search_examples/public/application.tsx
@@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page';
import { SearchExamplesApp } from './search/app';
import { SearchSessionsExampleApp } from './search_sessions/app';
import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public';
+import { SqlSearchExampleApp } from './sql_search/app';
const LINKS: ExampleLink[] = [
{
path: '/search',
title: 'Search',
},
+ {
+ path: '/sql-search',
+ title: 'SQL Search',
+ },
{
path: '/search-sessions',
title: 'Search Sessions',
@@ -51,12 +56,16 @@ export const renderApp = (
/>
+
+
+
+
diff --git a/examples/search_examples/public/sql_search/app.tsx b/examples/search_examples/public/sql_search/app.tsx
new file mode 100644
index 0000000000000..acb640c4d82db
--- /dev/null
+++ b/examples/search_examples/public/sql_search/app.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 React, { useState } from 'react';
+
+import {
+ EuiCodeBlock,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiForm,
+ EuiPageBody,
+ EuiPageContent,
+ EuiPageContentBody,
+ EuiPageHeader,
+ EuiPanel,
+ EuiSuperUpdateButton,
+ EuiText,
+ EuiTextArea,
+ EuiTitle,
+} from '@elastic/eui';
+
+import { CoreStart } from '../../../../src/core/public';
+
+import {
+ DataPublicPluginStart,
+ IKibanaSearchResponse,
+ isCompleteResponse,
+ isErrorResponse,
+} from '../../../../src/plugins/data/public';
+import {
+ SQL_SEARCH_STRATEGY,
+ SqlSearchStrategyRequest,
+ SqlSearchStrategyResponse,
+} from '../../../../src/plugins/data/common';
+
+interface SearchExamplesAppDeps {
+ notifications: CoreStart['notifications'];
+ data: DataPublicPluginStart;
+}
+
+export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => {
+ const [sqlQuery, setSqlQuery] = useState('');
+ const [request, setRequest] = useState>({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [rawResponse, setRawResponse] = useState>({});
+
+ function setResponse(response: IKibanaSearchResponse) {
+ setRawResponse(response.rawResponse);
+ }
+
+ const doSearch = async () => {
+ const req: SqlSearchStrategyRequest = {
+ params: {
+ query: sqlQuery,
+ },
+ };
+
+ // Submit the search request using the `data.search` service.
+ setRequest(req.params!);
+ setIsLoading(true);
+
+ data.search
+ .search(req, {
+ strategy: SQL_SEARCH_STRATEGY,
+ })
+ .subscribe({
+ next: (res) => {
+ if (isCompleteResponse(res)) {
+ setIsLoading(false);
+ setResponse(res);
+ } else if (isErrorResponse(res)) {
+ setIsLoading(false);
+ setResponse(res);
+ notifications.toasts.addDanger('An error has occurred');
+ }
+ },
+ error: (e) => {
+ setIsLoading(false);
+ data.search.showError(e);
+ },
+ });
+ };
+
+ return (
+
+
+
+ SQL search example
+
+
+
+
+
+
+
+ setSqlQuery(e.target.value)}
+ fullWidth
+ data-test-subj="sqlQueryInput"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Request
+
+
+ {JSON.stringify(request, null, 2)}
+
+
+
+
+
+
+ Response
+
+
+ {JSON.stringify(rawResponse, null, 2)}
+
+
+
+
+
+
+
+ );
+};
diff --git a/package.json b/package.json
index af0168e125544..ca95f41569310 100644
--- a/package.json
+++ b/package.json
@@ -108,7 +108,7 @@
"@elastic/charts": "45.0.1",
"@elastic/datemath": "link:bazel-bin/packages/elastic-datemath",
"@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.1",
- "@elastic/ems-client": "8.1.0",
+ "@elastic/ems-client": "8.2.0",
"@elastic/eui": "51.1.0",
"@elastic/filesaver": "1.1.2",
"@elastic/node-crypto": "1.2.1",
@@ -849,7 +849,7 @@
"mochawesome-merge": "^4.2.1",
"mock-fs": "^5.1.2",
"mock-http-server": "1.3.0",
- "ms-chromium-edge-driver": "^0.4.3",
+ "ms-chromium-edge-driver": "^0.5.1",
"multimatch": "^4.0.0",
"mutation-observer": "^1.0.3",
"ncp": "^2.0.0",
diff --git a/packages/kbn-es-query/src/index.ts b/packages/kbn-es-query/src/index.ts
index 3363bd826088f..02d54df995176 100644
--- a/packages/kbn-es-query/src/index.ts
+++ b/packages/kbn-es-query/src/index.ts
@@ -104,6 +104,7 @@ export {
nodeBuilder,
nodeTypes,
toElasticsearchQuery,
+ escapeKuery,
} from './kuery';
export {
diff --git a/packages/kbn-es-query/src/kuery/index.ts b/packages/kbn-es-query/src/kuery/index.ts
index 13039956916cb..7e7637e950f91 100644
--- a/packages/kbn-es-query/src/kuery/index.ts
+++ b/packages/kbn-es-query/src/kuery/index.ts
@@ -23,5 +23,6 @@ export const toElasticsearchQuery = (...params: Parameters {
+ test('should escape special characters', () => {
+ const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`;
+ const expected = `This \\\\ has \\(a lot of\\) \\ characters, don't you \\*think\\*? \\"Yes.\\"`;
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should escape keywords', () => {
+ const value = 'foo and bar or baz not qux';
+ const expected = 'foo \\and bar \\or baz \\not qux';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should escape keywords next to each other', () => {
+ const value = 'foo and bar or not baz';
+ const expected = 'foo \\and bar \\or \\not baz';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should not escape keywords without surrounding spaces', () => {
+ const value = 'And this has keywords, or does it not?';
+ const expected = 'And this has keywords, \\or does it not?';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should escape uppercase keywords', () => {
+ const value = 'foo AND bar';
+ const expected = 'foo \\AND bar';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should escape both keywords and special characters', () => {
+ const value = 'Hello, world, and to meet you!';
+ const expected = 'Hello, world, \\and \\ to meet you!';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+
+ test('should escape newlines and tabs', () => {
+ const value = 'This\nhas\tnewlines\r\nwith\ttabs';
+ const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs';
+
+ expect(escapeKuery(value)).toBe(expected);
+ });
+});
diff --git a/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts b/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts
new file mode 100644
index 0000000000000..6693fbb847fd1
--- /dev/null
+++ b/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { flow } from 'lodash';
+
+/**
+ * Escapes a Kuery node value to ensure that special characters, operators, and whitespace do not result in a parsing error or unintended
+ * behavior when using the value as an argument for the `buildNode` function.
+ */
+export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace);
+
+// See the SpecialCharacter rule in kuery.peg
+function escapeSpecialCharacters(str: string) {
+ return str.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string
+}
+
+// See the Keyword rule in kuery.peg
+function escapeAndOr(str: string) {
+ return str.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3');
+}
+
+function escapeNot(str: string) {
+ return str.replace(/not(\s+)/gi, '\\$&');
+}
+
+// See the Space rule in kuery.peg
+function escapeWhitespace(str: string) {
+ return str.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
+}
diff --git a/packages/kbn-es-query/src/kuery/utils/index.ts b/packages/kbn-es-query/src/kuery/utils/index.ts
new file mode 100644
index 0000000000000..34575ef08573d
--- /dev/null
+++ b/packages/kbn-es-query/src/kuery/utils/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 { escapeKuery } from './escape_kuery';
diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts
index c5e719a904ebd..9216f5b21d7f5 100644
--- a/packages/kbn-shared-ux-components/src/index.ts
+++ b/packages/kbn-shared-ux-components/src/index.ts
@@ -94,3 +94,20 @@ export const LazyIconButtonGroup = React.lazy(() =>
* The IconButtonGroup component that is wrapped by the `withSuspence` HOC.
*/
export const IconButtonGroup = withSuspense(LazyIconButtonGroup);
+
+/**
+ * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or
+ * the withSuspense` HOC to load this component.
+ */
+export const KibanaSolutionAvatarLazy = React.lazy(() =>
+ import('./solution_avatar').then(({ KibanaSolutionAvatar }) => ({
+ default: KibanaSolutionAvatar,
+ }))
+);
+
+/**
+ * A `KibanaSolutionAvatar` component that is wrapped by the `withSuspense` HOC. This component can
+ * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavAvatarLazy` component lazily with
+ * a predefined fallback and error boundary.
+ */
+export const KibanaSolutionAvatar = withSuspense(KibanaSolutionAvatarLazy);
diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts b/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts
index e5f05f2c70741..db7462d7cb1bf 100644
--- a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts
+++ b/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts
@@ -9,7 +9,6 @@
import { RedirectAppLinks } from './redirect_app_links';
export type { RedirectAppLinksProps } from './redirect_app_links';
-
export { RedirectAppLinks } from './redirect_app_links';
/**
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap b/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap
new file mode 100644
index 0000000000000..9817d7cdd8d45
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap
@@ -0,0 +1,10 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KibanaSolutionAvatar renders 1`] = `
+
+`;
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg b/packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg
new file mode 100644
index 0000000000000..fea0d6954343d
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx
new file mode 100644
index 0000000000000..db31c0fd5a3d4
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx
@@ -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 { KibanaSolutionAvatar } from './solution_avatar';
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss
new file mode 100644
index 0000000000000..3064ef0a04a67
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss
@@ -0,0 +1,14 @@
+.kbnSolutionAvatar {
+ @include euiBottomShadowSmall;
+
+ &--xxl {
+ @include euiBottomShadowMedium;
+ @include size(100px);
+ line-height: 100px;
+ border-radius: 100px;
+ display: inline-block;
+ background: $euiColorEmptyShade url('/assets/texture.svg') no-repeat;
+ background-size: cover, 125%;
+ text-align: center;
+ }
+}
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx
new file mode 100644
index 0000000000000..bc26806016df0
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { KibanaSolutionAvatar, KibanaSolutionAvatarProps } from './solution_avatar';
+
+export default {
+ title: 'Solution Avatar',
+ description: 'A wrapper around EuiAvatar, specifically to stylize Elastic Solutions',
+};
+
+type Params = Pick;
+
+export const PureComponent = (params: Params) => {
+ return ;
+};
+
+PureComponent.argTypes = {
+ name: {
+ control: 'text',
+ defaultValue: 'Kibana',
+ },
+ size: {
+ control: 'radio',
+ options: ['s', 'm', 'l', 'xl', 'xxl'],
+ defaultValue: 'xxl',
+ },
+};
diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx
new file mode 100644
index 0000000000000..7a8b20c3f8d64
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { KibanaSolutionAvatar } from './solution_avatar';
+
+describe('KibanaSolutionAvatar', () => {
+ test('renders', () => {
+ const component = shallow( );
+ expect(component).toMatchSnapshot();
+ });
+});
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
new file mode 100644
index 0000000000000..78459b90e4b3b
--- /dev/null
+++ b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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_avatar.scss';
+
+import React from 'react';
+import classNames from 'classnames';
+
+import { DistributiveOmit, EuiAvatar, EuiAvatarProps } from '@elastic/eui';
+
+export type KibanaSolutionAvatarProps = DistributiveOmit & {
+ /**
+ * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version
+ */
+ size?: EuiAvatarProps['size'] | 'xxl';
+};
+
+/**
+ * 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) => {
+ return (
+ // @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine
+
+ );
+};
diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js
index ba515865e5323..fd14c973683f7 100644
--- a/packages/kbn-test/jest-preset.js
+++ b/packages/kbn-test/jest-preset.js
@@ -16,7 +16,7 @@ module.exports = {
coverageDirectory: '/target/kibana-coverage/jest',
// An array of regexp pattern strings used to skip coverage collection
- coveragePathIgnorePatterns: ['/node_modules/', '.*\\.d\\.ts'],
+ coveragePathIgnorePatterns: ['/node_modules/', '.*\\.d\\.ts', 'jest\\.config\\.js'],
// A list of reporter names that Jest uses when writing coverage reports
coverageReporters: !!process.env.CODE_COVERAGE
diff --git a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts
index 844f57c8bf5c5..7f8d10b50edf5 100644
--- a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts
+++ b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts
@@ -75,6 +75,7 @@ const previouslyRegisteredTypes = [
'ml-telemetry',
'monitoring-telemetry',
'osquery-pack',
+ 'osquery-pack-asset',
'osquery-saved-query',
'osquery-usage-metric',
'osquery-manager-usage-metric',
diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts
index 9585c40e6a161..d8c1b8edb9558 100644
--- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts
+++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts
@@ -14,10 +14,7 @@ jest.mock('../../../../elasticsearch', () => {
return { getErrorMessage: mockGetEsErrorMessage };
});
-// Mock these functions to return empty results, as this simplifies test cases and we don't need to exercise alternate code paths for these
-jest.mock('@kbn/es-query', () => {
- return { nodeTypes: { function: { buildNode: jest.fn() } } };
-});
+// Mock this function to return empty results, as this simplifies test cases and we don't need to exercise alternate code paths for these
jest.mock('../search_dsl', () => {
return { getSearchDsl: jest.fn() };
});
diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts
index 7ccacffb9a4d2..e23f8ef1eb9fd 100644
--- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts
+++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts
@@ -32,8 +32,9 @@ describe('deleteLegacyUrlAliases', () => {
};
}
- const type = 'obj-type';
- const id = 'obj-id';
+ // Include KQL special characters in the object type/ID to implicitly assert that the kuery node builder handles it gracefully
+ const type = 'obj-type:"';
+ const id = 'id-1:"';
it('throws an error if namespaces includes the "all namespaces" string', async () => {
const namespaces = [ALL_NAMESPACES_STRING];
diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts
index 4d38afeac6eaa..690465f08bd36 100644
--- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts
+++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts
@@ -62,11 +62,6 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam
return;
}
- const { buildNode } = esKuery.nodeTypes.function;
- const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetType`, type);
- const match2 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetId`, id);
- const kueryNode = buildNode('and', [match1, match2]);
-
try {
await client.updateByQuery(
{
@@ -75,7 +70,7 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam
body: {
...getSearchDsl(mappings, registry, {
type: LEGACY_URL_ALIAS_TYPE,
- kueryNode,
+ kueryNode: createKueryNode(type, id),
}),
script: {
// Intentionally use one script source with variable params to take advantage of ES script caching
@@ -107,3 +102,17 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam
function throwError(type: string, id: string, message: string) {
throw new Error(`Failed to delete legacy URL aliases for ${type}/${id}: ${message}`);
}
+
+function getKueryKey(attribute: string) {
+ // Note: these node keys do NOT include '.attributes' for type-level fields because we are using the query in the ES client (instead of the SO client)
+ return `${LEGACY_URL_ALIAS_TYPE}.${attribute}`;
+}
+
+export function createKueryNode(type: string, id: string) {
+ const { buildNode } = esKuery.nodeTypes.function;
+ // Escape Kuery values to prevent parsing errors and unintended behavior (object types/IDs can contain KQL special characters/operators)
+ const match1 = buildNode('is', getKueryKey('targetType'), esKuery.escapeKuery(type));
+ const match2 = buildNode('is', getKueryKey('targetId'), esKuery.escapeKuery(id));
+ const kueryNode = buildNode('and', [match1, match2]);
+ return kueryNode;
+}
diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts
index 755fa5794b575..f0399f4b54aa0 100644
--- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts
+++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts
@@ -51,7 +51,8 @@ describe('findLegacyUrlAliases', () => {
});
}
- const obj1 = { type: 'obj-type', id: 'id-1' };
+ // Include KQL special characters in the object type/ID to implicitly assert that the kuery node builder handles it gracefully
+ const obj1 = { type: 'obj-type:"', id: 'id-1:"' };
const obj2 = { type: 'obj-type', id: 'id-2' };
const obj3 = { type: 'obj-type', id: 'id-3' };
diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts
index 7c1ce82129710..70b1730ec8f48 100644
--- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts
+++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts
@@ -68,15 +68,20 @@ export async function findLegacyUrlAliases(
function createAliasKueryFilter(objects: Array<{ type: string; id: string }>) {
const { buildNode } = esKuery.nodeTypes.function;
- // Note: these nodes include '.attributes' for type-level fields because these are eventually passed to `validateConvertFilterToKueryNode`, which requires it
const kueryNodes = objects.reduce((acc, { type, id }) => {
- const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.targetType`, type);
- const match2 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.sourceId`, id);
+ // Escape Kuery values to prevent parsing errors and unintended behavior (object types/IDs can contain KQL special characters/operators)
+ const match1 = buildNode('is', getKueryKey('targetType'), esKuery.escapeKuery(type));
+ const match2 = buildNode('is', getKueryKey('sourceId'), esKuery.escapeKuery(id));
acc.push(buildNode('and', [match1, match2]));
return acc;
}, []);
return buildNode('and', [
- buildNode('not', buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.disabled`, true)), // ignore aliases that have been disabled
+ buildNode('not', buildNode('is', getKueryKey('disabled'), true)), // ignore aliases that have been disabled
buildNode('or', kueryNodes),
]);
}
+
+function getKueryKey(attribute: string) {
+ // Note: these node keys include '.attributes' for type-level fields because these are eventually passed to `validateConvertFilterToKueryNode`, which requires it
+ return `${LEGACY_URL_ALIAS_TYPE}.attributes.${attribute}`;
+}
diff --git a/src/core/server/status/cached_plugins_status.ts b/src/core/server/status/cached_plugins_status.ts
new file mode 100644
index 0000000000000..fec9f51e63172
--- /dev/null
+++ b/src/core/server/status/cached_plugins_status.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { Observable } from 'rxjs';
+
+import { type PluginName } from '../plugins';
+import { type ServiceStatus } from './types';
+
+import { type Deps, PluginsStatusService as BasePluginsStatusService } from './plugins_status';
+
+export class PluginsStatusService extends BasePluginsStatusService {
+ private all$?: Observable>;
+ private dependenciesStatuses$: Record>>;
+ private derivedStatuses$: Record>;
+
+ constructor(deps: Deps) {
+ super(deps);
+ this.dependenciesStatuses$ = {};
+ this.derivedStatuses$ = {};
+ }
+
+ public getAll$(): Observable> {
+ if (!this.all$) {
+ this.all$ = super.getAll$();
+ }
+
+ return this.all$;
+ }
+
+ public getDependenciesStatus$(plugin: PluginName): Observable> {
+ if (!this.dependenciesStatuses$[plugin]) {
+ this.dependenciesStatuses$[plugin] = super.getDependenciesStatus$(plugin);
+ }
+
+ return this.dependenciesStatuses$[plugin];
+ }
+
+ public getDerivedStatus$(plugin: PluginName): Observable {
+ if (!this.derivedStatuses$[plugin]) {
+ this.derivedStatuses$[plugin] = super.getDerivedStatus$(plugin);
+ }
+
+ return this.derivedStatuses$[plugin];
+ }
+}
diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts
index 0befbf63bd186..c07624826ff83 100644
--- a/src/core/server/status/plugins_status.test.ts
+++ b/src/core/server/status/plugins_status.test.ts
@@ -10,7 +10,7 @@ import { PluginName } from '../plugins';
import { PluginsStatusService } from './plugins_status';
import { of, Observable, BehaviorSubject, ReplaySubject } from 'rxjs';
import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types';
-import { first } from 'rxjs/operators';
+import { first, skip } from 'rxjs/operators';
import { ServiceStatusLevelSnapshotSerializer } from './test_utils';
expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer);
@@ -215,7 +215,7 @@ describe('PluginStatusService', () => {
service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a status' }));
expect(await service.getAll$().pipe(first()).toPromise()).toEqual({
- a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded
+ a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available despite savedObjects being degraded
b: {
level: ServiceStatusLevels.degraded,
summary: '1 service is degraded: savedObjects',
@@ -239,6 +239,10 @@ describe('PluginStatusService', () => {
const statusUpdates: Array> = [];
const subscription = service
.getAll$()
+ // If we subscribe to the $getAll() Observable BEFORE setting a custom status Observable
+ // for a given plugin ('a' in this test), then the first emission will happen
+ // right after core$ services Observable emits
+ .pipe(skip(1))
.subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses));
service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a degraded' }));
@@ -261,6 +265,8 @@ describe('PluginStatusService', () => {
const statusUpdates: Array> = [];
const subscription = service
.getAll$()
+ // the first emission happens right after core services emit (see explanation above)
+ .pipe(skip(1))
.subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses));
const aStatus$ = new BehaviorSubject({
@@ -280,19 +286,21 @@ describe('PluginStatusService', () => {
});
it('emits an unavailable status if first emission times out, then continues future emissions', async () => {
- jest.useFakeTimers();
- const service = new PluginsStatusService({
- core$: coreAllAvailable$,
- pluginDependencies: new Map([
- ['a', []],
- ['b', ['a']],
- ]),
- });
+ const service = new PluginsStatusService(
+ {
+ core$: coreAllAvailable$,
+ pluginDependencies: new Map([
+ ['a', []],
+ ['b', ['a']],
+ ]),
+ },
+ 10 // set a small timeout so that the registered status Observable for 'a' times out quickly
+ );
const pluginA$ = new ReplaySubject(1);
service.set('a', pluginA$);
- const firstEmission = service.getAll$().pipe(first()).toPromise();
- jest.runAllTimers();
+ // the first emission happens right after core$ services emit
+ const firstEmission = service.getAll$().pipe(skip(1), first()).toPromise();
expect(await firstEmission).toEqual({
a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' },
@@ -308,16 +316,16 @@ describe('PluginStatusService', () => {
pluginA$.next({ level: ServiceStatusLevels.available, summary: 'a available' });
const secondEmission = service.getAll$().pipe(first()).toPromise();
- jest.runAllTimers();
expect(await secondEmission).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'a available' },
b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
});
- jest.useRealTimers();
});
});
describe('getDependenciesStatus$', () => {
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
it('only includes dependencies of specified plugin', async () => {
const service = new PluginsStatusService({
core$: coreAllAvailable$,
@@ -357,7 +365,7 @@ describe('PluginStatusService', () => {
it('debounces plugins custom status registration', async () => {
const service = new PluginsStatusService({
- core$: coreAllAvailable$,
+ core$: coreOneCriticalOneDegraded$,
pluginDependencies,
});
const available: ServiceStatus = {
@@ -375,8 +383,6 @@ describe('PluginStatusService', () => {
expect(statusUpdates).toStrictEqual([]);
- const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
// Waiting for the debounce timeout should cut a new update
await delay(25);
subscription.unsubscribe();
@@ -404,7 +410,6 @@ describe('PluginStatusService', () => {
const subscription = service
.getDependenciesStatus$('b')
.subscribe((status) => statusUpdates.push(status));
- const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
pluginA$.next(degraded);
pluginA$.next(available);
diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts
index c4e8e7e364248..8d042d4cba3f9 100644
--- a/src/core/server/status/plugins_status.ts
+++ b/src/core/server/status/plugins_status.ts
@@ -5,166 +5,338 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
-
-import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs';
+import { BehaviorSubject, Observable, ReplaySubject, Subscription } from 'rxjs';
import {
map,
distinctUntilChanged,
- switchMap,
+ filter,
debounceTime,
timeoutWith,
startWith,
} from 'rxjs/operators';
+import { sortBy } from 'lodash';
import { isDeepStrictEqual } from 'util';
-import { PluginName } from '../plugins';
-import { ServiceStatus, CoreStatus, ServiceStatusLevels } from './types';
+import { type PluginName } from '../plugins';
+import { type ServiceStatus, type CoreStatus, ServiceStatusLevels } from './types';
import { getSummaryStatus } from './get_summary_status';
const STATUS_TIMEOUT_MS = 30 * 1000; // 30 seconds
-interface Deps {
+const defaultStatus: ServiceStatus = {
+ level: ServiceStatusLevels.unavailable,
+ summary: `Status check timed out after ${STATUS_TIMEOUT_MS / 1000}s`,
+};
+
+export interface Deps {
core$: Observable;
pluginDependencies: ReadonlyMap;
}
+interface PluginData {
+ [name: PluginName]: {
+ name: PluginName;
+ depth: number; // depth of this plugin in the dependency tree (root plugins will have depth = 1)
+ dependencies: PluginName[];
+ reverseDependencies: PluginName[];
+ reportedStatus?: ServiceStatus;
+ derivedStatus: ServiceStatus;
+ };
+}
+interface PluginStatus {
+ [name: PluginName]: ServiceStatus;
+}
+
+interface ReportedStatusSubscriptions {
+ [name: PluginName]: Subscription;
+}
+
export class PluginsStatusService {
- private readonly pluginStatuses = new Map>();
- private readonly derivedStatuses = new Map>();
- private readonly dependenciesStatuses = new Map<
- PluginName,
- Observable>
- >();
- private allPluginsStatuses?: Observable>;
-
- private readonly update$ = new BehaviorSubject(true);
- private readonly defaultInheritedStatus$: Observable;
+ private coreStatus: CoreStatus = { elasticsearch: defaultStatus, savedObjects: defaultStatus };
+ private pluginData: PluginData;
+ private rootPlugins: PluginName[]; // root plugins are those that do not have any dependencies
+ private orderedPluginNames: PluginName[];
+ private pluginData$ = new ReplaySubject(1);
+ private pluginStatus: PluginStatus = {};
+ private pluginStatus$ = new BehaviorSubject(this.pluginStatus);
+ private reportedStatusSubscriptions: ReportedStatusSubscriptions = {};
+ private isReportingStatus: Record = {};
private newRegistrationsAllowed = true;
+ private coreSubscription: Subscription;
- constructor(private readonly deps: Deps) {
- this.defaultInheritedStatus$ = this.deps.core$.pipe(
- map((coreStatus) => {
- return getSummaryStatus(Object.entries(coreStatus), {
- allAvailableSummary: `All dependencies are available`,
- });
- })
- );
+ constructor(deps: Deps, private readonly statusTimeoutMs: number = STATUS_TIMEOUT_MS) {
+ this.pluginData = this.initPluginData(deps.pluginDependencies);
+ this.rootPlugins = this.getRootPlugins();
+ this.orderedPluginNames = this.getOrderedPluginNames();
+
+ this.coreSubscription = deps.core$
+ .pipe(debounceTime(10))
+ .subscribe((coreStatus: CoreStatus) => this.updateCoreAndPluginStatuses(coreStatus));
}
+ /**
+ * Register a status Observable for a specific plugin
+ * @param {PluginName} plugin The name of the plugin
+ * @param {Observable} status$ An external Observable that must be trusted as the source of truth for the status of the plugin
+ * @throws An error if the status registrations are not allowed
+ */
public set(plugin: PluginName, status$: Observable) {
if (!this.newRegistrationsAllowed) {
throw new Error(
`Custom statuses cannot be registered after setup, plugin [${plugin}] attempted`
);
}
- this.pluginStatuses.set(plugin, status$);
- this.update$.next(true); // trigger all existing Observables to update from the new source Observable
+
+ this.isReportingStatus[plugin] = true;
+ // unsubscribe from any previous subscriptions. Ideally plugins should register a status Observable only once
+ this.reportedStatusSubscriptions[plugin]?.unsubscribe();
+
+ // delete any derived statuses calculated before the custom status Observable was registered
+ delete this.pluginStatus[plugin];
+
+ this.reportedStatusSubscriptions[plugin] = status$
+ // Set a timeout for externally-defined status Observables
+ .pipe(timeoutWith(this.statusTimeoutMs, status$.pipe(startWith(defaultStatus))))
+ .subscribe((status) => this.updatePluginReportedStatus(plugin, status));
}
+ /**
+ * Prevent plugins from registering status Observables
+ */
public blockNewRegistrations() {
this.newRegistrationsAllowed = false;
}
+ /**
+ * Obtain an Observable of the status of all the plugins
+ * @returns {Observable>} An Observable that will yield the current status of all plugins
+ */
public getAll$(): Observable> {
- if (!this.allPluginsStatuses) {
- this.allPluginsStatuses = this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]);
- }
- return this.allPluginsStatuses;
+ return this.pluginStatus$.asObservable().pipe(
+ // do not emit until we have a status for all plugins
+ filter((all) => Object.keys(all).length === this.orderedPluginNames.length),
+ distinctUntilChanged>(isDeepStrictEqual)
+ );
}
+ /**
+ * Obtain an Observable of the status of the dependencies of the given plugin
+ * @param {PluginName} plugin the name of the plugin whose dependencies' status must be retreived
+ * @returns {Observable>} An Observable that will yield the current status of the plugin's dependencies
+ */
public getDependenciesStatus$(plugin: PluginName): Observable> {
- const dependencies = this.deps.pluginDependencies.get(plugin);
- if (!dependencies) {
- throw new Error(`Unknown plugin: ${plugin}`);
- }
- if (!this.dependenciesStatuses.has(plugin)) {
- this.dependenciesStatuses.set(
- plugin,
- this.getPluginStatuses$(dependencies).pipe(
- // Prevent many emissions at once from dependency status resolution from making this too noisy
- debounceTime(25)
- )
- );
- }
- return this.dependenciesStatuses.get(plugin)!;
+ const directDependencies = this.pluginData[plugin].dependencies;
+
+ return this.getAll$().pipe(
+ map((allStatus) => {
+ const dependenciesStatus: Record = {};
+ directDependencies.forEach((dep) => (dependenciesStatus[dep] = allStatus[dep]));
+ return dependenciesStatus;
+ }),
+ debounceTime(10)
+ );
}
+ /**
+ * Obtain an Observable of the derived status of the given plugin
+ * @param {PluginName} plugin the name of the plugin whose derived status must be retrieved
+ * @returns {Observable} An Observable that will yield the derived status of the plugin
+ */
public getDerivedStatus$(plugin: PluginName): Observable {
- if (!this.derivedStatuses.has(plugin)) {
- this.derivedStatuses.set(
- plugin,
- this.update$.pipe(
- debounceTime(25), // Avoid calling the plugin's custom status logic for every plugin that depends on it.
- switchMap(() => {
- // Only go up the dependency tree if any of this plugin's dependencies have a custom status
- // Helps eliminate memory overhead of creating thousands of Observables unnecessarily.
- if (this.anyCustomStatuses(plugin)) {
- return combineLatest([this.deps.core$, this.getDependenciesStatus$(plugin)]).pipe(
- map(([coreStatus, pluginStatuses]) => {
- return getSummaryStatus(
- [...Object.entries(coreStatus), ...Object.entries(pluginStatuses)],
- {
- allAvailableSummary: `All dependencies are available`,
- }
- );
- })
- );
- } else {
- return this.defaultInheritedStatus$;
- }
- })
- )
- );
- }
- return this.derivedStatuses.get(plugin)!;
+ return this.pluginData$.asObservable().pipe(
+ map((pluginData) => pluginData[plugin]?.derivedStatus),
+ filter((status: ServiceStatus | undefined): status is ServiceStatus => !!status),
+ distinctUntilChanged(isDeepStrictEqual)
+ );
}
- private getPluginStatuses$(plugins: PluginName[]): Observable> {
- if (plugins.length === 0) {
- return of({});
+ /**
+ * Hook to be called at the stop lifecycle event
+ */
+ public stop() {
+ // Cancel all active subscriptions
+ this.coreSubscription.unsubscribe();
+ Object.values(this.reportedStatusSubscriptions).forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+
+ /**
+ * Initialize a convenience data structure
+ * that maintain up-to-date information about the plugins and their statuses
+ * @param {ReadonlyMap} pluginDependencies Information about the different plugins and their dependencies
+ * @returns {PluginData}
+ */
+ private initPluginData(pluginDependencies: ReadonlyMap): PluginData {
+ const pluginData: PluginData = {};
+
+ if (pluginDependencies) {
+ pluginDependencies.forEach((dependencies, name) => {
+ pluginData[name] = {
+ name,
+ depth: 0,
+ dependencies,
+ reverseDependencies: [],
+ derivedStatus: defaultStatus,
+ };
+ });
+
+ pluginDependencies.forEach((dependencies, name) => {
+ dependencies.forEach((dependency) => {
+ pluginData[dependency].reverseDependencies.push(name);
+ });
+ });
}
- return this.update$.pipe(
- switchMap(() => {
- const pluginStatuses = plugins
- .map((depName) => {
- const pluginStatus = this.pluginStatuses.get(depName)
- ? this.pluginStatuses.get(depName)!.pipe(
- timeoutWith(
- STATUS_TIMEOUT_MS,
- this.pluginStatuses.get(depName)!.pipe(
- startWith({
- level: ServiceStatusLevels.unavailable,
- summary: `Status check timed out after ${STATUS_TIMEOUT_MS / 1000}s`,
- })
- )
- )
- )
- : this.getDerivedStatus$(depName);
- return [depName, pluginStatus] as [PluginName, Observable];
- })
- .map(([pName, status$]) =>
- status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus]))
- );
-
- return combineLatest(pluginStatuses).pipe(
- map((statuses) => Object.fromEntries(statuses)),
- distinctUntilChanged>(isDeepStrictEqual)
- );
- })
+ return pluginData;
+ }
+
+ /**
+ * Create a list with all the root plugins.
+ * Root plugins are all those plugins that do not have any dependency.
+ * @returns {PluginName[]} a list with all the root plugins present in the provided deps
+ */
+ private getRootPlugins(): PluginName[] {
+ return Object.keys(this.pluginData).filter(
+ (plugin) => this.pluginData[plugin].dependencies.length === 0
);
}
/**
- * Determines whether or not this plugin or any plugin in it's dependency tree have a custom status registered.
+ * Obtain a list of plugins names, ordered by depth.
+ * @see {calculateDepthRecursive}
+ * @returns {PluginName[]} a list of plugins, ordered by depth + name
+ */
+ private getOrderedPluginNames(): PluginName[] {
+ this.rootPlugins.forEach((plugin) => {
+ this.calculateDepthRecursive(plugin, 1);
+ });
+
+ return sortBy(Object.values(this.pluginData), ['depth', 'name']).map(({ name }) => name);
+ }
+
+ /**
+ * Calculate the depth of the given plugin, knowing that it's has at least the specified depth
+ * The depth of a plugin is determined by how many levels of dependencies the plugin has above it.
+ * We define root plugins as depth = 1, plugins that only depend on root plugins will have depth = 2
+ * and so on so forth
+ * @param {PluginName} plugin the name of the plugin whose depth must be calculated
+ * @param {number} depth the minimum depth that we know for sure this plugin has
+ */
+ private calculateDepthRecursive(plugin: PluginName, depth: number): void {
+ const pluginData = this.pluginData[plugin];
+ pluginData.depth = Math.max(pluginData.depth, depth);
+ const newDepth = depth + 1;
+ pluginData.reverseDependencies.forEach((revDep) =>
+ this.calculateDepthRecursive(revDep, newDepth)
+ );
+ }
+
+ /**
+ * Updates the core services statuses and plugins' statuses
+ * according to the latest status reported by core services.
+ * @param {CoreStatus} coreStatus the latest status of core services
+ */
+ private updateCoreAndPluginStatuses(coreStatus: CoreStatus): void {
+ this.coreStatus = coreStatus!;
+ const derivedStatus = getSummaryStatus(Object.entries(this.coreStatus), {
+ allAvailableSummary: `All dependencies are available`,
+ });
+
+ this.rootPlugins.forEach((plugin) => {
+ this.pluginData[plugin].derivedStatus = derivedStatus;
+ if (!this.isReportingStatus[plugin]) {
+ // this root plugin has NOT registered any status Observable. Thus, its status is derived from core
+ this.pluginStatus[plugin] = derivedStatus;
+ }
+ });
+
+ this.updatePluginsStatuses(this.rootPlugins);
+ }
+
+ /**
+ * Determine the derived statuses of the specified plugins and their dependencies,
+ * updating them on the pluginData structure
+ * Optionally, if the plugins have not registered a custom status Observable, update their "current" status as well.
+ * @param {PluginName[]} plugins The names of the plugins to be updated
+ */
+ private updatePluginsStatuses(plugins: PluginName[]): void {
+ const toCheck = new Set(plugins);
+
+ // Note that we are updating the plugins in an ordered fashion.
+ // This way, when updating plugin X (at depth = N),
+ // all of its dependencies (at depth < N) have already been updated
+ for (let i = 0; i < this.orderedPluginNames.length; ++i) {
+ const current = this.orderedPluginNames[i];
+ if (toCheck.has(current)) {
+ // update the current plugin status
+ this.updatePluginStatus(current);
+ // flag all its reverse dependencies to be checked
+ // TODO flag them only IF the status of this plugin has changed, seems to break some tests
+ this.pluginData[current].reverseDependencies.forEach((revDep) => toCheck.add(revDep));
+ }
+ }
+
+ this.pluginData$.next(this.pluginData);
+ this.pluginStatus$.next({ ...this.pluginStatus });
+ }
+
+ /**
+ * Determine the derived status of the specified plugin and update it on the pluginData structure
+ * Optionally, if the plugin has not registered a custom status Observable, update its "current" status as well
+ * @param {PluginName} plugin The name of the plugin to be updated
*/
- private anyCustomStatuses(plugin: PluginName): boolean {
- if (this.pluginStatuses.get(plugin)) {
- return true;
+ private updatePluginStatus(plugin: PluginName): void {
+ const newStatus = this.determinePluginStatus(plugin);
+ this.pluginData[plugin].derivedStatus = newStatus;
+
+ if (!this.isReportingStatus[plugin]) {
+ // this plugin has NOT registered any status Observable.
+ // Thus, its status is derived from its dependencies + core
+ this.pluginStatus[plugin] = newStatus;
}
+ }
+
+ /**
+ * Deterime the current plugin status, taking into account its reported status, its derived status
+ * and the status of the core services
+ * @param {PluginName} plugin the name of the plugin whose status must be determined
+ * @returns {ServiceStatus} The status of the plugin
+ */
+ private determinePluginStatus(plugin: PluginName): ServiceStatus {
+ const coreStatus: Array<[PluginName, ServiceStatus]> = Object.entries(this.coreStatus);
+ const newLocal = this.pluginData[plugin];
+
+ let depsStatus: Array<[PluginName, ServiceStatus]> = [];
- return this.deps.pluginDependencies
- .get(plugin)!
- .reduce((acc, depName) => acc || this.anyCustomStatuses(depName), false as boolean);
+ if (Object.keys(this.isReportingStatus).length) {
+ // if at least one plugin has registered a status Observable... take into account plugin dependencies
+ depsStatus = newLocal.dependencies.map((dependency) => [
+ dependency,
+ this.pluginData[dependency].reportedStatus || this.pluginData[dependency].derivedStatus,
+ ]);
+ }
+
+ const newStatus = getSummaryStatus([...coreStatus, ...depsStatus], {
+ allAvailableSummary: `All dependencies are available`,
+ });
+
+ return newStatus;
+ }
+
+ /**
+ * Updates the reported status for the given plugin, along with the status of its dependencies tree.
+ * @param {PluginName} plugin The name of the plugin whose reported status must be updated
+ * @param {ServiceStatus} reportedStatus The newly reported status for that plugin
+ */
+ private updatePluginReportedStatus(plugin: PluginName, reportedStatus: ServiceStatus): void {
+ const previousReportedLevel = this.pluginData[plugin].reportedStatus?.level;
+
+ this.pluginData[plugin].reportedStatus = reportedStatus;
+ this.pluginStatus[plugin] = reportedStatus;
+
+ if (reportedStatus.level !== previousReportedLevel) {
+ this.updatePluginsStatuses([plugin]);
+ }
}
}
diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts
index dfd0ff9a7e103..262667fddf26a 100644
--- a/src/core/server/status/status_service.test.ts
+++ b/src/core/server/status/status_service.test.ts
@@ -239,20 +239,20 @@ describe('StatusService', () => {
// Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing.
elasticsearch$.next(available);
- await delay(500);
+ await delay(100);
elasticsearch$.next(available);
- await delay(500);
+ await delay(100);
elasticsearch$.next({
level: ServiceStatusLevels.available,
summary: `Wow another summary`,
});
- await delay(500);
+ await delay(100);
savedObjects$.next(degraded);
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
@@ -300,9 +300,9 @@ describe('StatusService', () => {
savedObjects$.next(available);
savedObjects$.next(degraded);
// Waiting for the debounce timeout should cut a new update
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
@@ -410,20 +410,20 @@ describe('StatusService', () => {
// Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing.
elasticsearch$.next(available);
- await delay(500);
+ await delay(100);
elasticsearch$.next(available);
- await delay(500);
+ await delay(100);
elasticsearch$.next({
level: ServiceStatusLevels.available,
summary: `Wow another summary`,
});
- await delay(500);
+ await delay(100);
savedObjects$.next(degraded);
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
@@ -471,9 +471,9 @@ describe('StatusService', () => {
savedObjects$.next(available);
savedObjects$.next(degraded);
// Waiting for the debounce timeout should cut a new update
- await delay(500);
+ await delay(100);
savedObjects$.next(available);
- await delay(500);
+ await delay(100);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts
index 63a1b02d5b2e7..a5b5f0a37397a 100644
--- a/src/core/server/status/status_service.ts
+++ b/src/core/server/status/status_service.ts
@@ -25,7 +25,7 @@ import type { InternalCoreUsageDataSetup } from '../core_usage_data';
import { config, StatusConfigType } from './status_config';
import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types';
import { getSummaryStatus } from './get_summary_status';
-import { PluginsStatusService } from './plugins_status';
+import { PluginsStatusService } from './cached_plugins_status';
import { getOverallStatusChanges } from './log_overall_status';
interface StatusLogMeta extends LogMeta {
@@ -71,7 +71,7 @@ export class StatusService implements CoreService {
this.overall$ = combineLatest([core$, this.pluginsStatus.getAll$()]).pipe(
// Prevent many emissions at once from dependency status resolution from making this too noisy
- debounceTime(500),
+ debounceTime(80),
map(([coreStatus, pluginsStatus]) => {
const summary = getSummaryStatus([
...Object.entries(coreStatus),
@@ -174,6 +174,8 @@ export class StatusService implements CoreService {
this.subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
+
+ this.pluginsStatus?.stop();
this.subscriptions = [];
}
diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts
index d1ae8f595c8c2..017d824909953 100644
--- a/src/dev/license_checker/config.ts
+++ b/src/dev/license_checker/config.ts
@@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0'];
export const LICENSE_OVERRIDES = {
'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts
'@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint
- '@elastic/ems-client@8.1.0': ['Elastic License 2.0'],
+ '@elastic/ems-client@8.2.0': ['Elastic License 2.0'],
'@elastic/eui@51.1.0': ['SSPL-1.0 OR Elastic License 2.0'],
'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry
};
diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts
index abc957b369d2d..133c8114bdb50 100644
--- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts
+++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts
@@ -182,12 +182,16 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({
}
if (handlers?.inspectorAdapters?.tables) {
- const logTable = prepareLogTable(data, [
- [metric ? [metric] : undefined, strings.getMetricHelp()],
- [min ? [min] : undefined, strings.getMinHelp()],
- [max ? [max] : undefined, strings.getMaxHelp()],
- [goal ? [goal] : undefined, strings.getGoalHelp()],
- ]);
+ const logTable = prepareLogTable(
+ data,
+ [
+ [metric ? [metric] : undefined, strings.getMetricHelp()],
+ [min ? [min] : undefined, strings.getMinHelp()],
+ [max ? [max] : undefined, strings.getMaxHelp()],
+ [goal ? [goal] : undefined, strings.getGoalHelp()],
+ ],
+ true
+ );
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap
index 7e2a8084d5166..761b2c3adb156 100644
--- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap
+++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap
@@ -6,7 +6,7 @@ Object {
Object {
"id": "col-0-1",
"meta": Object {
- "dimensionName": undefined,
+ "dimensionName": "Metric",
"type": "number",
},
"name": "Count",
@@ -14,7 +14,7 @@ Object {
Object {
"id": "col-1-2",
"meta": Object {
- "dimensionName": undefined,
+ "dimensionName": "X axis",
"type": "string",
},
"name": "Dest",
diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts
index 44520a30a9b82..a1a04af76fd8b 100644
--- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts
+++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts
@@ -28,7 +28,7 @@ const convertToVisDimension = (
const column = columns.find((c) => c.id === accessor);
if (!column) return;
return {
- accessor: Number(column.id),
+ accessor: column,
format: {
id: column.meta.params?.id,
params: { ...column.meta.params?.params },
@@ -212,7 +212,7 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({
})
);
}
- const logTable = prepareLogTable(data, argsTable);
+ const logTable = prepareLogTable(data, argsTable, true);
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
return {
diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts
index 34e93c4d31ddd..bea25fbf708d7 100644
--- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts
+++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts
@@ -162,7 +162,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
}),
]);
}
- const logTable = prepareLogTable(input, argsTable);
+ const logTable = prepareLogTable(input, argsTable, true);
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap
index e07e367d10787..81ada60a772cd 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap
@@ -27,14 +27,6 @@ Object {
},
"name": "Field 3",
},
- Object {
- "id": "col-0-4",
- "meta": Object {
- "dimensionName": undefined,
- "type": "number",
- },
- "name": "Field 4",
- },
],
"rows": Array [
Object {
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap
index ff2a4ece368f8..e1d9f98f57209 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap
@@ -27,14 +27,6 @@ Object {
},
"name": "Field 3",
},
- Object {
- "id": "col-0-4",
- "meta": Object {
- "dimensionName": undefined,
- "type": "number",
- },
- "name": "Field 4",
- },
],
"rows": Array [
Object {
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap
index b0905139d3f1b..33525b33f6f96 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap
@@ -19,22 +19,6 @@ Object {
},
"name": "Field 2",
},
- Object {
- "id": "col-0-3",
- "meta": Object {
- "dimensionName": undefined,
- "type": "number",
- },
- "name": "Field 3",
- },
- Object {
- "id": "col-0-4",
- "meta": Object {
- "dimensionName": undefined,
- "type": "number",
- },
- "name": "Field 4",
- },
],
"rows": Array [
Object {
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts
index e5d1f424dd5f3..d3179026f3c9e 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts
@@ -134,12 +134,16 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({
};
if (handlers?.inspectorAdapters?.tables) {
- const logTable = prepareLogTable(context, [
- [[args.metric], strings.getSliceSizeHelp()],
- [args.buckets, strings.getSliceHelp()],
- [args.splitColumn, strings.getColumnSplitHelp()],
- [args.splitRow, strings.getRowSplitHelp()],
- ]);
+ const logTable = prepareLogTable(
+ context,
+ [
+ [[args.metric], strings.getSliceSizeHelp()],
+ [args.buckets, strings.getSliceHelp()],
+ [args.splitColumn, strings.getColumnSplitHelp()],
+ [args.splitRow, strings.getRowSplitHelp()],
+ ],
+ true
+ );
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts
index cb9dd7fd04aed..5edab8f7c5226 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts
@@ -154,12 +154,16 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({
};
if (handlers?.inspectorAdapters?.tables) {
- const logTable = prepareLogTable(context, [
- [[args.metric], strings.getSliceSizeHelp()],
- [args.buckets, strings.getSliceHelp()],
- [args.splitColumn, strings.getColumnSplitHelp()],
- [args.splitRow, strings.getRowSplitHelp()],
- ]);
+ const logTable = prepareLogTable(
+ context,
+ [
+ [[args.metric], strings.getSliceSizeHelp()],
+ [args.buckets, strings.getSliceHelp()],
+ [args.splitColumn, strings.getColumnSplitHelp()],
+ [args.splitRow, strings.getRowSplitHelp()],
+ ],
+ true
+ );
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts
index 102baec7cf2a6..cda9e59da0610 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts
@@ -134,12 +134,16 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition =>
};
if (handlers?.inspectorAdapters?.tables) {
- const logTable = prepareLogTable(context, [
- [[args.metric], strings.getSliceSizeHelp()],
- [args.buckets, strings.getSliceHelp()],
- [args.splitColumn, strings.getColumnSplitHelp()],
- [args.splitRow, strings.getRowSplitHelp()],
- ]);
+ const logTable = prepareLogTable(
+ context,
+ [
+ [[args.metric], strings.getSliceSizeHelp()],
+ [args.buckets, strings.getSliceHelp()],
+ [args.splitColumn, strings.getColumnSplitHelp()],
+ [args.splitRow, strings.getRowSplitHelp()],
+ ],
+ true
+ );
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts
index 073b78431fac9..3ff35d1277dba 100644
--- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts
+++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts
@@ -129,12 +129,16 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({
};
if (handlers?.inspectorAdapters?.tables) {
- const logTable = prepareLogTable(context, [
- [[args.metric], strings.getSliceSizeHelp()],
- [buckets, strings.getSliceHelp()],
- [args.splitColumn, strings.getColumnSplitHelp()],
- [args.splitRow, strings.getRowSplitHelp()],
- ]);
+ const logTable = prepareLogTable(
+ context,
+ [
+ [[args.metric], strings.getSliceSizeHelp()],
+ [buckets, strings.getSliceHelp()],
+ [args.splitColumn, strings.getColumnSplitHelp()],
+ [args.splitRow, strings.getRowSplitHelp()],
+ ],
+ true
+ );
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts
index 1a07d607ede3e..e4ccecd6a0069 100644
--- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts
+++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts
@@ -164,7 +164,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => {
if (args.bucket) {
argsTable.push([[args.bucket], dimension.tags]);
}
- const logTable = prepareLogTable(input, argsTable);
+ const logTable = prepareLogTable(input, argsTable, true);
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
return {
diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts
index badbb94e9752f..d0d103abe1ea2 100644
--- a/src/plugins/data/common/search/index.ts
+++ b/src/plugins/data/common/search/index.ts
@@ -17,3 +17,4 @@ export * from './poll_search';
export * from './strategies/es_search';
export * from './strategies/eql_search';
export * from './strategies/ese_search';
+export * from './strategies/sql_search';
diff --git a/src/plugins/data/common/search/strategies/sql_search/index.ts b/src/plugins/data/common/search/strategies/sql_search/index.ts
new file mode 100644
index 0000000000000..12594660136d8
--- /dev/null
+++ b/src/plugins/data/common/search/strategies/sql_search/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 * from './types';
diff --git a/src/plugins/data/common/search/strategies/sql_search/types.ts b/src/plugins/data/common/search/strategies/sql_search/types.ts
new file mode 100644
index 0000000000000..e51d0bf4a6b6c
--- /dev/null
+++ b/src/plugins/data/common/search/strategies/sql_search/types.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 type {
+ SqlGetAsyncRequest,
+ SqlQueryRequest,
+ SqlQueryResponse,
+} from '@elastic/elasticsearch/lib/api/types';
+import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types';
+
+export const SQL_SEARCH_STRATEGY = 'sql';
+
+export type SqlRequestParams =
+ | Omit
+ | Omit;
+export type SqlSearchStrategyRequest = IKibanaSearchRequest;
+
+export type SqlSearchStrategyResponse = IKibanaSearchResponse;
diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts
index 933449e779ef7..162c461f7a175 100644
--- a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts
+++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { escapeQuotes, escapeKuery } from './escape_kuery';
+import { escapeQuotes } from './escape_kuery';
describe('Kuery escape', () => {
test('should escape quotes', () => {
@@ -22,53 +22,4 @@ describe('Kuery escape', () => {
expect(escapeQuotes(value)).toBe(expected);
});
-
- test('should escape special characters', () => {
- const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`;
- const expected = `This \\\\ has \\(a lot of\\) \\ characters, don't you \\*think\\*? \\"Yes.\\"`;
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should escape keywords', () => {
- const value = 'foo and bar or baz not qux';
- const expected = 'foo \\and bar \\or baz \\not qux';
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should escape keywords next to each other', () => {
- const value = 'foo and bar or not baz';
- const expected = 'foo \\and bar \\or \\not baz';
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should not escape keywords without surrounding spaces', () => {
- const value = 'And this has keywords, or does it not?';
- const expected = 'And this has keywords, \\or does it not?';
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should escape uppercase keywords', () => {
- const value = 'foo AND bar';
- const expected = 'foo \\AND bar';
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should escape both keywords and special characters', () => {
- const value = 'Hello, world, and to meet you!';
- const expected = 'Hello, world, \\and \\ to meet you!';
-
- expect(escapeKuery(value)).toBe(expected);
- });
-
- test('should escape newlines and tabs', () => {
- const value = 'This\nhas\tnewlines\r\nwith\ttabs';
- const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs';
-
- expect(escapeKuery(value)).toBe(expected);
- });
});
diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts
index 54f03803a893e..6636f9b602687 100644
--- a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts
+++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { flow } from 'lodash';
+import { escapeKuery } from '@kbn/es-query';
/**
* Escapes backslashes and double-quotes. (Useful when putting a string in quotes to use as a value
@@ -16,23 +16,5 @@ export function escapeQuotes(str: string) {
return str.replace(/[\\"]/g, '\\$&');
}
-export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace);
-
-// See the SpecialCharacter rule in kuery.peg
-function escapeSpecialCharacters(str: string) {
- return str.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string
-}
-
-// See the Keyword rule in kuery.peg
-function escapeAndOr(str: string) {
- return str.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3');
-}
-
-function escapeNot(str: string) {
- return str.replace(/not(\s+)/gi, '\\$&');
-}
-
-// See the Space rule in kuery.peg
-function escapeWhitespace(str: string) {
- return str.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
-}
+// Re-export this function from the @kbn/es-query package to avoid refactoring
+export { escapeKuery };
diff --git a/src/plugins/data/server/search/README.md b/src/plugins/data/server/search/README.md
index b564c34a7f8b3..d663cdc38da1b 100644
--- a/src/plugins/data/server/search/README.md
+++ b/src/plugins/data/server/search/README.md
@@ -10,3 +10,4 @@ The `search` plugin includes:
- ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL
- (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions
- EQL_SEARCH_STRATEGY
+- SQL_SEARCH_STRATEGY
diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts
index 8fb92136bc259..7c01fefc92d65 100644
--- a/src/plugins/data/server/search/search_service.ts
+++ b/src/plugins/data/server/search/search_service.ts
@@ -77,6 +77,7 @@ import {
eqlRawResponse,
ENHANCED_ES_SEARCH_STRATEGY,
EQL_SEARCH_STRATEGY,
+ SQL_SEARCH_STRATEGY,
} from '../../common/search';
import { getEsaggs, getEsdsl, getEql } from './expressions';
import {
@@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search';
import { eqlSearchStrategyProvider } from './strategies/eql_search';
import { NoSearchIdInSessionError } from './errors/no_search_id_in_session';
import { CachedUiSettingsClient } from './services';
+import { sqlSearchStrategyProvider } from './strategies/sql_search';
type StrategyMap = Record>;
@@ -176,6 +178,7 @@ export class SearchService implements Plugin {
);
this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger));
+ this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger));
registerBsearchRoute(
bfetch,
diff --git a/src/plugins/data/server/search/strategies/sql_search/index.ts b/src/plugins/data/server/search/strategies/sql_search/index.ts
new file mode 100644
index 0000000000000..9af70ddcb618d
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { sqlSearchStrategyProvider } from './sql_search_strategy';
diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts
new file mode 100644
index 0000000000000..9944de7be17be
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts
@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils';
+import moment from 'moment';
+import { SearchSessionsConfigSchema } from '../../../../config';
+
+const getMockSearchSessionsConfig = ({
+ enabled = true,
+ defaultExpiration = moment.duration(7, 'd'),
+} = {}) =>
+ ({
+ enabled,
+ defaultExpiration,
+ } as SearchSessionsConfigSchema);
+
+describe('request utils', () => {
+ describe('getDefaultAsyncSubmitParams', () => {
+ test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ });
+ const params = getDefaultAsyncSubmitParams(mockConfig, {});
+ expect(params).toHaveProperty('keep_alive', '1m');
+ });
+
+ test('Uses `keep_alive` from config if enabled', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ });
+ const params = getDefaultAsyncSubmitParams(mockConfig, {
+ sessionId: 'foo',
+ });
+ expect(params).toHaveProperty('keep_alive', '259200000ms');
+ });
+
+ test('Uses `keepAlive` of `1m` if disabled', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: false,
+ });
+ const params = getDefaultAsyncSubmitParams(mockConfig, {
+ sessionId: 'foo',
+ });
+ expect(params).toHaveProperty('keep_alive', '1m');
+ });
+
+ test('Uses `keep_on_completion` if enabled', async () => {
+ const mockConfig = getMockSearchSessionsConfig({});
+ const params = getDefaultAsyncSubmitParams(mockConfig, {
+ sessionId: 'foo',
+ });
+ expect(params).toHaveProperty('keep_on_completion', true);
+ });
+
+ test('Does not use `keep_on_completion` if disabled', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: false,
+ });
+ const params = getDefaultAsyncSubmitParams(mockConfig, {
+ sessionId: 'foo',
+ });
+ expect(params).toHaveProperty('keep_on_completion', false);
+ });
+ });
+
+ describe('getDefaultAsyncGetParams', () => {
+ test('Uses `wait_for_completion_timeout`', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: true,
+ });
+ const params = getDefaultAsyncGetParams(mockConfig, {});
+ expect(params).toHaveProperty('wait_for_completion_timeout');
+ });
+
+ test('Uses `keep_alive` if `sessionId` is not provided', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: true,
+ });
+ const params = getDefaultAsyncGetParams(mockConfig, {});
+ expect(params).toHaveProperty('keep_alive', '1m');
+ });
+
+ test('Has no `keep_alive` if `sessionId` is provided', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: true,
+ });
+ const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
+ expect(params).not.toHaveProperty('keep_alive');
+ });
+
+ test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => {
+ const mockConfig = getMockSearchSessionsConfig({
+ defaultExpiration: moment.duration(3, 'd'),
+ enabled: false,
+ });
+ const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
+ expect(params).toHaveProperty('keep_alive', '1m');
+ });
+ });
+});
diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts
new file mode 100644
index 0000000000000..d05b2710b07ea
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types';
+import { ISearchOptions } from '../../../../common';
+import { SearchSessionsConfigSchema } from '../../../../config';
+
+/**
+ @internal
+ */
+export function getDefaultAsyncSubmitParams(
+ searchSessionsConfig: SearchSessionsConfigSchema | null,
+ options: ISearchOptions
+): Pick {
+ const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId;
+
+ const keepAlive = useSearchSessions
+ ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms`
+ : '1m';
+
+ return {
+ // Wait up to 100ms for the response to return
+ wait_for_completion_timeout: '100ms',
+ // If search sessions are used, store and get an async ID even for short running requests.
+ keep_on_completion: useSearchSessions,
+ // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise.
+ keep_alive: keepAlive,
+ };
+}
+
+/**
+ @internal
+ */
+export function getDefaultAsyncGetParams(
+ searchSessionsConfig: SearchSessionsConfigSchema | null,
+ options: ISearchOptions
+): Pick {
+ const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId;
+
+ return {
+ // Wait up to 100ms for the response to return
+ wait_for_completion_timeout: '100ms',
+ ...(useSearchSessions
+ ? // Don't change the expiration of search requests that are tracked in a search session
+ undefined
+ : {
+ // We still need to do polling for searches not within the context of a search session or when search session disabled
+ keep_alive: '1m',
+ }),
+ };
+}
diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts
new file mode 100644
index 0000000000000..9d6e3f4fd3ebc
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import { SqlSearchStrategyResponse } from '../../../../common';
+
+/**
+ * Get the Kibana representation of an async search response
+ */
+export function toAsyncKibanaSearchResponse(
+ response: SqlQueryResponse,
+ warning?: string
+): SqlSearchStrategyResponse {
+ return {
+ id: response.id,
+ rawResponse: response,
+ isPartial: response.is_partial,
+ isRunning: response.is_running,
+ ...(warning ? { warning } : {}),
+ };
+}
diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts
new file mode 100644
index 0000000000000..2734a512e046b
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts
@@ -0,0 +1,264 @@
+/*
+ * 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 { KbnServerError } from '../../../../../kibana_utils/server';
+import { errors } from '@elastic/elasticsearch';
+import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json';
+import { SearchStrategyDependencies } from '../../types';
+import { sqlSearchStrategyProvider } from './sql_search_strategy';
+import { createSearchSessionsClientMock } from '../../mocks';
+import { SqlSearchStrategyRequest } from '../../../../common';
+
+const mockSqlResponse = {
+ body: {
+ id: 'foo',
+ is_partial: false,
+ is_running: false,
+ rows: [],
+ },
+};
+
+describe('SQL search strategy', () => {
+ const mockSqlGetAsync = jest.fn();
+ const mockSqlQuery = jest.fn();
+ const mockSqlDelete = jest.fn();
+ const mockLogger: any = {
+ debug: () => {},
+ };
+ const mockDeps = {
+ esClient: {
+ asCurrentUser: {
+ sql: {
+ getAsync: mockSqlGetAsync,
+ query: mockSqlQuery,
+ deleteAsync: mockSqlDelete,
+ },
+ },
+ },
+ searchSessionsClient: createSearchSessionsClientMock(),
+ } as unknown as SearchStrategyDependencies;
+
+ beforeEach(() => {
+ mockSqlGetAsync.mockClear();
+ mockSqlQuery.mockClear();
+ mockSqlDelete.mockClear();
+ });
+
+ it('returns a strategy with `search and `cancel`, `extend`', async () => {
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ expect(typeof esSearch.search).toBe('function');
+ expect(typeof esSearch.cancel).toBe('function');
+ expect(typeof esSearch.extend).toBe('function');
+ });
+
+ describe('search', () => {
+ describe('no sessionId', () => {
+ it('makes a POST request with params when no ID provided', async () => {
+ mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ params }, {}, mockDeps).toPromise();
+
+ expect(mockSqlQuery).toBeCalled();
+ const request = mockSqlQuery.mock.calls[0][0];
+ expect(request.query).toEqual(params.query);
+ expect(request).toHaveProperty('format', 'json');
+ expect(request).toHaveProperty('keep_alive', '1m');
+ expect(request).toHaveProperty('wait_for_completion_timeout');
+ });
+
+ it('makes a GET request to async search with ID', async () => {
+ mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise();
+
+ expect(mockSqlGetAsync).toBeCalled();
+ const request = mockSqlGetAsync.mock.calls[0][0];
+ expect(request.id).toEqual('foo');
+ expect(request).toHaveProperty('wait_for_completion_timeout');
+ expect(request).toHaveProperty('keep_alive', '1m');
+ expect(request).toHaveProperty('format', 'json');
+ });
+ });
+
+ // skip until full search session support https://github.com/elastic/kibana/issues/127880
+ describe.skip('with sessionId', () => {
+ it('makes a POST request with params (long keepalive)', async () => {
+ mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise();
+
+ expect(mockSqlQuery).toBeCalled();
+ const request = mockSqlQuery.mock.calls[0][0];
+ expect(request.query).toEqual(params.query);
+
+ expect(request).toHaveProperty('keep_alive', '604800000ms');
+ });
+
+ it('makes a GET request to async search without keepalive', async () => {
+ mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise();
+
+ expect(mockSqlGetAsync).toBeCalled();
+ const request = mockSqlGetAsync.mock.calls[0][0];
+ expect(request.id).toEqual('foo');
+ expect(request).toHaveProperty('wait_for_completion_timeout');
+ expect(request).not.toHaveProperty('keep_alive');
+ });
+ });
+
+ describe('with sessionId (until SQL ignores session Id)', () => {
+ it('makes a POST request with params (long keepalive)', async () => {
+ mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise();
+
+ expect(mockSqlQuery).toBeCalled();
+ const request = mockSqlQuery.mock.calls[0][0];
+ expect(request.query).toEqual(params.query);
+
+ expect(request).toHaveProperty('wait_for_completion_timeout');
+ expect(request).toHaveProperty('keep_alive', '1m');
+ });
+
+ it('makes a GET request to async search with keepalive', async () => {
+ mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise();
+
+ expect(mockSqlGetAsync).toBeCalled();
+ const request = mockSqlGetAsync.mock.calls[0][0];
+ expect(request.id).toEqual('foo');
+ expect(request).toHaveProperty('wait_for_completion_timeout');
+ expect(request).toHaveProperty('keep_alive', '1m');
+ });
+ });
+
+ it('throws normalized error if ResponseError is thrown', async () => {
+ const errResponse = new errors.ResponseError({
+ body: indexNotFoundException,
+ statusCode: 404,
+ headers: {},
+ warnings: [],
+ meta: {} as any,
+ });
+
+ mockSqlQuery.mockRejectedValue(errResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ let err: KbnServerError | undefined;
+ try {
+ await esSearch.search({ params }, {}, mockDeps).toPromise();
+ } catch (e) {
+ err = e;
+ }
+ expect(mockSqlQuery).toBeCalled();
+ expect(err).toBeInstanceOf(KbnServerError);
+ expect(err?.statusCode).toBe(404);
+ expect(err?.message).toBe(errResponse.message);
+ expect(err?.errBody).toBe(indexNotFoundException);
+ });
+
+ it('throws normalized error if Error is thrown', async () => {
+ const errResponse = new Error('not good');
+
+ mockSqlQuery.mockRejectedValue(errResponse);
+
+ const params: SqlSearchStrategyRequest['params'] = {
+ query:
+ 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
+ };
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ let err: KbnServerError | undefined;
+ try {
+ await esSearch.search({ params }, {}, mockDeps).toPromise();
+ } catch (e) {
+ err = e;
+ }
+ expect(mockSqlQuery).toBeCalled();
+ expect(err).toBeInstanceOf(KbnServerError);
+ expect(err?.statusCode).toBe(500);
+ expect(err?.message).toBe(errResponse.message);
+ expect(err?.errBody).toBe(undefined);
+ });
+ });
+
+ describe('cancel', () => {
+ it('makes a DELETE request to async search with the provided ID', async () => {
+ mockSqlDelete.mockResolvedValueOnce(200);
+
+ const id = 'some_id';
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+
+ await esSearch.cancel!(id, {}, mockDeps);
+
+ expect(mockSqlDelete).toBeCalled();
+ const request = mockSqlDelete.mock.calls[0][0];
+ expect(request).toEqual({ id });
+ });
+ });
+
+ describe('extend', () => {
+ it('makes a GET request to async search with the provided ID and keepAlive', async () => {
+ mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
+
+ const id = 'some_other_id';
+ const keepAlive = '1d';
+ const esSearch = await sqlSearchStrategyProvider(mockLogger);
+ await esSearch.extend!(id, keepAlive, {}, mockDeps);
+
+ expect(mockSqlGetAsync).toBeCalled();
+ const request = mockSqlGetAsync.mock.calls[0][0];
+ expect(request).toEqual({ id, keep_alive: keepAlive });
+ });
+ });
+});
diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts
new file mode 100644
index 0000000000000..51ab35af3db0f
--- /dev/null
+++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 type { IScopedClusterClient, Logger } from 'kibana/server';
+import { catchError, tap } from 'rxjs/operators';
+import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types';
+import type { ISearchStrategy, SearchStrategyDependencies } from '../../types';
+import type {
+ IAsyncSearchOptions,
+ SqlSearchStrategyRequest,
+ SqlSearchStrategyResponse,
+} from '../../../../common';
+import { pollSearch } from '../../../../common';
+import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils';
+import { toAsyncKibanaSearchResponse } from './response_utils';
+import { getKbnServerError } from '../../../../../kibana_utils/server';
+
+export const sqlSearchStrategyProvider = (
+ logger: Logger,
+ useInternalUser: boolean = false
+): ISearchStrategy => {
+ async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) {
+ try {
+ const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
+ await client.sql.deleteAsync({ id });
+ } catch (e) {
+ throw getKbnServerError(e);
+ }
+ }
+
+ function asyncSearch(
+ { id, ...request }: SqlSearchStrategyRequest,
+ options: IAsyncSearchOptions,
+ { esClient }: SearchStrategyDependencies
+ ) {
+ const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
+
+ // disable search sessions until session task manager supports SQL
+ // https://github.com/elastic/kibana/issues/127880
+ // const sessionConfig = searchSessionsClient.getConfig();
+ const sessionConfig = null;
+
+ const search = async () => {
+ if (id) {
+ const params: SqlGetAsyncRequest = {
+ format: request.params?.format ?? 'json',
+ ...getDefaultAsyncGetParams(sessionConfig, options),
+ id,
+ };
+
+ const { body, headers } = await client.sql.getAsync(params, {
+ signal: options.abortSignal,
+ meta: true,
+ });
+
+ return toAsyncKibanaSearchResponse(body, headers?.warning);
+ } else {
+ const params: SqlQueryRequest = {
+ format: request.params?.format ?? 'json',
+ ...getDefaultAsyncSubmitParams(sessionConfig, options),
+ ...request.params,
+ };
+
+ const { headers, body } = await client.sql.query(params, {
+ signal: options.abortSignal,
+ meta: true,
+ });
+
+ return toAsyncKibanaSearchResponse(body, headers?.warning);
+ }
+ };
+
+ const cancel = async () => {
+ if (id) {
+ await cancelAsyncSearch(id, esClient);
+ }
+ };
+
+ return pollSearch(search, cancel, options).pipe(
+ tap((response) => (id = response.id)),
+ catchError((e) => {
+ throw getKbnServerError(e);
+ })
+ );
+ }
+
+ return {
+ /**
+ * @param request
+ * @param options
+ * @param deps `SearchStrategyDependencies`
+ * @returns `Observable>`
+ * @throws `KbnServerError`
+ */
+ search: (request, options: IAsyncSearchOptions, deps) => {
+ logger.debug(`sql search: search request=${JSON.stringify(request)}`);
+
+ return asyncSearch(request, options, deps);
+ },
+ /**
+ * @param id async search ID to cancel, as returned from _async_search API
+ * @param options
+ * @param deps `SearchStrategyDependencies`
+ * @returns `Promise`
+ * @throws `KbnServerError`
+ */
+ cancel: async (id, options, { esClient }) => {
+ logger.debug(`sql search: cancel async_search_id=${id}`);
+ await cancelAsyncSearch(id, esClient);
+ },
+ /**
+ *
+ * @param id async search ID to extend, as returned from _async_search API
+ * @param keepAlive
+ * @param options
+ * @param deps `SearchStrategyDependencies`
+ * @returns `Promise`
+ * @throws `KbnServerError`
+ */
+ extend: async (id, keepAlive, options, { esClient }) => {
+ logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`);
+ try {
+ const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
+ await client.sql.getAsync({
+ id,
+ keep_alive: keepAlive,
+ });
+ } catch (e) {
+ throw getKbnServerError(e);
+ }
+ },
+ };
+};
diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx
index bcfde68abd99c..fa4796a7fb0cb 100644
--- a/src/plugins/dev_tools/public/application.tsx
+++ b/src/plugins/dev_tools/public/application.tsx
@@ -9,7 +9,13 @@
import React, { useEffect, useRef } from 'react';
import { Observable } from 'rxjs';
import ReactDOM from 'react-dom';
-import { HashRouter as Router, Switch, Route, Redirect } from 'react-router-dom';
+import {
+ HashRouter as Router,
+ Switch,
+ Route,
+ Redirect,
+ RouteComponentProps,
+} from 'react-router-dom';
import { EuiTab, EuiTabs, EuiToolTip, EuiBetaBadge } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
@@ -39,6 +45,7 @@ interface DevToolsWrapperProps {
updateRoute: (newRoute: string) => void;
theme$: Observable;
appServices: AppServices;
+ location: RouteComponentProps['location'];
}
interface MountedDevToolDescriptor {
@@ -53,6 +60,7 @@ function DevToolsWrapper({
updateRoute,
theme$,
appServices,
+ location,
}: DevToolsWrapperProps) {
const { docTitleService, breadcrumbService } = appServices;
const mountedTool = useRef(null);
@@ -127,11 +135,7 @@ function DevToolsWrapper({
const params = {
element,
- appBasePath: '',
- onAppLeave: () => undefined,
- setHeaderActionMenu: () => undefined,
- // TODO: adapt to use Core's ScopedHistory
- history: {} as any,
+ location,
theme$,
};
@@ -204,6 +208,7 @@ export function renderApp(
render={(props) => (
;
+}
+
export class DevToolApp {
/**
* The id of the dev tools. This will become part of the URL path
@@ -29,7 +38,7 @@ export class DevToolApp {
* May also be a ReactNode.
*/
public readonly title: string;
- public readonly mount: AppMount;
+ public readonly mount: (params: DevToolMountParams) => AppUnmount | Promise;
/**
* Mark the navigation tab as beta.
@@ -62,7 +71,7 @@ export class DevToolApp {
constructor(
id: string,
title: string,
- mount: AppMount,
+ mount: (params: DevToolMountParams) => AppUnmount | Promise,
enableRouting: boolean,
order: number,
toolTipContent = '',
diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap
index 15c2093580a7f..4381db10f8cd0 100644
--- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap
+++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap
@@ -43,7 +43,29 @@ exports[`InspectorPanel should render as expected 1`] = `
"navigateToUrl": [MockFunction],
},
"http": Object {},
- "share": Object {},
+ "share": Object {
+ "navigate": [MockFunction],
+ "toggleShareContextMenu": [MockFunction],
+ "url": UrlService {
+ "deps": Object {
+ "getUrl": [Function],
+ "navigate": [Function],
+ "shortUrls": [Function],
+ },
+ "locators": LocatorClient {
+ "deps": Object {
+ "getUrl": [Function],
+ "navigate": [Function],
+ "shortUrls": [Function],
+ },
+ "getAllMigrations": [Function],
+ "locators": Map {},
+ },
+ "shortUrls": Object {
+ "get": [Function],
+ },
+ },
+ },
"uiSettings": Object {},
}
}
@@ -206,7 +228,29 @@ exports[`InspectorPanel should render as expected 1`] = `
"navigateToUrl": [MockFunction],
},
"http": Object {},
- "share": Object {},
+ "share": Object {
+ "navigate": [MockFunction],
+ "toggleShareContextMenu": [MockFunction],
+ "url": UrlService {
+ "deps": Object {
+ "getUrl": [Function],
+ "navigate": [Function],
+ "shortUrls": [Function],
+ },
+ "locators": LocatorClient {
+ "deps": Object {
+ "getUrl": [Function],
+ "navigate": [Function],
+ "shortUrls": [Function],
+ },
+ "getAllMigrations": [Function],
+ "locators": Map {},
+ },
+ "shortUrls": Object {
+ "get": [Function],
+ },
+ },
+ },
"uiSettings": Object {},
}
}
diff --git a/src/plugins/inspector/public/ui/inspector_panel.test.tsx b/src/plugins/inspector/public/ui/inspector_panel.test.tsx
index 254afca11c1da..4466a293ca6b3 100644
--- a/src/plugins/inspector/public/ui/inspector_panel.test.tsx
+++ b/src/plugins/inspector/public/ui/inspector_panel.test.tsx
@@ -13,6 +13,7 @@ import { InspectorViewDescription } from '../types';
import { Adapters } from '../../common';
import type { ApplicationStart, HttpSetup, IUiSettingsClient } from 'kibana/public';
import { SharePluginStart } from '../../../share/public';
+import { sharePluginMock } from '../../../share/public/mocks';
import { applicationServiceMock } from '../../../../core/public/mocks';
describe('InspectorPanel', () => {
@@ -21,7 +22,7 @@ describe('InspectorPanel', () => {
const dependencies = {
application: applicationServiceMock.createStartContract(),
http: {},
- share: {},
+ share: sharePluginMock.createStartContract(),
uiSettings: {},
} as unknown as {
application: ApplicationStart;
diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx
index e46910c170103..216ccbe8d0c2c 100644
--- a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx
+++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx
@@ -32,6 +32,10 @@ const openInConsoleLabel = i18n.translate('inspector.requests.openInConsoleLabel
defaultMessage: 'Open in Console',
});
+const openInSearchProfilerLabel = i18n.translate('inspector.requests.openInSearchProfilerLabel', {
+ defaultMessage: 'Open in Search Profiler',
+});
+
/**
* @internal
*/
@@ -39,6 +43,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps
const { services } = useKibana();
const navigateToUrl = services.application?.navigateToUrl;
+
const devToolsDataUri = compressToEncodedURIComponent(`GET ${indexPattern}/_search\n${json}`);
const consoleHref = services.share.url.locators
.get('CONSOLE_APP_LOCATOR')
@@ -52,6 +57,19 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps
[consoleHref, navigateToUrl]
);
+ const searchProfilerDataUri = compressToEncodedURIComponent(json);
+ const searchProfilerHref = services.share.url.locators
+ .get('SEARCH_PROFILER_LOCATOR')
+ ?.useUrl({ index: indexPattern, loadFrom: `data:text/plain,${searchProfilerDataUri}` });
+ // Check if both the Dev Tools UI and the SearchProfiler UI are enabled.
+ const canShowsearchProfiler =
+ services.application?.capabilities?.dev_tools.show && searchProfilerHref !== undefined;
+ const shouldShowsearchProfilerLink = !!(indexPattern && canShowsearchProfiler);
+ const handleSearchProfilerLinkClick = useCallback(
+ () => searchProfilerHref && navigateToUrl && navigateToUrl(searchProfilerHref),
+ [searchProfilerHref, navigateToUrl]
+ );
+
return (
-
-
- {(copy) => (
-
- {copyToClipboardLabel}
-
- )}
-
+
+
+
+
+ {(copy) => (
+
+ {copyToClipboardLabel}
+
+ )}
+
+
+
{shouldShowDevToolsLink && (
-
- {openInConsoleLabel}
-
+
+
+
+ {openInConsoleLabel}
+
+
+
+ )}
+ {shouldShowsearchProfilerLink && (
+
+
+
+ {openInSearchProfilerLabel}
+
+
+
)}
-
+
= ({
application,
children,
diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts
index 94e8b505f1dd2..c1f0a520a1e9c 100644
--- a/src/plugins/kibana_react/public/index.ts
+++ b/src/plugins/kibana_react/public/index.ts
@@ -80,6 +80,7 @@ export { reactToUiComponent, uiToReactComponent } from './adapters';
export { toMountPoint, MountPointPortal } from './util';
export type { ToMountPointOptions } from './util';
+/** @deprecated Use `RedirectAppLinks` from `@kbn/shared-ux-components */
export { RedirectAppLinks } from './app_links';
export { wrapWithTheme, KibanaThemeProvider } from './theme';
diff --git a/src/plugins/maps_ems/common/ems_defaults.ts b/src/plugins/maps_ems/common/ems_defaults.ts
index 7b964c10ab063..5e4ad7ca6effc 100644
--- a/src/plugins/maps_ems/common/ems_defaults.ts
+++ b/src/plugins/maps_ems/common/ems_defaults.ts
@@ -8,7 +8,7 @@
export const DEFAULT_EMS_FILE_API_URL = 'https://vector.maps.elastic.co';
export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co';
-export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.1';
+export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.2';
export const DEFAULT_EMS_FONT_LIBRARY_URL =
'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf';
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts
index 332d60787b8cb..5828abda1107f 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/index.ts
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts
@@ -7,4 +7,5 @@
*/
export { SolutionToolbar } from './solution_toolbar';
+/** @deprecated QuickButtonGroup - use `IconButtonGroup` from `@kbn/shared-ux-components */
export * from './items';
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts
index 6076dbf8cf123..32972e4d2628d 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts
@@ -10,6 +10,7 @@ export { SolutionToolbarButton } from './button';
export { SolutionToolbarPopover } from './popover';
export { AddFromLibraryButton } from './add_from_library';
export type { QuickButtonProps } from './quick_group';
+/** @deprecated use `IconButtonGroup` from `@kbn/shared-ux-components */
export { QuickButtonGroup } from './quick_group';
export { PrimaryActionButton } from './primary_button';
export { PrimaryActionPopover } from './primary_popover';
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx
index 3a04a4c974538..e9daaf4ad7912 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx
@@ -13,6 +13,7 @@ import { EuiContextMenu } from '@elastic/eui';
import { SolutionToolbar } from './solution_toolbar';
import { SolutionToolbarPopover } from './items';
+
import { AddFromLibraryButton, PrimaryActionButton, QuickButtonGroup } from './items';
const quickButtons = [
diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts
index 7148b9fb6c7dd..61e677f7231ce 100644
--- a/src/plugins/presentation_util/public/index.ts
+++ b/src/plugins/presentation_util/public/index.ts
@@ -54,6 +54,7 @@ export {
AddFromLibraryButton,
PrimaryActionButton,
PrimaryActionPopover,
+ /** @deprecated QuickButtonGroup - use `IconButtonGroup` from `@kbn/shared-ux-components */
QuickButtonGroup,
SolutionToolbar,
SolutionToolbarButton,
diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts
index c1bd0a11f550a..53893a50673bf 100644
--- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts
+++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts
@@ -57,7 +57,7 @@ export const dateHistogram: AnnotationsRequestProcessorsFunction = ({
time_zone: timezone,
extended_bounds: {
min: from.valueOf(),
- max: to.valueOf() - bucketSize * 1000,
+ max: to.valueOf(),
},
...dateHistogramInterval(autoBucketSize < bucketSize ? autoIntervalString : intervalString),
});
diff --git a/src/plugins/visualizations/common/utils/prepare_log_table.ts b/src/plugins/visualizations/common/utils/prepare_log_table.ts
index af36ccccff350..36234a0fcaa58 100644
--- a/src/plugins/visualizations/common/utils/prepare_log_table.ts
+++ b/src/plugins/visualizations/common/utils/prepare_log_table.ts
@@ -57,17 +57,23 @@ const getDimensionName = (
}
};
-export const prepareLogTable = (datatable: Datatable, dimensions: Dimension[]) => {
+export const prepareLogTable = (
+ datatable: Datatable,
+ dimensions: Dimension[],
+ removeUnmappedColumns: boolean = false
+) => {
return {
...datatable,
- columns: datatable.columns.map((column, columnIndex) => {
- return {
- ...column,
- meta: {
- ...column.meta,
- dimensionName: getDimensionName(column, columnIndex, dimensions),
- },
- };
- }),
+ columns: datatable.columns
+ .map((column, columnIndex) => {
+ return {
+ ...column,
+ meta: {
+ ...column.meta,
+ dimensionName: getDimensionName(column, columnIndex, dimensions),
+ },
+ };
+ })
+ .filter((column) => !removeUnmappedColumns || column.meta.dimensionName),
};
};
diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts
index d5d6e928b5483..cde0c925d91ff 100644
--- a/test/api_integration/apis/search/index.ts
+++ b/test/api_integration/apis/search/index.ts
@@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('search', () => {
loadTestFile(require.resolve('./search'));
+ loadTestFile(require.resolve('./sql_search'));
loadTestFile(require.resolve('./bsearch'));
});
}
diff --git a/test/api_integration/apis/search/sql_search.ts b/test/api_integration/apis/search/sql_search.ts
new file mode 100644
index 0000000000000..c57d424e56fc7
--- /dev/null
+++ b/test/api_integration/apis/search/sql_search.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+
+ const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`;
+
+ describe('SQL search', () => {
+ before(async () => {
+ await esArchiver.emptyKibanaIndex();
+ await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
+ });
+
+ after(async () => {
+ await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
+ });
+ describe('post', () => {
+ it('should return 200 when correctly formatted searches are provided', async () => {
+ const resp = await supertest
+ .post(`/internal/search/sql`)
+ .send({
+ params: {
+ query: sqlQuery,
+ },
+ })
+ .expect(200);
+
+ expect(resp.body).to.have.property('id');
+ expect(resp.body).to.have.property('isPartial');
+ expect(resp.body).to.have.property('isRunning');
+ expect(resp.body).to.have.property('rawResponse');
+ });
+
+ it('should fetch search results by id', async () => {
+ const resp1 = await supertest
+ .post(`/internal/search/sql`)
+ .send({
+ params: {
+ query: sqlQuery,
+ keep_on_completion: true, // force keeping the results even if completes early
+ },
+ })
+ .expect(200);
+ const id = resp1.body.id;
+
+ const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({});
+
+ expect(resp2.status).to.be(200);
+ expect(resp2.body.id).to.be(id);
+ expect(resp2.body).to.have.property('isPartial');
+ expect(resp2.body).to.have.property('isRunning');
+ expect(resp2.body).to.have.property('rawResponse');
+ });
+ });
+
+ describe('delete', () => {
+ it('should delete search', async () => {
+ const resp1 = await supertest
+ .post(`/internal/search/sql`)
+ .send({
+ params: {
+ query: sqlQuery,
+ keep_on_completion: true, // force keeping the results even if completes early
+ },
+ })
+ .expect(200);
+ const id = resp1.body.id;
+
+ // confirm it was saved
+ await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200);
+
+ // delete it
+ await supertest.delete(`/internal/search/sql/${id}`).send().expect(200);
+
+ // check it was deleted
+ await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404);
+ });
+ });
+ });
+}
diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts
index cd17244b1f498..4b424b2a79c66 100644
--- a/test/functional/apps/console/_autocomplete.ts
+++ b/test/functional/apps/console/_autocomplete.ts
@@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true);
});
- describe('with a missing comma in query', () => {
+ // FLAKY: https://github.com/elastic/kibana/issues/126414
+ describe.skip('with a missing comma in query', () => {
const LINE_NUMBER = 4;
beforeEach(async () => {
await PageObjects.console.clearTextArea();
diff --git a/x-pack/plugins/actions/kibana.json b/x-pack/plugins/actions/kibana.json
index 4e928fafc4d50..4970d2b0870c8 100644
--- a/x-pack/plugins/actions/kibana.json
+++ b/x-pack/plugins/actions/kibana.json
@@ -9,6 +9,6 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "actions"],
"requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog", "features"],
- "optionalPlugins": ["usageCollection", "spaces", "security"],
+ "optionalPlugins": ["usageCollection", "spaces", "security", "monitoringCollection"],
"ui": false
}
diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts
index c8972d8113f16..1bb0e76d7226b 100644
--- a/x-pack/plugins/actions/server/action_type_registry.test.ts
+++ b/x-pack/plugins/actions/server/action_type_registry.test.ts
@@ -13,8 +13,10 @@ import { actionsConfigMock } from './actions_config.mock';
import { licenseStateMock } from './lib/license_state.mock';
import { ActionsConfigurationUtilities } from './actions_config';
import { licensingMock } from '../../licensing/server/mocks';
+import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock';
const mockTaskManager = taskManagerMock.createSetup();
+const inMemoryMetrics = inMemoryMetricsMock.create();
let mockedLicenseState: jest.Mocked;
let mockedActionsConfig: jest.Mocked;
let actionTypeRegistryParams: ActionTypeRegistryOpts;
@@ -26,7 +28,10 @@ beforeEach(() => {
actionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
- taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
+ taskRunnerFactory: new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ ),
actionsConfigUtils: mockedActionsConfig,
licenseState: mockedLicenseState,
preconfiguredActions: [
diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts
index c73809cc33773..41bb5171de405 100644
--- a/x-pack/plugins/actions/server/actions_client.test.ts
+++ b/x-pack/plugins/actions/server/actions_client.test.ts
@@ -39,6 +39,7 @@ import { ConnectorTokenClient } from './builtin_action_types/lib/connector_token
import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks';
import { Logger } from 'kibana/server';
import { connectorTokenClientMock } from './builtin_action_types/lib/connector_token_client.mock';
+import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock';
jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => ({
SavedObjectsUtils: {
@@ -85,6 +86,7 @@ const executor: ExecutorType<{}, {}, {}, void> = async (options) => {
};
const connectorTokenClient = connectorTokenClientMock.create();
+const inMemoryMetrics = inMemoryMetricsMock.create();
beforeEach(() => {
jest.resetAllMocks();
@@ -92,7 +94,10 @@ beforeEach(() => {
actionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
- taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
+ taskRunnerFactory: new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ ),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: mockedLicenseState,
preconfiguredActions: [],
@@ -499,7 +504,10 @@ describe('create()', () => {
const localActionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
- taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
+ taskRunnerFactory: new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ ),
actionsConfigUtils: localConfigUtils,
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts
index edb1ec2b46369..12001a472ca49 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts
@@ -14,6 +14,7 @@ import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../actions_config.mock';
import { licenseStateMock } from '../lib/license_state.mock';
import { licensingMock } from '../../../licensing/server/mocks';
+import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
const ACTION_TYPE_IDS = [
'.index',
@@ -32,10 +33,14 @@ export function createActionTypeRegistry(): {
actionTypeRegistry: ActionTypeRegistry;
} {
const logger = loggingSystemMock.create().get() as jest.Mocked;
+ const inMemoryMetrics = inMemoryMetricsMock.create();
const actionTypeRegistry = new ActionTypeRegistry({
taskManager: taskManagerMock.createSetup(),
licensing: licensingMock.createSetup(),
- taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
+ taskRunnerFactory: new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ ),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts
index 916dd9ed02b9f..ffbf150119510 100644
--- a/x-pack/plugins/actions/server/create_execute_function.test.ts
+++ b/x-pack/plugins/actions/server/create_execute_function.test.ts
@@ -93,6 +93,78 @@ describe('execute()', () => {
});
});
+ test('schedules the action with all given parameters and consumer', async () => {
+ const actionTypeRegistry = actionTypeRegistryMock.create();
+ const executeFn = createExecutionEnqueuerFunction({
+ taskManager: mockTaskManager,
+ actionTypeRegistry,
+ isESOCanEncrypt: true,
+ preconfiguredActions: [],
+ });
+ savedObjectsClient.get.mockResolvedValueOnce({
+ id: '123',
+ type: 'action',
+ attributes: {
+ actionTypeId: 'mock-action',
+ },
+ references: [],
+ });
+ savedObjectsClient.create.mockResolvedValueOnce({
+ id: '234',
+ type: 'action_task_params',
+ attributes: {},
+ references: [],
+ });
+ await executeFn(savedObjectsClient, {
+ id: '123',
+ params: { baz: false },
+ spaceId: 'default',
+ executionId: '123abc',
+ consumer: 'test-consumer',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ source: asHttpRequestExecutionSource(request),
+ });
+ expect(mockTaskManager.schedule).toHaveBeenCalledTimes(1);
+ expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "params": Object {
+ "actionTaskParamsId": "234",
+ "spaceId": "default",
+ },
+ "scope": Array [
+ "actions",
+ ],
+ "state": Object {},
+ "taskType": "actions:mock-action",
+ },
+ ]
+ `);
+ expect(savedObjectsClient.get).toHaveBeenCalledWith('action', '123');
+ expect(savedObjectsClient.create).toHaveBeenCalledWith(
+ 'action_task_params',
+ {
+ actionId: '123',
+ params: { baz: false },
+ executionId: '123abc',
+ consumer: 'test-consumer',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ },
+ {
+ references: [
+ {
+ id: '123',
+ name: 'actionRef',
+ type: 'action',
+ },
+ ],
+ }
+ );
+ expect(actionTypeRegistry.isActionExecutable).toHaveBeenCalledWith('123', 'mock-action', {
+ notifyUsage: true,
+ });
+ });
+
test('schedules the action with all given parameters and relatedSavedObjects', async () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
const executeFn = createExecutionEnqueuerFunction({
diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts
index c071be4759de4..46337441caace 100644
--- a/x-pack/plugins/actions/server/create_execute_function.ts
+++ b/x-pack/plugins/actions/server/create_execute_function.ts
@@ -30,6 +30,7 @@ export interface ExecuteOptions extends Pick {
return async function execute(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
- { id, params, spaceId, source, apiKey, executionId, relatedSavedObjects }: ExecuteOptions
+ {
+ id,
+ params,
+ spaceId,
+ consumer,
+ source,
+ apiKey,
+ executionId,
+ relatedSavedObjects,
+ }: ExecuteOptions
) {
if (!isESOCanEncrypt) {
throw new Error(
@@ -89,6 +99,7 @@ export function createExecutionEnqueuerFunction({
params,
apiKey,
executionId,
+ consumer,
relatedSavedObjects: relatedSavedObjectWithRefs,
},
{
@@ -115,7 +126,7 @@ export function createEphemeralExecutionEnqueuerFunction({
}: CreateExecuteFunctionOptions): ExecutionEnqueuer {
return async function execute(
unsecuredSavedObjectsClient: SavedObjectsClientContract,
- { id, params, spaceId, source, apiKey, executionId }: ExecuteOptions
+ { id, params, spaceId, source, consumer, apiKey, executionId }: ExecuteOptions
): Promise {
const { action } = await getAction(unsecuredSavedObjectsClient, preconfiguredActions, id);
validateCanActionBeUsed(action);
@@ -129,6 +140,7 @@ export function createEphemeralExecutionEnqueuerFunction({
spaceId,
taskParams: {
actionId: id,
+ consumer,
// Saved Objects won't allow us to enforce unknown rather than any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: params as Record,
diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts
index 9b7f9a97e58ec..3d6e49fa13584 100644
--- a/x-pack/plugins/actions/server/lib/action_executor.test.ts
+++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts
@@ -135,6 +135,9 @@ test('successfully executes', async () => {
"type_id": "test",
},
],
+ "space_ids": Array [
+ "some-namespace",
+ ],
},
"message": "action started: test:1: 1",
},
@@ -163,6 +166,9 @@ test('successfully executes', async () => {
"type_id": "test",
},
],
+ "space_ids": Array [
+ "some-namespace",
+ ],
},
"message": "action executed: test:1: 1",
},
@@ -534,6 +540,7 @@ test('writes to event log for execute timeout', async () => {
await actionExecutor.logCancellation({
actionId: 'action1',
executionId: '123abc',
+ consumer: 'test-consumer',
relatedSavedObjects: [],
request: {} as KibanaRequest,
});
@@ -546,6 +553,7 @@ test('writes to event log for execute timeout', async () => {
kibana: {
alert: {
rule: {
+ consumer: 'test-consumer',
execution: {
uuid: '123abc',
},
@@ -560,6 +568,7 @@ test('writes to event log for execute timeout', async () => {
namespace: 'some-namespace',
},
],
+ space_ids: ['some-namespace'],
},
message: `action: test:action1: 'action-1' execution cancelled due to timeout - exceeded default timeout of "5m"`,
});
@@ -595,6 +604,7 @@ test('writes to event log for execute and execute start', async () => {
namespace: 'some-namespace',
},
],
+ space_ids: ['some-namespace'],
},
message: 'action started: test:1: action-1',
});
@@ -621,6 +631,96 @@ test('writes to event log for execute and execute start', async () => {
namespace: 'some-namespace',
},
],
+ space_ids: ['some-namespace'],
+ },
+ message: 'action executed: test:1: action-1',
+ });
+});
+
+test('writes to event log for execute and execute start when consumer and related saved object are defined', async () => {
+ const executorMock = setupActionExecutorMock();
+ executorMock.mockResolvedValue({
+ actionId: '1',
+ status: 'ok',
+ });
+ await actionExecutor.execute({
+ ...executeParams,
+ consumer: 'test-consumer',
+ relatedSavedObjects: [
+ {
+ typeId: '.rule-type',
+ type: 'alert',
+ id: '12',
+ },
+ ],
+ });
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(2);
+ expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, {
+ event: {
+ action: 'execute-start',
+ kind: 'action',
+ },
+ kibana: {
+ alert: {
+ rule: {
+ consumer: 'test-consumer',
+ execution: {
+ uuid: '123abc',
+ },
+ rule_type_id: '.rule-type',
+ },
+ },
+ saved_objects: [
+ {
+ rel: 'primary',
+ type: 'action',
+ id: '1',
+ type_id: 'test',
+ namespace: 'some-namespace',
+ },
+ {
+ rel: 'primary',
+ type: 'alert',
+ id: '12',
+ type_id: '.rule-type',
+ },
+ ],
+ space_ids: ['some-namespace'],
+ },
+ message: 'action started: test:1: action-1',
+ });
+ expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, {
+ event: {
+ action: 'execute',
+ kind: 'action',
+ outcome: 'success',
+ },
+ kibana: {
+ alert: {
+ rule: {
+ consumer: 'test-consumer',
+ execution: {
+ uuid: '123abc',
+ },
+ rule_type_id: '.rule-type',
+ },
+ },
+ saved_objects: [
+ {
+ rel: 'primary',
+ type: 'action',
+ id: '1',
+ type_id: 'test',
+ namespace: 'some-namespace',
+ },
+ {
+ rel: 'primary',
+ type: 'alert',
+ id: '12',
+ type_id: '.rule-type',
+ },
+ ],
+ space_ids: ['some-namespace'],
},
message: 'action executed: test:1: action-1',
});
diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts
index 0efdc4f8f082f..8869ed79dd2a6 100644
--- a/x-pack/plugins/actions/server/lib/action_executor.ts
+++ b/x-pack/plugins/actions/server/lib/action_executor.ts
@@ -61,6 +61,7 @@ export interface ExecuteOptions {
source?: ActionExecutionSource;
taskInfo?: TaskInfo;
executionId?: string;
+ consumer?: string;
relatedSavedObjects?: RelatedSavedObjects;
}
@@ -70,6 +71,7 @@ export class ActionExecutor {
private isInitialized = false;
private actionExecutorContext?: ActionExecutorContext;
private readonly isESOCanEncrypt: boolean;
+
private actionInfo: ActionInfo | undefined;
constructor({ isESOCanEncrypt }: { isESOCanEncrypt: boolean }) {
@@ -92,6 +94,7 @@ export class ActionExecutor {
isEphemeral,
taskInfo,
executionId,
+ consumer,
relatedSavedObjects,
}: ExecuteOptions): Promise> {
if (!this.isInitialized) {
@@ -187,9 +190,11 @@ export class ActionExecutor {
const event = createActionEventLogRecordObject({
actionId,
action: EVENT_LOG_ACTIONS.execute,
+ consumer,
...namespace,
...task,
executionId,
+ spaceId,
savedObjects: [
{
type: 'action',
@@ -198,18 +203,9 @@ export class ActionExecutor {
relation: SAVED_OBJECT_REL_PRIMARY,
},
],
+ relatedSavedObjects,
});
- for (const relatedSavedObject of relatedSavedObjects || []) {
- event.kibana?.saved_objects?.push({
- rel: SAVED_OBJECT_REL_PRIMARY,
- type: relatedSavedObject.type,
- id: relatedSavedObject.id,
- type_id: relatedSavedObject.typeId,
- namespace: relatedSavedObject.namespace,
- });
- }
-
eventLogger.startTiming(event);
const startEvent = cloneDeep({
@@ -288,6 +284,7 @@ export class ActionExecutor {
source,
executionId,
taskInfo,
+ consumer,
}: {
actionId: string;
request: KibanaRequest;
@@ -295,6 +292,7 @@ export class ActionExecutor {
executionId?: string;
relatedSavedObjects: RelatedSavedObjects;
source?: ActionExecutionSource;
+ consumer?: string;
}) {
const {
spaces,
@@ -326,6 +324,7 @@ export class ActionExecutor {
// Write event log entry
const event = createActionEventLogRecordObject({
actionId,
+ consumer,
action: EVENT_LOG_ACTIONS.executeTimeout,
message: `action: ${this.actionInfo.actionTypeId}:${actionId}: '${
this.actionInfo.name ?? ''
@@ -333,6 +332,7 @@ export class ActionExecutor {
...namespace,
...task,
executionId,
+ spaceId,
savedObjects: [
{
type: 'action',
@@ -341,17 +341,9 @@ export class ActionExecutor {
relation: SAVED_OBJECT_REL_PRIMARY,
},
],
+ relatedSavedObjects,
});
- for (const relatedSavedObject of (relatedSavedObjects || []) as RelatedSavedObjects) {
- event.kibana?.saved_objects?.push({
- rel: SAVED_OBJECT_REL_PRIMARY,
- type: relatedSavedObject.type,
- id: relatedSavedObject.id,
- type_id: relatedSavedObject.typeId,
- namespace: relatedSavedObject.namespace,
- });
- }
eventLogger.logEvent(event);
}
}
diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts
index bea2a2680bb83..72cbda1312b9a 100644
--- a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts
+++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts
@@ -13,6 +13,7 @@ describe('createActionEventLogRecordObject', () => {
createActionEventLogRecordObject({
actionId: '1',
action: 'execute-start',
+ consumer: 'test-consumer',
timestamp: '1970-01-01T00:00:00.000Z',
task: {
scheduled: '1970-01-01T00:00:00.000Z',
@@ -27,6 +28,7 @@ describe('createActionEventLogRecordObject', () => {
relation: 'primary',
},
],
+ spaceId: 'default',
})
).toStrictEqual({
'@timestamp': '1970-01-01T00:00:00.000Z',
@@ -37,6 +39,7 @@ describe('createActionEventLogRecordObject', () => {
kibana: {
alert: {
rule: {
+ consumer: 'test-consumer',
execution: {
uuid: '123abc',
},
@@ -50,6 +53,7 @@ describe('createActionEventLogRecordObject', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
task: {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
@@ -67,6 +71,7 @@ describe('createActionEventLogRecordObject', () => {
message: 'action execution start',
namespace: 'default',
executionId: '123abc',
+ consumer: 'test-consumer',
savedObjects: [
{
id: '2',
@@ -84,6 +89,7 @@ describe('createActionEventLogRecordObject', () => {
kibana: {
alert: {
rule: {
+ consumer: 'test-consumer',
execution: {
uuid: '123abc',
},
@@ -149,4 +155,66 @@ describe('createActionEventLogRecordObject', () => {
},
});
});
+
+ test('created action event "execute" with related saved object', async () => {
+ expect(
+ createActionEventLogRecordObject({
+ actionId: '1',
+ name: 'test name',
+ action: 'execute',
+ message: 'action execution start',
+ namespace: 'default',
+ executionId: '123abc',
+ consumer: 'test-consumer',
+ savedObjects: [
+ {
+ id: '2',
+ type: 'action',
+ typeId: '.email',
+ relation: 'primary',
+ },
+ ],
+ relatedSavedObjects: [
+ {
+ type: 'alert',
+ typeId: '.rule-type',
+ id: '123',
+ },
+ ],
+ })
+ ).toStrictEqual({
+ event: {
+ action: 'execute',
+ kind: 'action',
+ },
+ kibana: {
+ alert: {
+ rule: {
+ consumer: 'test-consumer',
+ execution: {
+ uuid: '123abc',
+ },
+ rule_type_id: '.rule-type',
+ },
+ },
+ saved_objects: [
+ {
+ id: '2',
+ namespace: 'default',
+ rel: 'primary',
+ type: 'action',
+ type_id: '.email',
+ },
+ {
+ id: '123',
+ rel: 'primary',
+ type: 'alert',
+ namespace: undefined,
+ type_id: '.rule-type',
+ },
+ ],
+ },
+ message: 'action execution start',
+ });
+ });
});
diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts
index 5555fe8ada325..c6686e97a4c8b 100644
--- a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts
+++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts
@@ -5,7 +5,9 @@
* 2.0.
*/
-import { IEvent } from '../../../event_log/server';
+import { set } from 'lodash';
+import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
+import { RelatedSavedObjects } from './related_saved_objects';
export type Event = Exclude;
@@ -16,6 +18,8 @@ interface CreateActionEventLogRecordParams {
message?: string;
namespace?: string;
timestamp?: string;
+ spaceId?: string;
+ consumer?: string;
task?: {
scheduled?: string;
scheduleDelay?: number;
@@ -27,10 +31,12 @@ interface CreateActionEventLogRecordParams {
typeId: string;
relation?: string;
}>;
+ relatedSavedObjects?: RelatedSavedObjects;
}
export function createActionEventLogRecordObject(params: CreateActionEventLogRecordParams): Event {
- const { action, message, task, namespace, executionId } = params;
+ const { action, message, task, namespace, executionId, spaceId, consumer, relatedSavedObjects } =
+ params;
const event: Event = {
...(params.timestamp ? { '@timestamp': params.timestamp } : {}),
@@ -39,17 +45,18 @@ export function createActionEventLogRecordObject(params: CreateActionEventLogRec
kind: 'action',
},
kibana: {
- ...(executionId
- ? {
- alert: {
- rule: {
+ alert: {
+ rule: {
+ ...(consumer ? { consumer } : {}),
+ ...(executionId
+ ? {
execution: {
uuid: executionId,
},
- },
- },
- }
- : {}),
+ }
+ : {}),
+ },
+ },
saved_objects: params.savedObjects.map((so) => ({
...(so.relation ? { rel: so.relation } : {}),
type: so.type,
@@ -57,9 +64,24 @@ export function createActionEventLogRecordObject(params: CreateActionEventLogRec
type_id: so.typeId,
...(namespace ? { namespace } : {}),
})),
+ ...(spaceId ? { space_ids: [spaceId] } : {}),
...(task ? { task: { scheduled: task.scheduled, schedule_delay: task.scheduleDelay } } : {}),
},
...(message ? { message } : {}),
};
+
+ for (const relatedSavedObject of relatedSavedObjects || []) {
+ const ruleTypeId = relatedSavedObject.type === 'alert' ? relatedSavedObject.typeId : null;
+ if (ruleTypeId) {
+ set(event, 'kibana.alert.rule.rule_type_id', ruleTypeId);
+ }
+ event.kibana?.saved_objects?.push({
+ rel: SAVED_OBJECT_REL_PRIMARY,
+ type: relatedSavedObject.type,
+ id: relatedSavedObject.id,
+ type_id: relatedSavedObject.typeId,
+ namespace: relatedSavedObject.namespace,
+ });
+ }
return event;
}
diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts
index ab4d50338684b..2793d82544955 100644
--- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts
+++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts
@@ -17,12 +17,15 @@ import { savedObjectsClientMock, loggingSystemMock, httpServiceMock } from 'src/
import { eventLoggerMock } from '../../../event_log/server/mocks';
import { ActionTypeDisabledError } from './errors';
import { actionsClientMock } from '../mocks';
+import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
+import { IN_MEMORY_METRICS } from '../monitoring';
const spaceIdToNamespace = jest.fn();
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockedEncryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient();
const mockedActionExecutor = actionExecutorMock.create();
const eventLogger = eventLoggerMock.create();
+const inMemoryMetrics = inMemoryMetricsMock.create();
let fakeTimer: sinon.SinonFakeTimers;
let taskRunnerFactory: TaskRunnerFactory;
@@ -46,7 +49,7 @@ beforeAll(() => {
},
taskType: 'actions:1',
};
- taskRunnerFactory = new TaskRunnerFactory(mockedActionExecutor);
+ taskRunnerFactory = new TaskRunnerFactory(mockedActionExecutor, inMemoryMetrics);
mockedActionExecutor.initialize(actionExecutorInitializerParams);
taskRunnerFactory.initialize(taskRunnerFactoryInitializerParams);
});
@@ -84,14 +87,20 @@ beforeEach(() => {
});
test(`throws an error if factory isn't initialized`, () => {
- const factory = new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true }));
+ const factory = new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ );
expect(() =>
factory.create({ taskInstance: mockedTaskInstance })
).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`);
});
test(`throws an error if factory is already initialized`, () => {
- const factory = new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true }));
+ const factory = new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ );
factory.initialize(taskRunnerFactoryInitializerParams);
expect(() =>
factory.initialize(taskRunnerFactoryInitializerParams)
@@ -212,6 +221,62 @@ test('executes the task by calling the executor with proper parameters, using st
);
});
+test('executes the task by calling the executor with proper parameters when consumer is provided', async () => {
+ const taskRunner = taskRunnerFactory.create({
+ taskInstance: mockedTaskInstance,
+ });
+
+ mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' });
+ spaceIdToNamespace.mockReturnValueOnce('namespace-test');
+ mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
+ id: '3',
+ type: 'action_task_params',
+ attributes: {
+ actionId: '2',
+ consumer: 'test-consumer',
+ params: { baz: true },
+ executionId: '123abc',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ },
+ references: [],
+ });
+
+ const runnerResult = await taskRunner.run();
+
+ expect(runnerResult).toBeUndefined();
+ expect(spaceIdToNamespace).toHaveBeenCalledWith('test');
+ expect(mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith(
+ 'action_task_params',
+ '3',
+ { namespace: 'namespace-test' }
+ );
+
+ expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
+ actionId: '2',
+ consumer: 'test-consumer',
+ isEphemeral: false,
+ params: { baz: true },
+ relatedSavedObjects: [],
+ executionId: '123abc',
+ request: expect.objectContaining({
+ headers: {
+ // base64 encoded "123:abc"
+ authorization: 'ApiKey MTIzOmFiYw==',
+ },
+ }),
+ taskInfo: {
+ scheduled: new Date(),
+ attempts: 0,
+ },
+ });
+
+ const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
+ expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
+ executeParams.request,
+ '/s/test'
+ );
+});
+
test('cleans up action_task_params object', async () => {
const taskRunner = taskRunnerFactory.create({
taskInstance: mockedTaskInstance,
@@ -562,7 +627,7 @@ test('sanitizes invalid relatedSavedObjects when provided', async () => {
});
test(`doesn't use API key when not provided`, async () => {
- const factory = new TaskRunnerFactory(mockedActionExecutor);
+ const factory = new TaskRunnerFactory(mockedActionExecutor, inMemoryMetrics);
factory.initialize(taskRunnerFactoryInitializerParams);
const taskRunner = factory.create({ taskInstance: mockedTaskInstance });
@@ -785,3 +850,92 @@ test('treats errors as errors if the error is thrown instead of returned', async
`Action '2' failed and will retry: undefined`
);
});
+
+test('increments monitoring metrics after execution', async () => {
+ const taskRunner = taskRunnerFactory.create({
+ taskInstance: mockedTaskInstance,
+ });
+
+ mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' });
+ spaceIdToNamespace.mockReturnValueOnce('namespace-test');
+ mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
+ id: '3',
+ type: 'action_task_params',
+ attributes: {
+ actionId: '2',
+ params: { baz: true },
+ executionId: '123abc',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ },
+ references: [],
+ });
+
+ await taskRunner.run();
+
+ expect(inMemoryMetrics.increment).toHaveBeenCalledTimes(1);
+ expect(inMemoryMetrics.increment.mock.calls[0][0]).toBe(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
+});
+
+test('increments monitoring metrics after a failed execution', async () => {
+ const taskRunner = taskRunnerFactory.create({
+ taskInstance: mockedTaskInstance,
+ });
+
+ mockedActionExecutor.execute.mockResolvedValueOnce({
+ status: 'error',
+ actionId: '2',
+ message: 'Error message',
+ data: { foo: true },
+ retry: false,
+ });
+
+ spaceIdToNamespace.mockReturnValueOnce('namespace-test');
+ mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
+ id: '3',
+ type: 'action_task_params',
+ attributes: {
+ actionId: '2',
+ params: { baz: true },
+ executionId: '123abc',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ },
+ references: [],
+ });
+
+ let err;
+ try {
+ await taskRunner.run();
+ } catch (e) {
+ err = e;
+ }
+
+ expect(err).toBeDefined();
+ expect(inMemoryMetrics.increment).toHaveBeenCalledTimes(2);
+ expect(inMemoryMetrics.increment.mock.calls[0][0]).toBe(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
+ expect(inMemoryMetrics.increment.mock.calls[1][0]).toBe(IN_MEMORY_METRICS.ACTION_FAILURES);
+});
+
+test('increments monitoring metrics after a timeout', async () => {
+ const taskRunner = taskRunnerFactory.create({
+ taskInstance: mockedTaskInstance,
+ });
+
+ mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' });
+ spaceIdToNamespace.mockReturnValueOnce('namespace-test');
+ mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
+ id: '3',
+ type: 'action_task_params',
+ attributes: {
+ actionId: '2',
+ params: { baz: true },
+ executionId: '123abc',
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ },
+ references: [],
+ });
+
+ await taskRunner.cancel();
+
+ expect(inMemoryMetrics.increment).toHaveBeenCalledTimes(1);
+ expect(inMemoryMetrics.increment.mock.calls[0][0]).toBe(IN_MEMORY_METRICS.ACTION_TIMEOUTS);
+});
diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts
index 99aead5a73a40..221c84664f47e 100644
--- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts
+++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts
@@ -34,6 +34,7 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../constants/saved_objects
import { asSavedObjectExecutionSource } from './action_execution_source';
import { RelatedSavedObjects, validatedRelatedSavedObjects } from './related_saved_objects';
import { injectSavedObjectReferences } from './action_task_params_utils';
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring';
export interface TaskRunnerContext {
logger: Logger;
@@ -48,9 +49,11 @@ export class TaskRunnerFactory {
private isInitialized = false;
private taskRunnerContext?: TaskRunnerContext;
private readonly actionExecutor: ActionExecutorContract;
+ private readonly inMemoryMetrics: InMemoryMetrics;
- constructor(actionExecutor: ActionExecutorContract) {
+ constructor(actionExecutor: ActionExecutorContract, inMemoryMetrics: InMemoryMetrics) {
this.actionExecutor = actionExecutor;
+ this.inMemoryMetrics = inMemoryMetrics;
}
public initialize(taskRunnerContext: TaskRunnerContext) {
@@ -66,7 +69,7 @@ export class TaskRunnerFactory {
throw new Error('TaskRunnerFactory not initialized');
}
- const { actionExecutor } = this;
+ const { actionExecutor, inMemoryMetrics } = this;
const {
logger,
encryptedSavedObjectsClient,
@@ -86,7 +89,7 @@ export class TaskRunnerFactory {
const { spaceId } = actionTaskExecutorParams;
const {
- attributes: { actionId, params, apiKey, executionId, relatedSavedObjects },
+ attributes: { actionId, params, apiKey, executionId, consumer, relatedSavedObjects },
references,
} = await getActionTaskParams(
actionTaskExecutorParams,
@@ -115,6 +118,7 @@ export class TaskRunnerFactory {
...getSourceFromReferences(references),
taskInfo,
executionId,
+ consumer,
relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects),
});
} catch (e) {
@@ -130,12 +134,14 @@ export class TaskRunnerFactory {
}
}
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
if (
executorResult &&
executorResult?.status === 'error' &&
executorResult?.retry !== undefined &&
isRetryableBasedOnAttempts
) {
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_FAILURES);
logger.error(
`Action '${actionId}' failed ${
!!executorResult.retry ? willRetryMessage : willNotRetryMessage
@@ -149,6 +155,7 @@ export class TaskRunnerFactory {
executorResult.retry as boolean | Date
);
} else if (executorResult && executorResult?.status === 'error') {
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_FAILURES);
logger.error(
`Action '${actionId}' failed ${willNotRetryMessage}: ${executorResult.message}`
);
@@ -179,7 +186,7 @@ export class TaskRunnerFactory {
const { spaceId } = actionTaskExecutorParams;
const {
- attributes: { actionId, apiKey, executionId, relatedSavedObjects },
+ attributes: { actionId, apiKey, executionId, consumer, relatedSavedObjects },
references,
} = await getActionTaskParams(
actionTaskExecutorParams,
@@ -194,11 +201,14 @@ export class TaskRunnerFactory {
await actionExecutor.logCancellation({
actionId,
request,
+ consumer,
executionId,
relatedSavedObjects: (relatedSavedObjects || []) as RelatedSavedObjects,
...getSourceFromReferences(references),
});
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_TIMEOUTS);
+
logger.debug(
`Cancelling action task for action with id ${actionId} - execution error due to timeout.`
);
diff --git a/x-pack/plugins/actions/server/monitoring/in_memory_metrics.mock.ts b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.mock.ts
new file mode 100644
index 0000000000000..4b613753d6164
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.mock.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+function createInMemoryMetricsMock() {
+ return jest.fn().mockImplementation(() => {
+ return {
+ increment: jest.fn(),
+ getInMemoryMetric: jest.fn(),
+ getAllInMemoryMetrics: jest.fn(),
+ };
+ });
+}
+
+export const inMemoryMetricsMock = {
+ create: createInMemoryMetricsMock(),
+};
diff --git a/x-pack/plugins/actions/server/monitoring/in_memory_metrics.test.ts b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.test.ts
new file mode 100644
index 0000000000000..8e888503451a5
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.test.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '.';
+import { loggingSystemMock } from '../../../../../src/core/server/mocks';
+
+describe('inMemoryMetrics', () => {
+ const logger = loggingSystemMock.createLogger();
+ const inMemoryMetrics = new InMemoryMetrics(logger);
+
+ beforeEach(() => {
+ const all = inMemoryMetrics.getAllInMemoryMetrics();
+ for (const key of Object.keys(all)) {
+ all[key as IN_MEMORY_METRICS] = 0;
+ }
+ });
+
+ it('should increment', () => {
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_EXECUTIONS)).toBe(1);
+ });
+
+ it('should set to null if incrementing will set over the max integer', () => {
+ const all = inMemoryMetrics.getAllInMemoryMetrics();
+ all[IN_MEMORY_METRICS.ACTION_EXECUTIONS] = Number.MAX_SAFE_INTEGER;
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_EXECUTIONS)).toBe(null);
+ expect(logger.info).toHaveBeenCalledWith(
+ `Metric ${IN_MEMORY_METRICS.ACTION_EXECUTIONS} has reached the max safe integer value and will no longer be used, skipping increment.`
+ );
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_EXECUTIONS)).toBe(null);
+ expect(logger.info).toHaveBeenCalledWith(
+ `Metric ${IN_MEMORY_METRICS.ACTION_EXECUTIONS} is null because the counter ran over the max safe integer value, skipping increment.`
+ );
+ });
+});
diff --git a/x-pack/plugins/actions/server/monitoring/in_memory_metrics.ts b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.ts
new file mode 100644
index 0000000000000..2d9b9f61407db
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/in_memory_metrics.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Logger } from '@kbn/logging';
+
+export enum IN_MEMORY_METRICS {
+ ACTION_EXECUTIONS = 'actionExecutions',
+ ACTION_FAILURES = 'actionFailures',
+ ACTION_TIMEOUTS = 'actionTimeouts',
+}
+
+export class InMemoryMetrics {
+ private logger: Logger;
+ private inMemoryMetrics: Record = {
+ [IN_MEMORY_METRICS.ACTION_EXECUTIONS]: 0,
+ [IN_MEMORY_METRICS.ACTION_FAILURES]: 0,
+ [IN_MEMORY_METRICS.ACTION_TIMEOUTS]: 0,
+ };
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+
+ public increment(metric: IN_MEMORY_METRICS) {
+ if (this.inMemoryMetrics[metric] === null) {
+ this.logger.info(
+ `Metric ${metric} is null because the counter ran over the max safe integer value, skipping increment.`
+ );
+ return;
+ }
+
+ if ((this.inMemoryMetrics[metric] as number) >= Number.MAX_SAFE_INTEGER) {
+ this.inMemoryMetrics[metric] = null;
+ this.logger.info(
+ `Metric ${metric} has reached the max safe integer value and will no longer be used, skipping increment.`
+ );
+ } else {
+ (this.inMemoryMetrics[metric] as number)++;
+ }
+ }
+
+ public getInMemoryMetric(metric: IN_MEMORY_METRICS) {
+ return this.inMemoryMetrics[metric];
+ }
+
+ public getAllInMemoryMetrics() {
+ return this.inMemoryMetrics;
+ }
+}
diff --git a/x-pack/plugins/actions/server/monitoring/index.ts b/x-pack/plugins/actions/server/monitoring/index.ts
new file mode 100644
index 0000000000000..f084c1a420327
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+export { registerClusterCollector } from './register_cluster_collector';
+export { registerNodeCollector } from './register_node_collector';
+export * from './types';
+export * from './in_memory_metrics';
diff --git a/x-pack/plugins/actions/server/monitoring/register_cluster_collector.test.ts b/x-pack/plugins/actions/server/monitoring/register_cluster_collector.test.ts
new file mode 100644
index 0000000000000..4949f0d443989
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/register_cluster_collector.test.ts
@@ -0,0 +1,158 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { coreMock } from 'src/core/public/mocks';
+import { CoreSetup } from '../../../../../src/core/server';
+import { monitoringCollectionMock } from '../../../monitoring_collection/server/mocks';
+import { Metric } from '../../../monitoring_collection/server';
+import { registerClusterCollector } from './register_cluster_collector';
+import { ActionsPluginsStart } from '../plugin';
+import { ClusterActionsMetric } from './types';
+
+jest.useFakeTimers('modern');
+jest.setSystemTime(new Date('2020-03-09').getTime());
+
+describe('registerClusterCollector()', () => {
+ const monitoringCollection = monitoringCollectionMock.createSetup();
+ const coreSetup = coreMock.createSetup() as unknown as CoreSetup;
+ const taskManagerFetch = jest.fn();
+
+ beforeEach(() => {
+ (coreSetup.getStartServices as jest.Mock).mockImplementation(async () => {
+ return [
+ undefined,
+ {
+ taskManager: {
+ fetch: taskManagerFetch,
+ },
+ },
+ ];
+ });
+ });
+
+ it('should get overdue actions', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerClusterCollector({ monitoringCollection, core: coreSetup });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('cluster_actions');
+
+ const nowInMs = +new Date();
+ const docs = [
+ {
+ runAt: nowInMs - 1000,
+ },
+ {
+ retryAt: nowInMs - 1000,
+ },
+ ];
+ taskManagerFetch.mockImplementation(async () => ({ docs }));
+
+ const result = (await metrics.cluster_actions.fetch()) as ClusterActionsMetric;
+ expect(result.overdue.count).toBe(docs.length);
+ expect(result.overdue.delay.p50).toBe(1000);
+ expect(result.overdue.delay.p99).toBe(1000);
+ expect(taskManagerFetch).toHaveBeenCalledWith({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.scope': {
+ value: 'actions',
+ },
+ },
+ },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.status': 'idle',
+ },
+ },
+ {
+ range: {
+ 'task.runAt': {
+ lte: 'now',
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ bool: {
+ should: [
+ {
+ term: {
+ 'task.status': 'running',
+ },
+ },
+ {
+ term: {
+ 'task.status': 'claiming',
+ },
+ },
+ ],
+ },
+ },
+ {
+ range: {
+ 'task.retryAt': {
+ lte: 'now',
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('should calculate accurate p50 and p99', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerClusterCollector({ monitoringCollection, core: coreSetup });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('cluster_actions');
+
+ const nowInMs = +new Date();
+ const docs = [
+ { runAt: nowInMs - 1000 },
+ { runAt: nowInMs - 2000 },
+ { runAt: nowInMs - 3000 },
+ { runAt: nowInMs - 4000 },
+ { runAt: nowInMs - 40000 },
+ ];
+ taskManagerFetch.mockImplementation(async () => ({ docs }));
+
+ const result = (await metrics.cluster_actions.fetch()) as ClusterActionsMetric;
+ expect(result.overdue.count).toBe(docs.length);
+ expect(result.overdue.delay.p50).toBe(3000);
+ expect(result.overdue.delay.p99).toBe(40000);
+ });
+});
diff --git a/x-pack/plugins/actions/server/monitoring/register_cluster_collector.ts b/x-pack/plugins/actions/server/monitoring/register_cluster_collector.ts
new file mode 100644
index 0000000000000..b09b1d99db87e
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/register_cluster_collector.ts
@@ -0,0 +1,90 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import stats from 'stats-lite';
+import { MonitoringCollectionSetup } from '../../../monitoring_collection/server';
+import {
+ IdleTaskWithExpiredRunAt,
+ RunningOrClaimingTaskWithExpiredRetryAt,
+} from '../../../task_manager/server';
+import { CoreSetup } from '../../../../../src/core/server';
+import { ActionsPluginsStart } from '../plugin';
+import { ClusterActionsMetric } from './types';
+
+export function registerClusterCollector({
+ monitoringCollection,
+ core,
+}: {
+ monitoringCollection: MonitoringCollectionSetup;
+ core: CoreSetup;
+}) {
+ monitoringCollection.registerMetric({
+ type: 'cluster_actions',
+ schema: {
+ overdue: {
+ count: {
+ type: 'long',
+ },
+ delay: {
+ p50: {
+ type: 'long',
+ },
+ p99: {
+ type: 'long',
+ },
+ },
+ },
+ },
+ fetch: async () => {
+ const [_, pluginStart] = await core.getStartServices();
+ const nowInMs = +new Date();
+ const { docs: overdueTasks } = await pluginStart.taskManager.fetch({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.scope': {
+ value: 'actions',
+ },
+ },
+ },
+ {
+ bool: {
+ should: [IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt],
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ const overdueTasksDelay = overdueTasks.map(
+ (overdueTask) => nowInMs - +new Date(overdueTask.runAt || overdueTask.retryAt)
+ );
+
+ const metrics: ClusterActionsMetric = {
+ overdue: {
+ count: overdueTasks.length,
+ delay: {
+ p50: stats.percentile(overdueTasksDelay, 0.5),
+ p99: stats.percentile(overdueTasksDelay, 0.99),
+ },
+ },
+ };
+
+ if (isNaN(metrics.overdue.delay.p50)) {
+ metrics.overdue.delay.p50 = 0;
+ }
+
+ if (isNaN(metrics.overdue.delay.p99)) {
+ metrics.overdue.delay.p99 = 0;
+ }
+
+ return metrics;
+ },
+ });
+}
diff --git a/x-pack/plugins/actions/server/monitoring/register_node_collector.test.ts b/x-pack/plugins/actions/server/monitoring/register_node_collector.test.ts
new file mode 100644
index 0000000000000..8ec7a3620943a
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/register_node_collector.test.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { monitoringCollectionMock } from '../../../monitoring_collection/server/mocks';
+import { Metric } from '../../../monitoring_collection/server';
+import { registerNodeCollector } from './register_node_collector';
+import { NodeActionsMetric } from './types';
+import { IN_MEMORY_METRICS } from '.';
+import { inMemoryMetricsMock } from './in_memory_metrics.mock';
+
+describe('registerNodeCollector()', () => {
+ const monitoringCollection = monitoringCollectionMock.createSetup();
+ const inMemoryMetrics = inMemoryMetricsMock.create();
+
+ it('should get in memory action metrics', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerNodeCollector({ monitoringCollection, inMemoryMetrics });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('node_actions');
+
+ (inMemoryMetrics.getInMemoryMetric as jest.Mock).mockImplementation((metric) => {
+ switch (metric) {
+ case IN_MEMORY_METRICS.ACTION_FAILURES:
+ return 2;
+ case IN_MEMORY_METRICS.ACTION_EXECUTIONS:
+ return 10;
+ case IN_MEMORY_METRICS.ACTION_TIMEOUTS:
+ return 1;
+ }
+ });
+
+ const result = (await metrics.node_actions.fetch()) as NodeActionsMetric;
+ expect(result).toStrictEqual({ failures: 2, executions: 10, timeouts: 1 });
+ });
+});
diff --git a/x-pack/plugins/actions/server/monitoring/register_node_collector.ts b/x-pack/plugins/actions/server/monitoring/register_node_collector.ts
new file mode 100644
index 0000000000000..7aab4f274e72a
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/register_node_collector.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { MonitoringCollectionSetup } from '../../../monitoring_collection/server';
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '.';
+
+export function registerNodeCollector({
+ monitoringCollection,
+ inMemoryMetrics,
+}: {
+ monitoringCollection: MonitoringCollectionSetup;
+ inMemoryMetrics: InMemoryMetrics;
+}) {
+ monitoringCollection.registerMetric({
+ type: 'node_actions',
+ schema: {
+ failures: {
+ type: 'long',
+ },
+ executions: {
+ type: 'long',
+ },
+ timeouts: {
+ type: 'long',
+ },
+ },
+ fetch: async () => {
+ return {
+ failures: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_FAILURES),
+ executions: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_EXECUTIONS),
+ timeouts: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.ACTION_TIMEOUTS),
+ };
+ },
+ });
+}
diff --git a/x-pack/plugins/actions/server/monitoring/types.ts b/x-pack/plugins/actions/server/monitoring/types.ts
new file mode 100644
index 0000000000000..39e6840ded7e3
--- /dev/null
+++ b/x-pack/plugins/actions/server/monitoring/types.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { MetricResult } from '../../../monitoring_collection/server';
+
+export type ClusterActionsMetric = MetricResult<{
+ overdue: {
+ count: number;
+ delay: {
+ p50: number;
+ p99: number;
+ };
+ };
+}>;
+
+export type NodeActionsMetric = MetricResult<{
+ failures: number | null;
+ executions: number | null;
+ timeouts: number | null;
+}>;
diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts
index f3d1fe8b4ff8a..2262258c20ef2 100644
--- a/x-pack/plugins/actions/server/plugin.ts
+++ b/x-pack/plugins/actions/server/plugin.ts
@@ -88,6 +88,8 @@ import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/aler
import { ACTIONS_FEATURE_ID, AlertHistoryEsIndexConnectorId } from '../common';
import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from './constants/event_log';
import { ConnectorTokenClient } from './builtin_action_types/lib/connector_token_client';
+import { InMemoryMetrics, registerClusterCollector, registerNodeCollector } from './monitoring';
+import { MonitoringCollectionSetup } from '../../monitoring_collection/server';
export interface PluginSetupContract {
registerType<
@@ -134,6 +136,7 @@ export interface ActionsPluginsSetup {
security?: SecurityPluginSetup;
features: FeaturesPluginSetup;
spaces?: SpacesPluginSetup;
+ monitoringCollection?: MonitoringCollectionSetup;
}
export interface ActionsPluginsStart {
@@ -164,6 +167,7 @@ export class ActionsPlugin implements Plugin(),
diff --git a/x-pack/plugins/actions/server/saved_objects/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json
index deb80c4c9798f..80646579f86db 100644
--- a/x-pack/plugins/actions/server/saved_objects/mappings.json
+++ b/x-pack/plugins/actions/server/saved_objects/mappings.json
@@ -29,6 +29,9 @@
"actionId": {
"type": "keyword"
},
+ "consumer": {
+ "type": "keyword"
+ },
"params": {
"enabled": false,
"type": "object"
diff --git a/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts
index 63fe7c0e32047..82c1c55cd8020 100644
--- a/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts
+++ b/x-pack/plugins/actions/server/saved_objects/transform_connectors_for_export.test.ts
@@ -14,12 +14,17 @@ import { licenseStateMock } from '../lib/license_state.mock';
import { taskManagerMock } from '../../../task_manager/server/mocks';
import { ActionExecutor, TaskRunnerFactory } from '../lib';
import { registerBuiltInActionTypes } from '../builtin_action_types';
+import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
describe('transform connector for export', () => {
+ const inMemoryMetrics = inMemoryMetricsMock.create();
const actionTypeRegistryParams: ActionTypeRegistryOpts = {
licensing: licensingMock.createSetup(),
taskManager: taskManagerMock.createSetup(),
- taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })),
+ taskRunnerFactory: new TaskRunnerFactory(
+ new ActionExecutor({ isESOCanEncrypt: true }),
+ inMemoryMetrics
+ ),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts
index 1e58627cefbcc..ec9a194da5f42 100644
--- a/x-pack/plugins/actions/server/types.ts
+++ b/x-pack/plugins/actions/server/types.ts
@@ -139,6 +139,7 @@ export interface ActionTaskParams extends SavedObjectAttributes {
params: Record;
apiKey?: string;
executionId?: string;
+ consumer?: string;
}
interface PersistedActionTaskExecutorParams {
diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json
index b2526d84a3ce4..95788811e43f8 100644
--- a/x-pack/plugins/actions/tsconfig.json
+++ b/x-pack/plugins/actions/tsconfig.json
@@ -21,6 +21,7 @@
{ "path": "../event_log/tsconfig.json" },
{ "path": "../encrypted_saved_objects/tsconfig.json" },
{ "path": "../features/tsconfig.json" },
+ { "path": "../monitoring_collection/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" }
]
}
diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts
index dd85fadb49878..1628abff7efc1 100644
--- a/x-pack/plugins/alerting/common/alert.ts
+++ b/x-pack/plugins/alerting/common/alert.ts
@@ -76,6 +76,7 @@ export interface AlertAggregations {
alertExecutionStatus: { [status: string]: number };
ruleEnabledStatus: { enabled: number; disabled: number };
ruleMutedStatus: { muted: number; unmuted: number };
+ ruleSnoozedStatus: { snoozed: number };
}
export interface MappedParamsProperties {
diff --git a/x-pack/plugins/alerting/kibana.json b/x-pack/plugins/alerting/kibana.json
index f061572604f84..fc45f22d9c9a6 100644
--- a/x-pack/plugins/alerting/kibana.json
+++ b/x-pack/plugins/alerting/kibana.json
@@ -19,6 +19,6 @@
"licensing",
"taskManager"
],
- "optionalPlugins": ["usageCollection", "spaces", "security"],
+ "optionalPlugins": ["usageCollection", "spaces", "security", "monitoringCollection"],
"extraPublicDirs": ["common", "common/parse_duration"]
}
diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts
index 546fd3e4aed9a..f7b154777baa4 100644
--- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts
+++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts
@@ -46,6 +46,7 @@ export enum WriteOperations {
MuteAlert = 'muteAlert',
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
+ Unsnooze = 'unsnooze',
}
export interface EnsureAuthorizedOpts {
diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts
index 3895c90d4a6c2..ba16b7c553e86 100644
--- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts
+++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts
@@ -28,6 +28,7 @@ describe('createAlertEventLogRecordObject', () => {
executionId: '7a7065d7-6e8b-4aae-8d20-c93613dec9fb',
ruleId: '1',
ruleType,
+ consumer: 'rule-consumer',
action: 'execute-start',
timestamp: '1970-01-01T00:00:00.000Z',
task: {
@@ -42,6 +43,7 @@ describe('createAlertEventLogRecordObject', () => {
relation: 'primary',
},
],
+ spaceId: 'default',
})
).toStrictEqual({
'@timestamp': '1970-01-01T00:00:00.000Z',
@@ -53,9 +55,11 @@ describe('createAlertEventLogRecordObject', () => {
kibana: {
alert: {
rule: {
+ consumer: 'rule-consumer',
execution: {
uuid: '7a7065d7-6e8b-4aae-8d20-c93613dec9fb',
},
+ rule_type_id: 'test',
},
},
saved_objects: [
@@ -67,6 +71,7 @@ describe('createAlertEventLogRecordObject', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
task: {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
@@ -88,6 +93,7 @@ describe('createAlertEventLogRecordObject', () => {
ruleId: '1',
ruleName: 'test name',
ruleType,
+ consumer: 'rule-consumer',
action: 'recovered-instance',
instanceId: 'test1',
group: 'group 1',
@@ -107,6 +113,7 @@ describe('createAlertEventLogRecordObject', () => {
relation: 'primary',
},
],
+ spaceId: 'default',
})
).toStrictEqual({
event: {
@@ -120,9 +127,11 @@ describe('createAlertEventLogRecordObject', () => {
kibana: {
alert: {
rule: {
+ consumer: 'rule-consumer',
execution: {
uuid: '7a7065d7-6e8b-4aae-8d20-c93613dec9fb',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -139,6 +148,7 @@ describe('createAlertEventLogRecordObject', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: 'message text here',
rule: {
@@ -158,6 +168,7 @@ describe('createAlertEventLogRecordObject', () => {
ruleId: '1',
ruleName: 'test name',
ruleType,
+ consumer: 'rule-consumer',
action: 'execute-action',
instanceId: 'test1',
group: 'group 1',
@@ -182,6 +193,7 @@ describe('createAlertEventLogRecordObject', () => {
typeId: '.email',
},
],
+ spaceId: 'default',
})
).toStrictEqual({
event: {
@@ -195,9 +207,11 @@ describe('createAlertEventLogRecordObject', () => {
kibana: {
alert: {
rule: {
+ consumer: 'rule-consumer',
execution: {
uuid: '7a7065d7-6e8b-4aae-8d20-c93613dec9fb',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -220,6 +234,7 @@ describe('createAlertEventLogRecordObject', () => {
type_id: '.email',
},
],
+ space_ids: ['default'],
},
message: 'action execution start',
rule: {
diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts
index 95e33d394fbd2..9c16c3af555c2 100644
--- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts
+++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts
@@ -16,6 +16,8 @@ interface CreateAlertEventLogRecordParams {
ruleId: string;
ruleType: UntypedNormalizedRuleType;
action: string;
+ spaceId?: string;
+ consumer?: string;
ruleName?: string;
instanceId?: string;
message?: string;
@@ -48,6 +50,8 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor
group,
subgroup,
namespace,
+ consumer,
+ spaceId,
} = params;
const alerting =
params.instanceId || group || subgroup
@@ -70,18 +74,20 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor
...(state?.duration !== undefined ? { duration: state.duration as number } : {}),
},
kibana: {
- ...(alerting ? alerting : {}),
- ...(executionId
- ? {
- alert: {
- rule: {
+ alert: {
+ rule: {
+ rule_type_id: ruleType.id,
+ ...(consumer ? { consumer } : {}),
+ ...(executionId
+ ? {
execution: {
uuid: executionId,
},
- },
- },
- }
- : {}),
+ }
+ : {}),
+ },
+ },
+ ...(alerting ? alerting : {}),
saved_objects: params.savedObjects.map((so) => ({
...(so.relation ? { rel: so.relation } : {}),
type: so.type,
@@ -89,6 +95,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor
type_id: so.typeId,
namespace,
})),
+ ...(spaceId ? { space_ids: [spaceId] } : {}),
...(task ? { task: { scheduled: task.scheduled, schedule_delay: task.scheduleDelay } } : {}),
},
...(message ? { message } : {}),
diff --git a/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.mock.ts b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.mock.ts
new file mode 100644
index 0000000000000..4b613753d6164
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.mock.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+function createInMemoryMetricsMock() {
+ return jest.fn().mockImplementation(() => {
+ return {
+ increment: jest.fn(),
+ getInMemoryMetric: jest.fn(),
+ getAllInMemoryMetrics: jest.fn(),
+ };
+ });
+}
+
+export const inMemoryMetricsMock = {
+ create: createInMemoryMetricsMock(),
+};
diff --git a/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.test.ts b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.test.ts
new file mode 100644
index 0000000000000..630aba27485e4
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.test.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '.';
+import { loggingSystemMock } from '../../../../../src/core/server/mocks';
+
+describe('inMemoryMetrics', () => {
+ const logger = loggingSystemMock.createLogger();
+ const inMemoryMetrics = new InMemoryMetrics(logger);
+
+ beforeEach(() => {
+ const all = inMemoryMetrics.getAllInMemoryMetrics();
+ for (const key of Object.keys(all)) {
+ all[key as IN_MEMORY_METRICS] = 0;
+ }
+ });
+
+ it('should increment', () => {
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_EXECUTIONS)).toBe(1);
+ });
+
+ it('should set to null if incrementing will set over the max integer', () => {
+ const all = inMemoryMetrics.getAllInMemoryMetrics();
+ all[IN_MEMORY_METRICS.RULE_EXECUTIONS] = Number.MAX_SAFE_INTEGER;
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_EXECUTIONS)).toBe(null);
+ expect(logger.info).toHaveBeenCalledWith(
+ `Metric ${IN_MEMORY_METRICS.RULE_EXECUTIONS} has reached the max safe integer value and will no longer be used, skipping increment.`
+ );
+ inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_EXECUTIONS)).toBe(null);
+ expect(logger.info).toHaveBeenCalledWith(
+ `Metric ${IN_MEMORY_METRICS.RULE_EXECUTIONS} is null because the counter ran over the max safe integer value, skipping increment.`
+ );
+ });
+});
diff --git a/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.ts b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.ts
new file mode 100644
index 0000000000000..a2d0425da1427
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/in_memory_metrics.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Logger } from '@kbn/logging';
+
+export enum IN_MEMORY_METRICS {
+ RULE_EXECUTIONS = 'ruleExecutions',
+ RULE_FAILURES = 'ruleFailures',
+ RULE_TIMEOUTS = 'ruleTimeouts',
+}
+
+export class InMemoryMetrics {
+ private logger: Logger;
+ private inMemoryMetrics: Record = {
+ [IN_MEMORY_METRICS.RULE_EXECUTIONS]: 0,
+ [IN_MEMORY_METRICS.RULE_FAILURES]: 0,
+ [IN_MEMORY_METRICS.RULE_TIMEOUTS]: 0,
+ };
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+
+ public increment(metric: IN_MEMORY_METRICS) {
+ if (this.inMemoryMetrics[metric] === null) {
+ this.logger.info(
+ `Metric ${metric} is null because the counter ran over the max safe integer value, skipping increment.`
+ );
+ return;
+ }
+
+ if ((this.inMemoryMetrics[metric] as number) >= Number.MAX_SAFE_INTEGER) {
+ this.inMemoryMetrics[metric] = null;
+ this.logger.info(
+ `Metric ${metric} has reached the max safe integer value and will no longer be used, skipping increment.`
+ );
+ } else {
+ (this.inMemoryMetrics[metric] as number)++;
+ }
+ }
+
+ public getInMemoryMetric(metric: IN_MEMORY_METRICS) {
+ return this.inMemoryMetrics[metric];
+ }
+
+ public getAllInMemoryMetrics() {
+ return this.inMemoryMetrics;
+ }
+}
diff --git a/x-pack/plugins/alerting/server/monitoring/index.ts b/x-pack/plugins/alerting/server/monitoring/index.ts
new file mode 100644
index 0000000000000..5f298456554f0
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+export { registerNodeCollector } from './register_node_collector';
+export { registerClusterCollector } from './register_cluster_collector';
+export * from './types';
+export * from './in_memory_metrics';
diff --git a/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.test.ts b/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.test.ts
new file mode 100644
index 0000000000000..73bef5b29172b
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.test.ts
@@ -0,0 +1,158 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { coreMock } from 'src/core/public/mocks';
+import { CoreSetup } from '../../../../../src/core/server';
+import { monitoringCollectionMock } from '../../../monitoring_collection/server/mocks';
+import { Metric } from '../../../monitoring_collection/server';
+import { registerClusterCollector } from './register_cluster_collector';
+import { AlertingPluginsStart } from '../plugin';
+import { ClusterRulesMetric } from './types';
+
+jest.useFakeTimers('modern');
+jest.setSystemTime(new Date('2020-03-09').getTime());
+
+describe('registerClusterCollector()', () => {
+ const monitoringCollection = monitoringCollectionMock.createSetup();
+ const coreSetup = coreMock.createSetup() as unknown as CoreSetup;
+ const taskManagerFetch = jest.fn();
+
+ beforeEach(() => {
+ (coreSetup.getStartServices as jest.Mock).mockImplementation(async () => {
+ return [
+ undefined,
+ {
+ taskManager: {
+ fetch: taskManagerFetch,
+ },
+ },
+ ];
+ });
+ });
+
+ it('should get overdue rules', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerClusterCollector({ monitoringCollection, core: coreSetup });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('cluster_rules');
+
+ const nowInMs = +new Date();
+ const docs = [
+ {
+ runAt: nowInMs - 1000,
+ },
+ {
+ retryAt: nowInMs - 1000,
+ },
+ ];
+ taskManagerFetch.mockImplementation(async () => ({ docs }));
+
+ const result = (await metrics.cluster_rules.fetch()) as ClusterRulesMetric;
+ expect(result.overdue.count).toBe(docs.length);
+ expect(result.overdue.delay.p50).toBe(1000);
+ expect(result.overdue.delay.p99).toBe(1000);
+ expect(taskManagerFetch).toHaveBeenCalledWith({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.scope': {
+ value: 'alerting',
+ },
+ },
+ },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.status': 'idle',
+ },
+ },
+ {
+ range: {
+ 'task.runAt': {
+ lte: 'now',
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ bool: {
+ should: [
+ {
+ term: {
+ 'task.status': 'running',
+ },
+ },
+ {
+ term: {
+ 'task.status': 'claiming',
+ },
+ },
+ ],
+ },
+ },
+ {
+ range: {
+ 'task.retryAt': {
+ lte: 'now',
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('should calculate accurate p50 and p99', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerClusterCollector({ monitoringCollection, core: coreSetup });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('cluster_rules');
+
+ const nowInMs = +new Date();
+ const docs = [
+ { runAt: nowInMs - 1000 },
+ { runAt: nowInMs - 2000 },
+ { runAt: nowInMs - 3000 },
+ { runAt: nowInMs - 4000 },
+ { runAt: nowInMs - 40000 },
+ ];
+ taskManagerFetch.mockImplementation(async () => ({ docs }));
+
+ const result = (await metrics.cluster_rules.fetch()) as ClusterRulesMetric;
+ expect(result.overdue.count).toBe(docs.length);
+ expect(result.overdue.delay.p50).toBe(3000);
+ expect(result.overdue.delay.p99).toBe(40000);
+ });
+});
diff --git a/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.ts b/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.ts
new file mode 100644
index 0000000000000..63dd6053d3889
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/register_cluster_collector.ts
@@ -0,0 +1,90 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import stats from 'stats-lite';
+import { MonitoringCollectionSetup } from '../../../monitoring_collection/server';
+import {
+ IdleTaskWithExpiredRunAt,
+ RunningOrClaimingTaskWithExpiredRetryAt,
+} from '../../../task_manager/server';
+import { CoreSetup } from '../../../../../src/core/server';
+import { AlertingPluginsStart } from '../plugin';
+import { ClusterRulesMetric } from './types';
+
+export function registerClusterCollector({
+ monitoringCollection,
+ core,
+}: {
+ monitoringCollection: MonitoringCollectionSetup;
+ core: CoreSetup;
+}) {
+ monitoringCollection.registerMetric({
+ type: 'cluster_rules',
+ schema: {
+ overdue: {
+ count: {
+ type: 'long',
+ },
+ delay: {
+ p50: {
+ type: 'long',
+ },
+ p99: {
+ type: 'long',
+ },
+ },
+ },
+ },
+ fetch: async () => {
+ const [_, pluginStart] = await core.getStartServices();
+ const now = +new Date();
+ const { docs: overdueTasks } = await pluginStart.taskManager.fetch({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'task.scope': {
+ value: 'alerting',
+ },
+ },
+ },
+ {
+ bool: {
+ should: [IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt],
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ const overdueTasksDelay = overdueTasks.map(
+ (overdueTask) => now - +new Date(overdueTask.runAt || overdueTask.retryAt)
+ );
+
+ const metrics: ClusterRulesMetric = {
+ overdue: {
+ count: overdueTasks.length,
+ delay: {
+ p50: stats.percentile(overdueTasksDelay, 0.5),
+ p99: stats.percentile(overdueTasksDelay, 0.99),
+ },
+ },
+ };
+
+ if (isNaN(metrics.overdue.delay.p50)) {
+ metrics.overdue.delay.p50 = 0;
+ }
+
+ if (isNaN(metrics.overdue.delay.p99)) {
+ metrics.overdue.delay.p99 = 0;
+ }
+
+ return metrics;
+ },
+ });
+}
diff --git a/x-pack/plugins/alerting/server/monitoring/register_node_collector.test.ts b/x-pack/plugins/alerting/server/monitoring/register_node_collector.test.ts
new file mode 100644
index 0000000000000..7c5dea1d2eb5f
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/register_node_collector.test.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { monitoringCollectionMock } from '../../../monitoring_collection/server/mocks';
+import { loggingSystemMock } from '../../../../../src/core/server/mocks';
+import { Metric } from '../../../monitoring_collection/server';
+import { registerNodeCollector } from './register_node_collector';
+import { NodeRulesMetric } from './types';
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '.';
+
+jest.mock('./in_memory_metrics');
+
+describe('registerNodeCollector()', () => {
+ const monitoringCollection = monitoringCollectionMock.createSetup();
+ const logger = loggingSystemMock.createLogger();
+ const inMemoryMetrics = new InMemoryMetrics(logger);
+
+ afterEach(() => {
+ (inMemoryMetrics.getInMemoryMetric as jest.Mock).mockClear();
+ });
+
+ it('should get in memory rule metrics', async () => {
+ const metrics: Record> = {};
+ monitoringCollection.registerMetric.mockImplementation((metric) => {
+ metrics[metric.type] = metric;
+ });
+ registerNodeCollector({ monitoringCollection, inMemoryMetrics });
+
+ const metricTypes = Object.keys(metrics);
+ expect(metricTypes.length).toBe(1);
+ expect(metricTypes[0]).toBe('node_rules');
+
+ (inMemoryMetrics.getInMemoryMetric as jest.Mock).mockImplementation((metric) => {
+ switch (metric) {
+ case IN_MEMORY_METRICS.RULE_FAILURES:
+ return 2;
+ case IN_MEMORY_METRICS.RULE_EXECUTIONS:
+ return 10;
+ case IN_MEMORY_METRICS.RULE_TIMEOUTS:
+ return 1;
+ }
+ });
+
+ const result = (await metrics.node_rules.fetch()) as NodeRulesMetric;
+ expect(result).toStrictEqual({ failures: 2, executions: 10, timeouts: 1 });
+ });
+});
diff --git a/x-pack/plugins/alerting/server/monitoring/register_node_collector.ts b/x-pack/plugins/alerting/server/monitoring/register_node_collector.ts
new file mode 100644
index 0000000000000..4e364e5c812fb
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/register_node_collector.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { MonitoringCollectionSetup } from '../../../monitoring_collection/server';
+import { IN_MEMORY_METRICS } from '.';
+import { InMemoryMetrics } from './in_memory_metrics';
+
+export function registerNodeCollector({
+ monitoringCollection,
+ inMemoryMetrics,
+}: {
+ monitoringCollection: MonitoringCollectionSetup;
+ inMemoryMetrics: InMemoryMetrics;
+}) {
+ monitoringCollection.registerMetric({
+ type: 'node_rules',
+ schema: {
+ failures: {
+ type: 'long',
+ },
+ executions: {
+ type: 'long',
+ },
+ timeouts: {
+ type: 'long',
+ },
+ },
+ fetch: async () => {
+ return {
+ failures: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_FAILURES),
+ executions: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_EXECUTIONS),
+ timeouts: inMemoryMetrics.getInMemoryMetric(IN_MEMORY_METRICS.RULE_TIMEOUTS),
+ };
+ },
+ });
+}
diff --git a/x-pack/plugins/alerting/server/monitoring/types.ts b/x-pack/plugins/alerting/server/monitoring/types.ts
new file mode 100644
index 0000000000000..20cb3fb5bb4e5
--- /dev/null
+++ b/x-pack/plugins/alerting/server/monitoring/types.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { MetricResult } from '../../../monitoring_collection/server';
+
+export type ClusterRulesMetric = MetricResult<{
+ overdue: {
+ count: number;
+ delay: {
+ p50: number;
+ p99: number;
+ };
+ };
+}>;
+
+export type NodeRulesMetric = MetricResult<{
+ failures: number | null;
+ executions: number | null;
+ timeouts: number | null;
+}>;
diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts
index a2c47d9d142be..9fe63b186fa6b 100644
--- a/x-pack/plugins/alerting/server/plugin.test.ts
+++ b/x-pack/plugins/alerting/server/plugin.test.ts
@@ -20,6 +20,7 @@ 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 => ({
healthCheck: {
@@ -71,6 +72,7 @@ describe('Alerting Plugin', () => {
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
+ monitoringCollection: monitoringCollectionMock.createSetup(),
};
let plugin: AlertingPlugin;
@@ -262,6 +264,7 @@ describe('Alerting Plugin', () => {
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
+ monitoringCollection: monitoringCollectionMock.createSetup(),
});
const startContract = plugin.start(coreMock.createStart(), {
@@ -299,6 +302,7 @@ describe('Alerting Plugin', () => {
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
+ monitoringCollection: monitoringCollectionMock.createSetup(),
});
const startContract = plugin.start(coreMock.createStart(), {
@@ -347,6 +351,7 @@ describe('Alerting Plugin', () => {
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
+ monitoringCollection: monitoringCollectionMock.createSetup(),
});
const startContract = plugin.start(coreMock.createStart(), {
diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts
index 9a044ef711612..a442931ca73d8 100644
--- a/x-pack/plugins/alerting/server/plugin.ts
+++ b/x-pack/plugins/alerting/server/plugin.ts
@@ -64,6 +64,8 @@ import { AlertingAuthorizationClientFactory } from './alerting_authorization_cli
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';
export const EVENT_LOG_PROVIDER = 'alerting';
@@ -125,6 +127,7 @@ export interface AlertingPluginsSetup {
usageCollection?: UsageCollectionSetup;
eventLog: IEventLogService;
statusService: StatusServiceSetup;
+ monitoringCollection: MonitoringCollectionSetup;
}
export interface AlertingPluginsStart {
@@ -155,6 +158,7 @@ export class AlertingPlugin {
private eventLogger?: IEventLogger;
private kibanaBaseUrl: string | undefined;
private usageCounter: UsageCounter | undefined;
+ private inMemoryMetrics: InMemoryMetrics;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get();
@@ -164,6 +168,7 @@ export class AlertingPlugin {
this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory();
this.telemetryLogger = initializerContext.logger.get('usage');
this.kibanaVersion = initializerContext.env.packageInfo.version;
+ this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics'));
}
public setup(
@@ -207,6 +212,7 @@ export class AlertingPlugin {
licenseState: this.licenseState,
licensing: plugins.licensing,
minimumScheduleInterval: this.config.rules.minimumScheduleInterval,
+ inMemoryMetrics: this.inMemoryMetrics,
});
this.ruleTypeRegistry = ruleTypeRegistry;
@@ -257,6 +263,17 @@ export class AlertingPlugin {
this.createRouteHandlerContext(core)
);
+ if (plugins.monitoringCollection) {
+ registerNodeCollector({
+ monitoringCollection: plugins.monitoringCollection,
+ inMemoryMetrics: this.inMemoryMetrics,
+ });
+ registerClusterCollector({
+ monitoringCollection: plugins.monitoringCollection,
+ core,
+ });
+ }
+
// Routes
const router = core.http.createRouter();
// Register routes
diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts
index 81fb66ef5cf55..038e923f28f0c 100644
--- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts
+++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts
@@ -57,6 +57,9 @@ describe('aggregateRulesRoute', () => {
muted: 2,
unmuted: 39,
},
+ ruleSnoozedStatus: {
+ snoozed: 4,
+ },
};
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
@@ -88,6 +91,9 @@ describe('aggregateRulesRoute', () => {
"muted": 2,
"unmuted": 39,
},
+ "rule_snoozed_status": Object {
+ "snoozed": 4,
+ },
},
}
`);
@@ -120,6 +126,9 @@ describe('aggregateRulesRoute', () => {
muted: 2,
unmuted: 39,
},
+ rule_snoozed_status: {
+ snoozed: 4,
+ },
},
});
});
diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts
index ee05897848ecf..8c44f57b83789 100644
--- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts
+++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts
@@ -49,12 +49,14 @@ const rewriteBodyRes: RewriteResponseCase = ({
alertExecutionStatus,
ruleEnabledStatus,
ruleMutedStatus,
+ ruleSnoozedStatus,
...rest
}) => ({
...rest,
rule_execution_status: alertExecutionStatus,
rule_enabled_status: ruleEnabledStatus,
rule_muted_status: ruleMutedStatus,
+ rule_snoozed_status: ruleSnoozedStatus,
});
export const aggregateRulesRoute = (
diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts
index ed1a9583cc75c..e03e726bb2b2c 100644
--- a/x-pack/plugins/alerting/server/routes/index.ts
+++ b/x-pack/plugins/alerting/server/routes/index.ts
@@ -31,6 +31,7 @@ import { unmuteAllRuleRoute } from './unmute_all_rule';
import { unmuteAlertRoute } from './unmute_alert';
import { updateRuleApiKeyRoute } from './update_rule_api_key';
import { snoozeRuleRoute } from './snooze_rule';
+import { unsnoozeRuleRoute } from './unsnooze_rule';
export interface RouteOptions {
router: IRouter;
@@ -65,4 +66,5 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
+ unsnoozeRuleRoute(router, licenseState);
}
diff --git a/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts b/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts
index 567ff3a5653d6..dbcce10cc8e3e 100644
--- a/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts
+++ b/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts
@@ -21,17 +21,10 @@ beforeEach(() => {
jest.resetAllMocks();
});
-const SNOOZE_END_TIME = '2025-03-07T00:00:00.000Z';
+// These tests don't test for future snooze time validation, so this date doesn't need to be in the future
+const SNOOZE_END_TIME = '2021-03-07T00:00:00.000Z';
describe('snoozeAlertRoute', () => {
- beforeAll(() => {
- jest.useFakeTimers('modern');
- jest.setSystemTime(new Date(2020, 3, 1));
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
it('snoozes an alert', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
diff --git a/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts b/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts
new file mode 100644
index 0000000000000..a0fbf9776240a
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { unsnoozeRuleRoute } from './unsnooze_rule';
+import { httpServiceMock } from 'src/core/server/mocks';
+import { licenseStateMock } from '../lib/license_state.mock';
+import { mockHandlerArguments } from './_mock_handler_arguments';
+import { rulesClientMock } from '../rules_client.mock';
+import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled';
+
+const rulesClient = rulesClientMock.create();
+jest.mock('../lib/license_api_access.ts', () => ({
+ verifyApiAccess: jest.fn(),
+}));
+
+beforeEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('unsnoozeAlertRoute', () => {
+ it('unsnoozes an alert', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ unsnoozeRuleRoute(router, licenseState);
+
+ const [config, handler] = router.post.mock.calls[0];
+
+ expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_unsnooze"`);
+
+ rulesClient.unsnooze.mockResolvedValueOnce();
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ params: {
+ id: '1',
+ },
+ },
+ ['noContent']
+ );
+
+ expect(await handler(context, req, res)).toEqual(undefined);
+
+ expect(rulesClient.unsnooze).toHaveBeenCalledTimes(1);
+ expect(rulesClient.unsnooze.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ },
+ ]
+ `);
+
+ expect(res.noContent).toHaveBeenCalled();
+ });
+
+ it('ensures the rule type gets validated for the license', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ unsnoozeRuleRoute(router, licenseState);
+
+ const [, handler] = router.post.mock.calls[0];
+
+ rulesClient.unsnooze.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid'));
+
+ const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [
+ 'ok',
+ 'forbidden',
+ ]);
+
+ await handler(context, req, res);
+
+ expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
+ });
+});
diff --git a/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts b/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts
new file mode 100644
index 0000000000000..f779f1681d482
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { IRouter } from 'kibana/server';
+import { schema } from '@kbn/config-schema';
+import { ILicenseState, RuleMutedError } from '../lib';
+import { verifyAccessAndContext } from './lib';
+import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
+
+const paramSchema = schema.object({
+ id: schema.string(),
+});
+
+export const unsnoozeRuleRoute = (
+ router: IRouter,
+ licenseState: ILicenseState
+) => {
+ router.post(
+ {
+ path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_unsnooze`,
+ validate: {
+ params: paramSchema,
+ },
+ },
+ router.handleLegacyErrors(
+ verifyAccessAndContext(licenseState, async function (context, req, res) {
+ const rulesClient = context.alerting.getRulesClient();
+ const params = req.params;
+ try {
+ await rulesClient.unsnooze({ ...params });
+ return res.noContent();
+ } catch (e) {
+ if (e instanceof RuleMutedError) {
+ return e.sendResponse(res);
+ }
+ throw e;
+ }
+ })
+ )
+ );
+};
diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts
index 34dca1faa79ca..f4102a2bc6227 100644
--- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts
+++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts
@@ -13,6 +13,7 @@ import { ILicenseState } from './lib/license_state';
import { licenseStateMock } from './lib/license_state.mock';
import { licensingMock } from '../../licensing/server/mocks';
import { loggingSystemMock } from 'src/core/server/mocks';
+import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock';
const logger = loggingSystemMock.create().get();
let mockedLicenseState: jest.Mocked;
@@ -20,6 +21,8 @@ let ruleTypeRegistryParams: ConstructorOptions;
const taskManager = taskManagerMock.createSetup();
+const inMemoryMetrics = inMemoryMetricsMock.create();
+
beforeEach(() => {
jest.resetAllMocks();
mockedLicenseState = licenseStateMock.create();
@@ -30,6 +33,7 @@ beforeEach(() => {
licenseState: mockedLicenseState,
licensing: licensingMock.createSetup(),
minimumScheduleInterval: { value: '1m', enforce: false },
+ inMemoryMetrics,
};
});
diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts
index 8aabd383e38b3..35e9e312e9a1a 100644
--- a/x-pack/plugins/alerting/server/rule_type_registry.ts
+++ b/x-pack/plugins/alerting/server/rule_type_registry.ts
@@ -31,6 +31,7 @@ import {
} from '../common';
import { ILicenseState } from './lib/license_state';
import { getRuleTypeFeatureUsageName } from './lib/get_rule_type_feature_usage_name';
+import { InMemoryMetrics } from './monitoring';
import { AlertingRulesConfig } from '.';
export interface ConstructorOptions {
@@ -40,6 +41,7 @@ export interface ConstructorOptions {
licenseState: ILicenseState;
licensing: LicensingPluginSetup;
minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval'];
+ inMemoryMetrics: InMemoryMetrics;
}
export interface RegistryRuleType
@@ -136,6 +138,7 @@ export class RuleTypeRegistry {
private readonly licenseState: ILicenseState;
private readonly minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval'];
private readonly licensing: LicensingPluginSetup;
+ private readonly inMemoryMetrics: InMemoryMetrics;
constructor({
logger,
@@ -144,6 +147,7 @@ export class RuleTypeRegistry {
licenseState,
licensing,
minimumScheduleInterval,
+ inMemoryMetrics,
}: ConstructorOptions) {
this.logger = logger;
this.taskManager = taskManager;
@@ -151,6 +155,7 @@ export class RuleTypeRegistry {
this.licenseState = licenseState;
this.licensing = licensing;
this.minimumScheduleInterval = minimumScheduleInterval;
+ this.inMemoryMetrics = inMemoryMetrics;
}
public has(id: string) {
@@ -269,7 +274,7 @@ export class RuleTypeRegistry {
InstanceContext,
ActionGroupIds,
RecoveryActionGroupId | RecoveredActionGroupId
- >(normalizedRuleType, context),
+ >(normalizedRuleType, context, this.inMemoryMetrics),
},
});
// No need to notify usage on basic alert types
diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts
index de1de6a8e3cbc..bc5c9c0a5e0cd 100644
--- a/x-pack/plugins/alerting/server/rules_client.mock.ts
+++ b/x-pack/plugins/alerting/server/rules_client.mock.ts
@@ -33,6 +33,7 @@ const createRulesClientMock = () => {
getExecutionLogForRule: jest.fn(),
getSpaceId: jest.fn(),
snooze: jest.fn(),
+ unsnooze: jest.fn(),
};
return mocked;
};
diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts
index 65be7fc739ca2..2192073a1244b 100644
--- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts
+++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts
@@ -25,6 +25,7 @@ export enum RuleAuditAction {
AGGREGATE = 'rule_aggregate',
GET_EXECUTION_LOG = 'rule_get_execution_log',
SNOOZE = 'rule_snooze',
+ UNSNOOZE = 'rule_unsnooze',
}
type VerbsTuple = [string, string, string];
@@ -50,6 +51,7 @@ const eventVerbs: Record = {
'accessed execution log for',
],
rule_snooze: ['snooze', 'snoozing', 'snoozed'],
+ rule_unsnooze: ['unsnooze', 'unsnoozing', 'unsnoozed'],
};
const eventTypes: Record = {
@@ -69,6 +71,7 @@ const eventTypes: Record = {
rule_aggregate: 'access',
rule_get_execution_log: 'access',
rule_snooze: 'change',
+ rule_unsnooze: 'change',
};
export interface RuleAuditEventParams {
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 666617dcf3fd8..4dc1ab6ce1122 100644
--- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts
+++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts
@@ -125,6 +125,13 @@ export interface RuleAggregation {
doc_count: number;
}>;
};
+ snoozed: {
+ buckets: Array<{
+ key: number;
+ key_as_string: string;
+ doc_count: number;
+ }>;
+ };
}
export interface ConstructorOptions {
@@ -191,6 +198,7 @@ export interface AggregateResult {
alertExecutionStatus: { [status: string]: number };
ruleEnabledStatus?: { enabled: number; disabled: number };
ruleMutedStatus?: { muted: number; unmuted: number };
+ ruleSnoozedStatus?: { snoozed: number };
}
export interface FindResult {
@@ -251,6 +259,14 @@ export interface GetExecutionLogByIdParams {
sort: estypes.Sort;
}
+interface ScheduleRuleOptions {
+ id: string;
+ consumer: string;
+ ruleTypeId: string;
+ schedule: IntervalSchedule;
+ throwOnConflict: boolean; // whether to throw conflict errors or swallow them
+}
+
// NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects
const extractedSavedObjectParamReferenceNamePrefix = 'param:';
@@ -368,19 +384,12 @@ export class RulesClient {
await this.validateActions(ruleType, data.actions);
- // Validate that schedule interval is not less than configured minimum
+ // Throw error if schedule interval is less than the minimum and we are enforcing it
const intervalInMs = parseDuration(data.schedule.interval);
- if (intervalInMs < this.minimumScheduleIntervalInMs) {
- if (this.minimumScheduleInterval.enforce) {
- throw Boom.badRequest(
- `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}`
- );
- } else {
- // just log warning but allow rule to be created
- this.logger.warn(
- `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.`
- );
- }
+ if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) {
+ throw Boom.badRequest(
+ `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}`
+ );
}
// Extract saved object references for this rule
@@ -449,12 +458,13 @@ export class RulesClient {
if (data.enabled) {
let scheduledTask;
try {
- scheduledTask = await this.scheduleRule(
- createdAlert.id,
- rawRule.alertTypeId,
- data.schedule,
- true
- );
+ scheduledTask = await this.scheduleRule({
+ id: createdAlert.id,
+ consumer: data.consumer,
+ ruleTypeId: rawRule.alertTypeId,
+ schedule: data.schedule,
+ throwOnConflict: true,
+ });
} catch (e) {
// Cleanup data, something went wrong scheduling the task
try {
@@ -472,6 +482,14 @@ export class RulesClient {
});
createdAlert.attributes.scheduledTaskId = scheduledTask.id;
}
+
+ // Log warning if schedule interval is less than the minimum but we're not enforcing it
+ if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) {
+ this.logger.warn(
+ `Rule schedule interval (${data.schedule.interval}) for "${createdAlert.attributes.alertTypeId}" rule type with ID "${createdAlert.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.`
+ );
+ }
+
return this.getAlertFromRaw(
createdAlert.id,
createdAlert.attributes.alertTypeId,
@@ -858,6 +876,7 @@ export class RulesClient {
);
throw error;
}
+
const { filter: authorizationFilter } = authorizationTuple;
const resp = await this.unsecuredSavedObjectsClient.find({
...options,
@@ -878,6 +897,13 @@ export class RulesClient {
muted: {
terms: { field: 'alert.attributes.muteAll' },
},
+ snoozed: {
+ date_range: {
+ field: 'alert.attributes.snoozeEndTime',
+ format: 'strict_date_time',
+ ranges: [{ from: 'now' }],
+ },
+ },
},
});
@@ -893,6 +919,7 @@ export class RulesClient {
muted: 0,
unmuted: 0,
},
+ ruleSnoozedStatus: { snoozed: 0 },
};
for (const key of RuleExecutionStatusValues) {
@@ -934,6 +961,11 @@ export class RulesClient {
unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0,
};
+ const snoozedBuckets = resp.aggregations.snoozed.buckets;
+ ret.ruleSnoozedStatus = {
+ snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0),
+ };
+
return ret;
}
@@ -1117,19 +1149,12 @@ export class RulesClient {
const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params);
await this.validateActions(ruleType, data.actions);
- // Validate that schedule interval is not less than configured minimum
+ // Throw error if schedule interval is less than the minimum and we are enforcing it
const intervalInMs = parseDuration(data.schedule.interval);
- if (intervalInMs < this.minimumScheduleIntervalInMs) {
- if (this.minimumScheduleInterval.enforce) {
- throw Boom.badRequest(
- `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}`
- );
- } else {
- // just log warning but allow rule to be updated
- this.logger.warn(
- `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.`
- );
- }
+ if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) {
+ throw Boom.badRequest(
+ `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}`
+ );
}
// Extract saved object references for this rule
@@ -1192,6 +1217,13 @@ export class RulesClient {
throw e;
}
+ // Log warning if schedule interval is less than the minimum but we're not enforcing it
+ if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) {
+ this.logger.warn(
+ `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.`
+ );
+ }
+
return this.getPartialRuleFromRaw(
id,
ruleType,
@@ -1422,12 +1454,13 @@ export class RulesClient {
);
throw e;
}
- const scheduledTask = await this.scheduleRule(
+ const scheduledTask = await this.scheduleRule({
id,
- attributes.alertTypeId,
- attributes.schedule as IntervalSchedule,
- false
- );
+ consumer: attributes.consumer,
+ ruleTypeId: attributes.alertTypeId,
+ schedule: attributes.schedule as IntervalSchedule,
+ throwOnConflict: false,
+ });
await this.unsecuredSavedObjectsClient.update('alert', id, {
scheduledTaskId: scheduledTask.id,
});
@@ -1496,6 +1529,7 @@ export class RulesClient {
ruleId: id,
ruleName: attributes.name,
ruleType: this.ruleTypeRegistry.get(attributes.alertTypeId),
+ consumer: attributes.consumer,
instanceId,
action: EVENT_LOG_ACTIONS.recoveredInstance,
message,
@@ -1503,6 +1537,7 @@ export class RulesClient {
group: actionGroup,
subgroup: actionSubgroup,
namespace: this.namespace,
+ spaceId: this.spaceId,
savedObjects: [
{
id,
@@ -1659,6 +1694,68 @@ export class RulesClient {
);
}
+ public async unsnooze({ id }: { id: string }): Promise {
+ return await retryIfConflicts(
+ this.logger,
+ `rulesClient.unsnooze('${id}')`,
+ async () => await this.unsnoozeWithOCC({ id })
+ );
+ }
+
+ private async unsnoozeWithOCC({ id }: { id: string }) {
+ const { attributes, version } = await this.unsecuredSavedObjectsClient.get(
+ 'alert',
+ id
+ );
+
+ try {
+ await this.authorization.ensureAuthorized({
+ ruleTypeId: attributes.alertTypeId,
+ consumer: attributes.consumer,
+ operation: WriteOperations.Unsnooze,
+ entity: AlertingAuthorizationEntity.Rule,
+ });
+
+ if (attributes.actions.length) {
+ await this.actionsAuthorization.ensureAuthorized('execute');
+ }
+ } catch (error) {
+ this.auditLogger?.log(
+ ruleAuditEvent({
+ action: RuleAuditAction.UNSNOOZE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+
+ this.auditLogger?.log(
+ ruleAuditEvent({
+ action: RuleAuditAction.UNSNOOZE,
+ outcome: 'unknown',
+ savedObject: { type: 'alert', id },
+ })
+ );
+
+ this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId);
+
+ const updateAttributes = this.updateMeta({
+ snoozeEndTime: null,
+ muteAll: false,
+ updatedBy: await this.getUserName(),
+ updatedAt: new Date().toISOString(),
+ });
+ const updateOptions = { version };
+
+ await partiallyUpdateAlert(
+ this.unsecuredSavedObjectsClient,
+ id,
+ updateAttributes,
+ updateOptions
+ );
+ }
+
public async muteAll({ id }: { id: string }): Promise {
return await retryIfConflicts(
this.logger,
@@ -1925,12 +2022,8 @@ export class RulesClient {
return this.spaceId;
}
- private async scheduleRule(
- id: string,
- ruleTypeId: string,
- schedule: IntervalSchedule,
- throwOnConflict: boolean // whether to throw conflict errors or swallow them
- ) {
+ private async scheduleRule(opts: ScheduleRuleOptions) {
+ const { id, consumer, ruleTypeId, schedule, throwOnConflict } = opts;
const taskInstance = {
id, // use the same ID for task document as the rule
taskType: `alerting:${ruleTypeId}`,
@@ -1938,6 +2031,7 @@ export class RulesClient {
params: {
alertId: id,
spaceId: this.spaceId,
+ consumer,
},
state: {
previousStartedAt: null,
@@ -2022,12 +2116,15 @@ export class RulesClient {
executionStatus,
schedule,
actions,
+ snoozeEndTime,
...partialRawRule
}: Partial,
references: SavedObjectReference[] | undefined,
includeLegacyId: boolean = false,
excludeFromPublicApi: boolean = false
): PartialRule | PartialRuleWithLegacyId {
+ const snoozeEndTimeDate = snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime;
+ const includeSnoozeEndTime = snoozeEndTimeDate !== undefined && !excludeFromPublicApi;
const rule = {
id,
notifyWhen,
@@ -2037,6 +2134,7 @@ export class RulesClient {
schedule: schedule as IntervalSchedule,
actions: actions ? this.injectReferencesIntoActions(id, actions, references || []) : [],
params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params,
+ ...(includeSnoozeEndTime ? { snoozeEndTime: snoozeEndTimeDate } : {}),
...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}),
...(createdAt ? { createdAt: new Date(createdAt) } : {}),
...(scheduledTaskId ? { scheduledTaskId } : {}),
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts
index aa910f4203f46..af27decb73a2a 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts
@@ -101,6 +101,17 @@ describe('aggregate()', () => {
{ key: 1, key_as_string: '1', doc_count: 3 },
],
},
+ snoozed: {
+ buckets: [
+ {
+ key: '2022-03-21T20:22:01.501Z-*',
+ format: 'strict_date_time',
+ from: 1.647894121501e12,
+ from_as_string: '2022-03-21T20:22:01.501Z',
+ doc_count: 2,
+ },
+ ],
+ },
},
});
@@ -146,6 +157,9 @@ describe('aggregate()', () => {
"muted": 3,
"unmuted": 27,
},
+ "ruleSnoozedStatus": Object {
+ "snoozed": 2,
+ },
}
`);
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1);
@@ -166,6 +180,13 @@ describe('aggregate()', () => {
muted: {
terms: { field: 'alert.attributes.muteAll' },
},
+ snoozed: {
+ date_range: {
+ field: 'alert.attributes.snoozeEndTime',
+ format: 'strict_date_time',
+ ranges: [{ from: 'now' }],
+ },
+ },
},
},
]);
@@ -193,6 +214,13 @@ describe('aggregate()', () => {
muted: {
terms: { field: 'alert.attributes.muteAll' },
},
+ snoozed: {
+ date_range: {
+ field: 'alert.attributes.snoozeEndTime',
+ format: 'strict_date_time',
+ ranges: [{ from: 'now' }],
+ },
+ },
},
},
]);
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
index df0e806e5e798..8a9cd1d4acc7f 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
@@ -456,6 +456,7 @@ describe('create()', () => {
"id": "1",
"params": Object {
"alertId": "1",
+ "consumer": "bar",
"spaceId": "default",
},
"schedule": Object {
@@ -2602,7 +2603,7 @@ describe('create()', () => {
await rulesClient.create({ data });
expect(rulesClientParams.logger.warn).toHaveBeenCalledWith(
- `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.`
+ `Rule schedule interval (1s) for "123" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.`
);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled();
expect(taskManager.schedule).toHaveBeenCalled();
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts
index 1ed8c5d77e567..5a6a7265d3a33 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts
@@ -329,6 +329,12 @@ describe('disable()', () => {
kind: 'alert',
},
kibana: {
+ alert: {
+ rule: {
+ consumer: 'myApp',
+ rule_type_id: '123',
+ },
+ },
alerting: {
action_group_id: 'default',
action_subgroup: 'newSubgroup',
@@ -343,6 +349,7 @@ describe('disable()', () => {
type_id: 'myType',
},
],
+ space_ids: ['default'],
},
message: "instance '1' has recovered due to the rule was disabled",
rule: {
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts
index 77ce4c7c49eb6..36ffd44a1df30 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts
@@ -255,6 +255,7 @@ describe('enable()', () => {
params: {
alertId: '1',
spaceId: 'default',
+ consumer: 'myApp',
},
schedule: {
interval: '10s',
@@ -536,6 +537,7 @@ describe('enable()', () => {
params: {
alertId: '1',
spaceId: 'default',
+ consumer: 'myApp',
},
schedule: {
interval: '10s',
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts
index a087dfd436817..4bc0276a9ae1a 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts
@@ -1947,7 +1947,7 @@ describe('update()', () => {
},
});
expect(rulesClientParams.logger.warn).toHaveBeenCalledWith(
- `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.`
+ `Rule schedule interval (1s) for "myType" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.`
);
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled();
});
diff --git a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts
index 9c99343b233dd..6bd3dfc99472b 100644
--- a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts
+++ b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts
@@ -13,12 +13,14 @@ import { ILicenseState } from '../lib/license_state';
import { licenseStateMock } from '../lib/license_state.mock';
import { licensingMock } from '../../../licensing/server/mocks';
import { isRuleExportable } from './is_rule_exportable';
+import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
import { loggingSystemMock } from 'src/core/server/mocks';
let ruleTypeRegistryParams: ConstructorOptions;
let logger: MockedLogger;
let mockedLicenseState: jest.Mocked;
const taskManager = taskManagerMock.createSetup();
+const inMemoryMetrics = inMemoryMetricsMock.create();
beforeEach(() => {
jest.resetAllMocks();
@@ -31,6 +33,7 @@ beforeEach(() => {
licenseState: mockedLicenseState,
licensing: licensingMock.createSetup(),
minimumScheduleInterval: { value: '1m', enforce: false },
+ inMemoryMetrics,
};
});
diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
index 11e50c55f5735..7d02535566cac 100644
--- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
@@ -79,6 +79,7 @@ const createExecutionHandlerParams: jest.Mocked<
spaceId: 'test1',
ruleId: '1',
ruleName: 'name-of-alert',
+ ruleConsumer: 'rule-consumer',
executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
tags: ['tag-A', 'tag-B'],
apiKey: 'MTIzOmFiYw==',
@@ -148,6 +149,7 @@ describe('Create Execution Handler', () => {
Array [
Object {
"apiKey": "MTIzOmFiYw==",
+ "consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
@@ -191,9 +193,11 @@ describe('Create Execution Handler', () => {
"kibana": Object {
"alert": Object {
"rule": Object {
+ "consumer": "rule-consumer",
"execution": Object {
"uuid": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
},
+ "rule_type_id": "test",
},
},
"alerting": Object {
@@ -215,6 +219,9 @@ describe('Create Execution Handler', () => {
"type_id": "test",
},
],
+ "space_ids": Array [
+ "test1",
+ ],
},
"message": "alert: test:1: 'name-of-alert' instanceId: '2' scheduled actionGroup: 'default' action: test:1",
"rule": Object {
@@ -275,6 +282,7 @@ describe('Create Execution Handler', () => {
expect(alertExecutionStore.numberOfTriggeredActions).toBe(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({
+ consumer: 'rule-consumer',
id: '2',
params: {
foo: true,
@@ -373,6 +381,7 @@ describe('Create Execution Handler', () => {
Array [
Object {
"apiKey": "MTIzOmFiYw==",
+ "consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
@@ -416,6 +425,7 @@ describe('Create Execution Handler', () => {
Array [
Object {
"apiKey": "MTIzOmFiYw==",
+ "consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
index c4cfc66c9acbb..279afee0e42c6 100644
--- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
+++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
@@ -37,6 +37,7 @@ export function createExecutionHandler<
logger,
ruleId,
ruleName,
+ ruleConsumer,
executionId,
tags,
actionsPlugin,
@@ -138,6 +139,7 @@ export function createExecutionHandler<
params: action.params,
spaceId,
apiKey: apiKey ?? null,
+ consumer: ruleConsumer,
source: asSavedObjectExecutionSource({
id: ruleId,
type: 'alert',
@@ -174,8 +176,10 @@ export function createExecutionHandler<
const event = createAlertEventLogRecordObject({
ruleId,
ruleType: ruleType as UntypedNormalizedRuleType,
+ consumer: ruleConsumer,
action: EVENT_LOG_ACTIONS.executeAction,
executionId,
+ spaceId,
instanceId: alertId,
group: actionGroup,
subgroup: actionSubgroup,
diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts
index 1a20ab28dfe13..4e38be4834c86 100644
--- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts
+++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts
@@ -22,6 +22,7 @@ export const RULE_TYPE_ID = 'test';
export const DATE_1969 = '1969-12-31T00:00:00.000Z';
export const DATE_1970 = '1970-01-01T00:00:00.000Z';
export const DATE_1970_5_MIN = '1969-12-31T23:55:00.000Z';
+export const DATE_9999 = '9999-12-31T12:34:56.789Z';
export const MOCK_DURATION = 86400000000000;
export const SAVED_OBJECT = {
@@ -29,6 +30,7 @@ export const SAVED_OBJECT = {
type: 'alert',
attributes: {
apiKey: Buffer.from('123:abc').toString('base64'),
+ consumer: 'bar',
enabled: true,
},
references: [],
@@ -174,6 +176,8 @@ export const mockTaskInstance = () => ({
taskType: 'alerting:test',
params: {
alertId: RULE_ID,
+ spaceId: 'default',
+ consumer: 'bar',
},
ownerId: null,
});
@@ -196,6 +200,7 @@ export const generateEventLog = ({
action,
task,
duration,
+ consumer,
start,
end,
outcome,
@@ -226,6 +231,7 @@ export const generateEventLog = ({
kibana: {
alert: {
rule: {
+ ...(consumer && { consumer }),
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
...(!isNil(numberOfTriggeredActions) && {
@@ -237,6 +243,7 @@ export const generateEventLog = ({
},
}),
},
+ rule_type_id: 'test',
},
},
...((actionSubgroup || actionGroupId || instanceId || status) && {
@@ -248,6 +255,7 @@ export const generateEventLog = ({
},
}),
saved_objects: savedObjects,
+ space_ids: ['default'],
...(task && {
task: {
schedule_delay: 0,
@@ -364,6 +372,7 @@ export const generateEnqueueFunctionInput = () => ({
params: {
foo: true,
},
+ consumer: 'bar',
relatedSavedObjects: [
{
id: '1',
@@ -379,7 +388,7 @@ export const generateEnqueueFunctionInput = () => ({
},
type: 'SAVED_OBJECT',
},
- spaceId: undefined,
+ spaceId: 'default',
});
export const generateAlertInstance = ({ id, duration, start }: GeneratorParams = { id: 1 }) => ({
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 1daa7d6913d8c..604b5aeb74339 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
@@ -42,6 +42,7 @@ import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
import { omit } from 'lodash';
import { ruleTypeRegistryMock } from '../rule_type_registry.mock';
import { ExecuteOptions } from '../../../actions/server/create_execute_function';
+import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
import moment from 'moment';
import {
generateActionSO,
@@ -64,8 +65,10 @@ import {
DATE_1969,
DATE_1970,
DATE_1970_5_MIN,
+ DATE_9999,
} from './fixtures';
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';
@@ -101,6 +104,7 @@ describe('Task Runner', () => {
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
const dataPlugin = dataPluginMock.createStartContract();
const uiSettingsService = uiSettingsServiceMock.createStartContract();
+ const inMemoryMetrics = inMemoryMetricsMock.create();
type TaskRunnerFactoryInitializerParamsType = jest.Mocked & {
actionsPlugin: jest.Mocked;
@@ -189,7 +193,8 @@ describe('Task Runner', () => {
previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -240,6 +245,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
@@ -291,7 +297,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -318,6 +325,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -329,6 +337,7 @@ describe('Task Runner', () => {
actionSubgroup: 'subDefault',
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -340,6 +349,7 @@ describe('Task Runner', () => {
actionGroupId: 'default',
actionSubgroup: 'subDefault',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -350,6 +360,7 @@ describe('Task Runner', () => {
instanceId: '1',
actionSubgroup: 'subDefault',
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
+ consumer: 'bar',
actionId: '1',
})
);
@@ -361,6 +372,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 1,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -386,7 +398,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -405,7 +418,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
3,
- `no scheduling of actions for rule test:1: '${RULE_NAME}': rule is muted.`
+ `no scheduling of actions for rule test:1: '${RULE_NAME}': rule is snoozed.`
);
expect(logger.debug).nthCalledWith(
4,
@@ -420,6 +433,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -430,6 +444,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -440,6 +455,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -450,11 +466,75 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 0,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
});
+ type SnoozeTestParams = [
+ muteAll: boolean,
+ snoozeEndTime: string | undefined | null,
+ shouldBeSnoozed: boolean
+ ];
+
+ const snoozeTestParams: SnoozeTestParams[] = [
+ [false, null, false],
+ [false, undefined, false],
+ [false, DATE_1970, false],
+ [false, DATE_9999, true],
+ [true, null, true],
+ [true, undefined, true],
+ [true, DATE_1970, true],
+ [true, DATE_9999, true],
+ ];
+
+ test.each(snoozeTestParams)(
+ 'snoozing works as expected with muteAll: %s; snoozeEndTime: %s',
+ async (muteAll, snoozeEndTime, shouldBeSnoozed) => {
+ taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
+ taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
+ ruleType.executor.mockImplementation(
+ async ({
+ services: executorServices,
+ }: AlertExecutorOptions<
+ AlertTypeParams,
+ AlertTypeState,
+ AlertInstanceState,
+ AlertInstanceContext,
+ string
+ >) => {
+ executorServices.alertFactory.create('1').scheduleActions('default');
+ }
+ );
+ const taskRunner = new TaskRunner(
+ ruleType,
+ mockedTaskInstance,
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
+ );
+ rulesClient.get.mockResolvedValue({
+ ...mockedRuleTypeSavedObject,
+ muteAll,
+ snoozeEndTime: snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime,
+ });
+ encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
+ await taskRunner.run();
+
+ const expectedExecutions = shouldBeSnoozed ? 0 : 1;
+ expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(expectedExecutions);
+ expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0);
+
+ const logger = taskRunnerFactoryInitializerParams.logger;
+ const expectedMessage = `no scheduling of actions for rule test:1: '${RULE_NAME}': rule is snoozed.`;
+ if (expectedExecutions) {
+ expect(logger.debug).not.toHaveBeenCalledWith(expectedMessage);
+ } else {
+ expect(logger.debug).toHaveBeenCalledWith(expectedMessage);
+ }
+ }
+ );
+
test.each(ephemeralTestParams)(
'skips firing actions for active alert if alert is muted %s',
async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => {
@@ -482,7 +562,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -555,7 +636,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -597,7 +679,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -652,7 +735,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -670,6 +754,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -680,6 +765,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -690,6 +776,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 0,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -733,7 +820,8 @@ describe('Task Runner', () => {
},
},
},
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -752,6 +840,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 1,
task: true,
+ consumer: 'bar',
})
);
expect(enqueueFunction).toHaveBeenCalledTimes(1);
@@ -804,7 +893,8 @@ describe('Task Runner', () => {
},
},
},
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -823,6 +913,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 1,
task: true,
+ consumer: 'bar',
})
);
@@ -857,7 +948,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
@@ -894,6 +986,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -904,6 +997,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -914,6 +1008,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -924,6 +1019,7 @@ describe('Task Runner', () => {
instanceId: '1',
actionId: '1',
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -934,6 +1030,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 1,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -990,7 +1087,8 @@ describe('Task Runner', () => {
},
},
},
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1024,6 +1122,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1034,6 +1133,7 @@ describe('Task Runner', () => {
instanceId: '2',
start: '1969-12-31T06:00:00.000Z',
end: DATE_1970,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1044,6 +1144,7 @@ describe('Task Runner', () => {
duration: MOCK_DURATION,
start: DATE_1969,
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1054,6 +1155,7 @@ describe('Task Runner', () => {
actionGroupId: 'default',
instanceId: '1',
actionId: '1',
+ consumer: 'bar',
})
);
@@ -1065,6 +1167,7 @@ describe('Task Runner', () => {
actionGroupId: 'recovered',
instanceId: '2',
actionId: '2',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1075,6 +1178,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 2,
task: true,
+ consumer: 'bar',
})
);
@@ -1127,7 +1231,8 @@ describe('Task Runner', () => {
alertId,
},
},
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1204,7 +1309,8 @@ describe('Task Runner', () => {
},
},
},
- customTaskRunnerFactoryInitializerParams
+ customTaskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -1280,7 +1386,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1297,6 +1404,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1308,6 +1416,7 @@ describe('Task Runner', () => {
instanceId: '2',
start: '1969-12-31T06:00:00.000Z',
end: DATE_1970,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1318,6 +1427,7 @@ describe('Task Runner', () => {
duration: MOCK_DURATION,
start: DATE_1969,
instanceId: '1',
+ consumer: 'bar',
})
);
@@ -1329,6 +1439,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 0,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1351,7 +1462,8 @@ describe('Task Runner', () => {
spaceId: 'foo',
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
@@ -1367,7 +1479,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
@@ -1394,7 +1507,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
@@ -1423,7 +1537,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValueOnce(mockedRuleTypeSavedObject);
@@ -1458,7 +1573,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -1475,6 +1591,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1485,6 +1602,7 @@ describe('Task Runner', () => {
reason: 'execute',
task: true,
status: 'error',
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1498,7 +1616,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -1515,6 +1634,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1525,6 +1645,7 @@ describe('Task Runner', () => {
task: true,
reason: 'decrypt',
status: 'error',
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1538,7 +1659,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -1556,6 +1678,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1566,6 +1689,7 @@ describe('Task Runner', () => {
task: true,
reason: 'license',
status: 'error',
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1579,7 +1703,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -1600,6 +1725,7 @@ describe('Task Runner', () => {
task: true,
reason: 'unknown',
status: 'error',
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1613,7 +1739,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1633,6 +1760,7 @@ describe('Task Runner', () => {
task: true,
reason: 'read',
status: 'error',
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1650,7 +1778,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
legacyTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
@@ -1686,7 +1815,8 @@ describe('Task Runner', () => {
...mockedTaskInstance,
state: originalAlertSate,
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -1714,7 +1844,8 @@ describe('Task Runner', () => {
spaceId: 'foo',
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1743,7 +1874,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1765,7 +1897,8 @@ describe('Task Runner', () => {
interval: '1d',
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1789,7 +1922,8 @@ describe('Task Runner', () => {
spaceId: 'test space',
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -1835,7 +1969,8 @@ describe('Task Runner', () => {
alertInstances: {},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -1854,6 +1989,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1864,6 +2000,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1874,6 +2011,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '2',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1884,6 +2022,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1894,6 +2033,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '2',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1904,6 +2044,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 0,
task: true,
+ consumer: 'bar',
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
@@ -1952,7 +2093,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -1971,6 +2113,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1981,6 +2124,7 @@ describe('Task Runner', () => {
duration: MOCK_DURATION,
start: DATE_1969,
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -1991,6 +2135,7 @@ describe('Task Runner', () => {
duration: 64800000000000,
start: '1969-12-31T06:00:00.000Z',
instanceId: '2',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2001,6 +2146,7 @@ describe('Task Runner', () => {
status: 'active',
numberOfTriggeredActions: 0,
task: true,
+ consumer: 'bar',
})
);
@@ -2042,7 +2188,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -2060,6 +2207,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2068,6 +2216,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2075,6 +2224,7 @@ describe('Task Runner', () => {
generateEventLog({
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
+ consumer: 'bar',
instanceId: '2',
})
);
@@ -2084,6 +2234,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.execute,
outcome: 'success',
status: 'active',
+ consumer: 'bar',
numberOfTriggeredActions: 0,
task: true,
})
@@ -2121,7 +2272,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -2139,6 +2291,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2148,6 +2301,7 @@ describe('Task Runner', () => {
duration: MOCK_DURATION,
start: DATE_1969,
end: DATE_1970,
+ consumer: 'bar',
instanceId: '1',
})
);
@@ -2158,6 +2312,7 @@ describe('Task Runner', () => {
duration: 64800000000000,
start: '1969-12-31T06:00:00.000Z',
end: DATE_1970,
+ consumer: 'bar',
instanceId: '2',
})
);
@@ -2168,6 +2323,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.execute,
outcome: 'success',
status: 'ok',
+ consumer: 'bar',
numberOfTriggeredActions: 0,
task: true,
})
@@ -2207,7 +2363,8 @@ describe('Task Runner', () => {
},
},
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
@@ -2225,12 +2382,14 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
2,
generateEventLog({
action: EVENT_LOG_ACTIONS.recoveredInstance,
+ consumer: 'bar',
instanceId: '1',
})
);
@@ -2238,6 +2397,7 @@ describe('Task Runner', () => {
3,
generateEventLog({
action: EVENT_LOG_ACTIONS.recoveredInstance,
+ consumer: 'bar',
instanceId: '2',
})
);
@@ -2248,6 +2408,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.execute,
outcome: 'success',
status: 'ok',
+ consumer: 'bar',
numberOfTriggeredActions: 0,
task: true,
})
@@ -2268,7 +2429,8 @@ describe('Task Runner', () => {
{
...taskRunnerFactoryInitializerParams,
supportsEphemeralTasks: true,
- }
+ },
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -2320,6 +2482,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(
@@ -2339,7 +2502,8 @@ describe('Task Runner', () => {
...mockedTaskInstance,
state,
},
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
@@ -2357,6 +2521,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2364,6 +2529,7 @@ describe('Task Runner', () => {
generateEventLog({
errorMessage: 'Rule failed to execute because rule ran after it was disabled.',
action: EVENT_LOG_ACTIONS.execute,
+ consumer: 'bar',
outcome: 'failure',
task: true,
reason: 'disabled',
@@ -2377,7 +2543,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
@@ -2390,7 +2557,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
@@ -2415,7 +2583,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -2447,7 +2616,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT);
@@ -2458,7 +2628,6 @@ describe('Task Runner', () => {
const runnerResult = await taskRunner.run();
expect(runnerResult.monitoring?.execution.history.length).toBe(200);
});
-
test('Actions circuit breaker kicked in, should set status as warning and log a message in event log', async () => {
const ruleTypeWithConfig = {
...ruleType,
@@ -2527,7 +2696,8 @@ describe('Task Runner', () => {
const taskRunner = new TaskRunner(
ruleTypeWithConfig,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
const runnerResult = await taskRunner.run();
@@ -2568,6 +2738,7 @@ describe('Task Runner', () => {
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2578,6 +2749,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2588,6 +2760,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
+ consumer: 'bar',
})
);
@@ -2599,6 +2772,7 @@ describe('Task Runner', () => {
actionGroupId: 'default',
instanceId: '1',
actionId: '1',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2609,6 +2783,7 @@ describe('Task Runner', () => {
actionGroupId: 'default',
instanceId: '1',
actionId: '2',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2619,6 +2794,7 @@ describe('Task Runner', () => {
actionGroupId: 'default',
instanceId: '1',
actionId: '3',
+ consumer: 'bar',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@@ -2630,7 +2806,55 @@ describe('Task Runner', () => {
numberOfTriggeredActions: ruleTypeWithConfig.config.execution.actions.max,
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
task: true,
+ consumer: 'bar',
})
);
});
+
+ test('increments monitoring metrics after execution', async () => {
+ const taskRunner = new TaskRunner(
+ ruleType,
+ mockedTaskInstance,
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
+ );
+ rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject);
+ encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
+ id: '1',
+ type: 'alert',
+ attributes: {
+ apiKey: Buffer.from('123:abc').toString('base64'),
+ enabled: true,
+ },
+ references: [],
+ });
+
+ await taskRunner.run();
+ await taskRunner.run();
+ await taskRunner.run();
+
+ ruleType.executor.mockImplementation(
+ async ({
+ services: executorServices,
+ }: AlertExecutorOptions<
+ AlertTypeParams,
+ AlertTypeState,
+ AlertInstanceState,
+ AlertInstanceContext,
+ string
+ >) => {
+ throw new Error('OMG');
+ }
+ );
+ await taskRunner.run();
+ await taskRunner.cancel();
+
+ expect(inMemoryMetrics.increment).toHaveBeenCalledTimes(6);
+ expect(inMemoryMetrics.increment.mock.calls[0][0]).toBe(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.increment.mock.calls[1][0]).toBe(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.increment.mock.calls[2][0]).toBe(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.increment.mock.calls[3][0]).toBe(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ expect(inMemoryMetrics.increment.mock.calls[4][0]).toBe(IN_MEMORY_METRICS.RULE_FAILURES);
+ expect(inMemoryMetrics.increment.mock.calls[5][0]).toBe(IN_MEMORY_METRICS.RULE_TIMEOUTS);
+ });
});
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 c938869fa640c..dc7b3472a3433 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
@@ -63,6 +63,7 @@ import {
createAlertEventLogRecordObject,
Event,
} from '../lib/create_alert_event_log_record_object';
+import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring';
import {
ActionsCompletion,
AlertExecutionStore,
@@ -102,6 +103,7 @@ export class TaskRunner<
private logger: Logger;
private taskInstance: RuleTaskInstance;
private ruleName: string | null;
+ private ruleConsumer: string | null;
private ruleType: NormalizedRuleType<
Params,
ExtractedParams,
@@ -113,6 +115,7 @@ export class TaskRunner<
>;
private readonly executionId: string;
private readonly ruleTypeRegistry: RuleTypeRegistry;
+ private readonly inMemoryMetrics: InMemoryMetrics;
private usageCounter?: UsageCounter;
private searchAbortController: AbortController;
private cancelled: boolean;
@@ -128,36 +131,39 @@ export class TaskRunner<
RecoveryActionGroupId
>,
taskInstance: ConcreteTaskInstance,
- context: TaskRunnerContext
+ context: TaskRunnerContext,
+ inMemoryMetrics: InMemoryMetrics
) {
this.context = context;
this.logger = context.logger;
this.usageCounter = context.usageCounter;
this.ruleType = ruleType;
this.ruleName = null;
+ this.ruleConsumer = null;
this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance);
this.ruleTypeRegistry = context.ruleTypeRegistry;
this.searchAbortController = new AbortController();
this.cancelled = false;
this.executionId = uuid.v4();
+ this.inMemoryMetrics = inMemoryMetrics;
}
private async getDecryptedAttributes(
ruleId: string,
spaceId: string
- ): Promise<{ apiKey: string | null; enabled: boolean }> {
+ ): Promise<{ apiKey: string | null; enabled: boolean; consumer: string }> {
const namespace = this.context.spaceIdToNamespace(spaceId);
// Only fetch encrypted attributes here, we'll create a saved objects client
// scoped with the API key to fetch the remaining data.
const {
- attributes: { apiKey, enabled },
+ attributes: { apiKey, enabled, consumer },
} = await this.context.encryptedSavedObjectsClient.getDecryptedAsInternalUser(
'alert',
ruleId,
{ namespace }
);
- return { apiKey, enabled };
+ return { apiKey, enabled, consumer };
}
private getFakeKibanaRequest(spaceId: string, apiKey: RawRule['apiKey']) {
@@ -210,6 +216,7 @@ export class TaskRunner<
>({
ruleId,
ruleName,
+ ruleConsumer: this.ruleConsumer!,
tags,
executionId: this.executionId,
logger: this.logger,
@@ -245,6 +252,18 @@ export class TaskRunner<
}
}
+ private isRuleSnoozed(rule: SanitizedAlert): boolean {
+ if (rule.muteAll) {
+ return true;
+ }
+
+ if (rule.snoozeEndTime == null) {
+ return false;
+ }
+
+ return Date.now() < rule.snoozeEndTime.getTime();
+ }
+
private shouldLogAndScheduleActionsForAlerts() {
// if execution hasn't been cancelled, return true
if (!this.cancelled) {
@@ -305,7 +324,6 @@ export class TaskRunner<
schedule,
throttle,
notifyWhen,
- muteAll,
mutedInstanceIds,
name,
tags,
@@ -469,6 +487,7 @@ export class TaskRunner<
namespace,
ruleType,
rule,
+ spaceId,
});
}
@@ -477,7 +496,8 @@ export class TaskRunner<
triggeredActionsStatus: ActionsCompletion.COMPLETE,
};
- if (!muteAll && this.shouldLogAndScheduleActionsForAlerts()) {
+ const ruleIsSnoozed = this.isRuleSnoozed(rule);
+ if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) {
const mutedAlertIdsSet = new Set(mutedInstanceIds);
const alertsWithExecutableActions = Object.entries(alertsWithScheduledActions).filter(
@@ -528,8 +548,8 @@ export class TaskRunner<
alertExecutionStore,
});
} else {
- if (muteAll) {
- this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is muted.`);
+ if (ruleIsSnoozed) {
+ this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is snoozed.`);
}
if (!this.shouldLogAndScheduleActionsForAlerts()) {
this.logger.debug(
@@ -589,14 +609,18 @@ export class TaskRunner<
} = this.taskInstance;
let enabled: boolean;
let apiKey: string | null;
+ let consumer: string;
try {
const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId);
apiKey = decryptedAttributes.apiKey;
enabled = decryptedAttributes.enabled;
+ consumer = decryptedAttributes.consumer;
} catch (err) {
throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Decrypt, err);
}
+ this.ruleConsumer = consumer;
+
if (!enabled) {
throw new ErrorWithReason(
AlertExecutionStatusErrorReasons.Disabled,
@@ -658,12 +682,24 @@ export class TaskRunner<
async run(): Promise {
const {
- params: { alertId: ruleId, spaceId },
+ params: { alertId: ruleId, spaceId, consumer },
startedAt,
state: originalState,
schedule: taskSchedule,
} = this.taskInstance;
+ // Initially use consumer as stored inside the task instance
+ // Replace this with consumer as read from the rule saved object after
+ // we successfully read the rule SO. This allows us to populate a consumer
+ // value for `execute-start` events (which are written before the rule SO is read)
+ // and in the event of decryption errors (where we cannot read the rule SO)
+ // Because "consumer" is set when a rule is created, this value should be static
+ // for the life of a rule but there may be edge cases where migrations cause
+ // the consumer values to become out of sync.
+ if (consumer) {
+ this.ruleConsumer = consumer;
+ }
+
if (apm.currentTransaction) {
apm.currentTransaction.name = `Execute Alerting Rule`;
apm.currentTransaction.addLabels({
@@ -682,8 +718,10 @@ export class TaskRunner<
const event = createAlertEventLogRecordObject({
ruleId,
ruleType: this.ruleType as UntypedNormalizedRuleType,
+ consumer: this.ruleConsumer!,
action: EVENT_LOG_ACTIONS.execute,
namespace,
+ spaceId,
executionId: this.executionId,
task: {
scheduled: this.taskInstance.scheduledAt.toISOString(),
@@ -709,6 +747,7 @@ export class TaskRunner<
},
message: `rule execution start: "${ruleId}"`,
});
+
eventLogger.logEvent(startEvent);
const { state, schedule, monitoring } = await errorAsRuleTaskRunResult(
@@ -745,6 +784,10 @@ export class TaskRunner<
eventLogger.stopTiming(event);
set(event, 'kibana.alerting.status', executionStatus.status);
+ if (this.ruleConsumer) {
+ set(event, 'kibana.alert.rule.consumer', this.ruleConsumer);
+ }
+
const monitoringHistory: RuleMonitoringHistory = {
success: true,
timestamp: +new Date(),
@@ -806,6 +849,10 @@ export class TaskRunner<
eventLogger.logEvent(event);
if (!this.cancelled) {
+ this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS);
+ if (executionStatus.error) {
+ this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_FAILURES);
+ }
this.logger.debug(
`Updating rule task for ${this.ruleType.id} rule with id ${ruleId} - ${JSON.stringify(
executionStatus
@@ -876,10 +923,14 @@ export class TaskRunner<
// Write event log entry
const {
- params: { alertId: ruleId, spaceId },
+ params: { alertId: ruleId, spaceId, consumer },
} = this.taskInstance;
const namespace = this.context.spaceIdToNamespace(spaceId);
+ if (consumer && !this.ruleConsumer) {
+ this.ruleConsumer = consumer;
+ }
+
this.logger.debug(
`Cancelling rule type ${this.ruleType.id} with id ${ruleId} - execution exceeded rule type timeout of ${this.ruleType.ruleTaskTimeout}`
);
@@ -904,9 +955,11 @@ export class TaskRunner<
kibana: {
alert: {
rule: {
+ ...(this.ruleConsumer ? { consumer: this.ruleConsumer } : {}),
execution: {
uuid: this.executionId,
},
+ rule_type_id: this.ruleType.id,
},
},
saved_objects: [
@@ -918,6 +971,7 @@ export class TaskRunner<
namespace,
},
],
+ space_ids: [spaceId],
},
rule: {
id: ruleId,
@@ -929,6 +983,8 @@ export class TaskRunner<
};
eventLogger.logEvent(event);
+ this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_TIMEOUTS);
+
// Update the rule saved object with execution status
const executionStatus: AlertExecutionStatus = {
lastExecutionDate: new Date(),
@@ -1007,6 +1063,7 @@ function generateNewAndRecoveredAlertEvents<
recoveredAlerts,
rule,
ruleType,
+ spaceId,
} = params;
const originalAlertIds = Object.keys(originalAlerts);
const currentAlertIds = Object.keys(currentAlerts);
@@ -1081,9 +1138,11 @@ function generateNewAndRecoveredAlertEvents<
kibana: {
alert: {
rule: {
+ consumer: rule.consumer,
execution: {
uuid: executionId,
},
+ rule_type_id: ruleType.id,
},
},
alerting: {
@@ -1100,6 +1159,7 @@ function generateNewAndRecoveredAlertEvents<
namespace,
},
],
+ space_ids: [spaceId],
},
message,
rule: {
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 32a35ed684d7b..7a46cc001ab2d 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
@@ -36,6 +36,7 @@ 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', () => ({
v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
@@ -87,6 +88,8 @@ describe('Task Runner Cancel', () => {
taskType: 'alerting:test',
params: {
alertId: '1',
+ spaceId: 'default',
+ consumer: 'bar',
},
ownerId: null,
};
@@ -103,6 +106,7 @@ describe('Task Runner Cancel', () => {
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
const uiSettingsService = uiSettingsServiceMock.createStartContract();
const dataPlugin = dataPluginMock.createStartContract();
+ const inMemoryMetrics = inMemoryMetricsMock.create();
type TaskRunnerFactoryInitializerParamsType = jest.Mocked & {
actionsPlugin: jest.Mocked;
@@ -211,6 +215,7 @@ describe('Task Runner Cancel', () => {
attributes: {
apiKey: Buffer.from('123:abc').toString('base64'),
enabled: true,
+ consumer: 'bar',
},
references: [],
});
@@ -222,7 +227,8 @@ describe('Task Runner Cancel', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
const promise = taskRunner.run();
@@ -249,9 +255,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
saved_objects: [
@@ -262,6 +270,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
task: {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
@@ -284,9 +293,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
saved_objects: [
@@ -297,6 +308,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`,
rule: {
@@ -316,6 +328,7 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
metrics: {
number_of_searches: 3,
@@ -325,6 +338,7 @@ describe('Task Runner Cancel', () => {
},
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -338,6 +352,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
task: {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
@@ -397,10 +412,15 @@ describe('Task Runner Cancel', () => {
}
);
// setting cancelAlertsOnRuleTimeout to false here
- const taskRunner = new TaskRunner(ruleType, mockedTaskInstance, {
- ...taskRunnerFactoryInitializerParams,
- cancelAlertsOnRuleTimeout: false,
- });
+ const taskRunner = new TaskRunner(
+ ruleType,
+ mockedTaskInstance,
+ {
+ ...taskRunnerFactoryInitializerParams,
+ cancelAlertsOnRuleTimeout: false,
+ },
+ inMemoryMetrics
+ );
const promise = taskRunner.run();
await Promise.resolve();
@@ -437,7 +457,8 @@ describe('Task Runner Cancel', () => {
cancelAlertsOnRuleTimeout: false,
},
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
const promise = taskRunner.run();
@@ -467,7 +488,8 @@ describe('Task Runner Cancel', () => {
const taskRunner = new TaskRunner(
ruleType,
mockedTaskInstance,
- taskRunnerFactoryInitializerParams
+ taskRunnerFactoryInitializerParams,
+ inMemoryMetrics
);
const promise = taskRunner.run();
@@ -515,9 +537,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
task: {
@@ -532,6 +556,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: `rule execution start: \"1\"`,
rule: {
@@ -550,9 +575,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
saved_objects: [
@@ -564,6 +591,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`,
rule: {
@@ -583,6 +611,7 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
metrics: {
number_of_searches: 3,
@@ -592,6 +621,7 @@ describe('Task Runner Cancel', () => {
},
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -610,6 +640,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: "rule executed: test:1: 'rule-name'",
rule: {
@@ -664,9 +695,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
task: {
@@ -682,6 +715,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: `rule execution start: "1"`,
rule: {
@@ -700,9 +734,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
saved_objects: [
@@ -714,6 +750,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`,
rule: {
@@ -734,9 +771,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -752,6 +791,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: "test:1: 'rule-name' created new alert: '1'",
rule: {
@@ -774,9 +814,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -786,6 +828,7 @@ describe('Task Runner Cancel', () => {
saved_objects: [
{ id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' },
],
+ space_ids: ['default'],
},
message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'",
rule: {
@@ -805,9 +848,11 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -827,6 +872,7 @@ describe('Task Runner Cancel', () => {
type_id: 'action',
},
],
+ space_ids: ['default'],
},
message:
"alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1",
@@ -843,6 +889,7 @@ describe('Task Runner Cancel', () => {
kibana: {
alert: {
rule: {
+ consumer: 'bar',
execution: {
metrics: {
number_of_searches: 3,
@@ -852,6 +899,7 @@ describe('Task Runner Cancel', () => {
},
uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
},
+ rule_type_id: 'test',
},
},
alerting: {
@@ -870,6 +918,7 @@ describe('Task Runner Cancel', () => {
type_id: 'test',
},
],
+ space_ids: ['default'],
},
message: "rule executed: test:1: 'rule-name'",
rule: {
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 a5d0d35bba84c..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
@@ -25,7 +25,9 @@ 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();
const executionContext = executionContextServiceMock.createSetupContract();
const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract();
const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
@@ -108,7 +110,7 @@ describe('Task Runner Factory', () => {
test(`throws an error if factory isn't initialized`, () => {
const factory = new TaskRunnerFactory();
expect(() =>
- factory.create(ruleType, { taskInstance: mockedTaskInstance })
+ factory.create(ruleType, { taskInstance: mockedTaskInstance }, inMemoryMetrics)
).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`);
});
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 03b2a109a73a6..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
@@ -33,6 +33,7 @@ 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;
@@ -86,7 +87,8 @@ export class TaskRunnerFactory {
ActionGroupIds,
RecoveryActionGroupId
>,
- { taskInstance }: RunContext
+ { taskInstance }: RunContext,
+ inMemoryMetrics: InMemoryMetrics
) {
if (!this.isInitialized) {
throw new Error('TaskRunnerFactory not initialized');
@@ -100,6 +102,6 @@ export class TaskRunnerFactory {
InstanceContext,
ActionGroupIds,
RecoveryActionGroupId
- >(ruleType, taskInstance, this.taskRunnerContext!);
+ >(ruleType, taskInstance, this.taskRunnerContext!, inMemoryMetrics);
}
}
diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts
index 843af6b1d16d2..00878c2980a8c 100644
--- a/x-pack/plugins/alerting/server/task_runner/types.ts
+++ b/x-pack/plugins/alerting/server/task_runner/types.ts
@@ -79,6 +79,7 @@ export interface GenerateNewAndRecoveredAlertEventsParams<
string
>;
rule: SanitizedAlert;
+ spaceId: string;
}
export interface ScheduleActionsForRecoveredAlertsParams<
@@ -121,6 +122,7 @@ export interface CreateExecutionHandlerOptions<
> {
ruleId: string;
ruleName: string;
+ ruleConsumer: string;
executionId: string;
tags?: string[];
actionsPlugin: ActionsPluginStartContract;
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 4e34fc2a04f30..3bb64ad00a194 100644
--- a/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts
+++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts
@@ -151,6 +151,16 @@ Object {
'logs.alert.document.count': 1675765,
'document.test.': 17687687,
},
+ ruleTypesEsSearchDuration: {
+ '.index-threshold': 23,
+ 'logs.alert.document.count': 526,
+ 'document.test.': 534,
+ },
+ ruleTypesTotalSearchDuration: {
+ '.index-threshold': 62,
+ 'logs.alert.document.count': 588,
+ 'document.test.': 637,
+ },
},
},
failuresByReason: {
@@ -165,6 +175,12 @@ Object {
},
},
avgDuration: { value: 10 },
+ avgEsSearchDuration: {
+ value: 25.785714285714285,
+ },
+ avgTotalSearchDuration: {
+ value: 30.642857142857142,
+ },
},
hits: {
hits: [],
@@ -177,12 +193,24 @@ Object {
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(telemetry).toStrictEqual({
+ avgEsSearchDuration: 26,
+ avgEsSearchDurationByType: {
+ '__index-threshold': 12,
+ document__test__: 534,
+ logs__alert__document__count: 526,
+ },
avgExecutionTime: 0,
avgExecutionTimeByType: {
'__index-threshold': 1043934,
document__test__: 17687687,
logs__alert__document__count: 1675765,
},
+ avgTotalSearchDuration: 31,
+ avgTotalSearchDurationByType: {
+ '__index-threshold': 31,
+ document__test__: 637,
+ logs__alert__document__count: 588,
+ },
countByType: {
'__index-threshold': 2,
document__test__: 1,
diff --git a/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts
index b77083c62f000..4fbad593d1600 100644
--- a/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts
+++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts
@@ -40,12 +40,17 @@ const ruleTypeMetric = {
const ruleTypeExecutionsWithDurationMetric = {
scripted_metric: {
- init_script: 'state.ruleTypes = [:]; state.ruleTypesDuration = [:];',
+ init_script:
+ 'state.ruleTypes = [:]; state.ruleTypesDuration = [:]; state.ruleTypesEsSearchDuration = [:]; state.ruleTypesTotalSearchDuration = [:];',
map_script: `
String ruleType = doc['rule.category'].value;
long duration = doc['event.duration'].value / (1000 * 1000);
+ long esSearchDuration = doc['kibana.alert.rule.execution.metrics.es_search_duration_ms'].empty ? 0 : doc['kibana.alert.rule.execution.metrics.es_search_duration_ms'].value;
+ long totalSearchDuration = doc['kibana.alert.rule.execution.metrics.total_search_duration_ms'].empty ? 0 : doc['kibana.alert.rule.execution.metrics.total_search_duration_ms'].value;
state.ruleTypes.put(ruleType, state.ruleTypes.containsKey(ruleType) ? state.ruleTypes.get(ruleType) + 1 : 1);
state.ruleTypesDuration.put(ruleType, state.ruleTypesDuration.containsKey(ruleType) ? state.ruleTypesDuration.get(ruleType) + duration : duration);
+ state.ruleTypesEsSearchDuration.put(ruleType, state.ruleTypesEsSearchDuration.containsKey(ruleType) ? state.ruleTypesEsSearchDuration.get(ruleType) + esSearchDuration : esSearchDuration);
+ state.ruleTypesTotalSearchDuration.put(ruleType, state.ruleTypesTotalSearchDuration.containsKey(ruleType) ? state.ruleTypesTotalSearchDuration.get(ruleType) + totalSearchDuration : totalSearchDuration);
`,
// Combine script is executed per cluster, but we already have a key-value pair per cluster.
// Despite docs that say this is optional, this script can't be blank.
@@ -398,13 +403,24 @@ export async function getExecutionsPerDayCount(
byRuleTypeId: ruleTypeExecutionsWithDurationMetric,
failuresByReason: ruleTypeFailureExecutionsMetric,
avgDuration: { avg: { field: 'event.duration' } },
+ avgEsSearchDuration: {
+ avg: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' },
+ },
+ avgTotalSearchDuration: {
+ avg: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' },
+ },
},
},
});
const executionsAggregations = searchResult.aggregations as {
byRuleTypeId: {
- value: { ruleTypes: Record; ruleTypesDuration: Record };
+ value: {
+ ruleTypes: Record;
+ ruleTypesDuration: Record;
+ ruleTypesEsSearchDuration: Record;
+ ruleTypesTotalSearchDuration: Record;
+ };
};
};
@@ -414,6 +430,15 @@ export async function getExecutionsPerDayCount(
searchResult.aggregations.avgDuration.value / (1000 * 1000)
);
+ const aggsAvgEsSearchDuration = Math.round(
+ // @ts-expect-error aggegation type is not specified
+ searchResult.aggregations.avgEsSearchDuration.value
+ );
+ const aggsAvgTotalSearchDuration = Math.round(
+ // @ts-expect-error aggegation type is not specified
+ searchResult.aggregations.avgTotalSearchDuration.value
+ );
+
const executionFailuresAggregations = searchResult.aggregations as {
failuresByReason: { value: { reasons: Record> } };
};
@@ -482,6 +507,36 @@ export async function getExecutionsPerDayCount(
}),
{}
),
+ avgEsSearchDuration: aggsAvgEsSearchDuration,
+ avgEsSearchDurationByType: Object.keys(
+ executionsAggregations.byRuleTypeId.value.ruleTypes
+ ).reduce(
+ // ES DSL aggregations are returned as `any` by esClient.search
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (obj: any, key: string) => ({
+ ...obj,
+ [replaceDotSymbols(key)]: Math.round(
+ executionsAggregations.byRuleTypeId.value.ruleTypesEsSearchDuration[key] /
+ parseInt(executionsAggregations.byRuleTypeId.value.ruleTypes[key], 10)
+ ),
+ }),
+ {}
+ ),
+ avgTotalSearchDuration: aggsAvgTotalSearchDuration,
+ avgTotalSearchDurationByType: Object.keys(
+ executionsAggregations.byRuleTypeId.value.ruleTypes
+ ).reduce(
+ // ES DSL aggregations are returned as `any` by esClient.search
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (obj: any, key: string) => ({
+ ...obj,
+ [replaceDotSymbols(key)]: Math.round(
+ executionsAggregations.byRuleTypeId.value.ruleTypesTotalSearchDuration[key] /
+ parseInt(executionsAggregations.byRuleTypeId.value.ruleTypes[key], 10)
+ ),
+ }),
+ {}
+ ),
};
}
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 54e4549786381..f375e758a8c9b 100644
--- a/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts
+++ b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts
@@ -156,6 +156,10 @@ export function createAlertingUsageCollector(
count_failed_and_unrecognized_rule_tasks_by_status_by_type_per_day: {},
avg_execution_time_per_day: 0,
avg_execution_time_by_type_per_day: {},
+ avg_es_search_duration_per_day: 0,
+ avg_es_search_duration_by_type_per_day: {},
+ avg_total_search_duration_per_day: 0,
+ avg_total_search_duration_by_type_per_day: {},
};
}
},
@@ -203,6 +207,10 @@ export function createAlertingUsageCollector(
count_failed_and_unrecognized_rule_tasks_by_status_by_type_per_day: byTaskStatusSchemaByType,
avg_execution_time_per_day: { type: 'long' },
avg_execution_time_by_type_per_day: byTypeSchema,
+ avg_es_search_duration_per_day: { type: 'long' },
+ 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,
},
});
}
diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts
index 15978e9967ad2..7aee043653806 100644
--- a/x-pack/plugins/alerting/server/usage/task.ts
+++ b/x-pack/plugins/alerting/server/usage/task.ts
@@ -138,6 +138,12 @@ export function telemetryTaskRunner(
dailyFailedAndUnrecognizedTasks.countByStatusByRuleType,
avg_execution_time_per_day: dailyExecutionCounts.avgExecutionTime,
avg_execution_time_by_type_per_day: dailyExecutionCounts.avgExecutionTimeByType,
+ avg_es_search_duration_per_day: dailyExecutionCounts.avgEsSearchDuration,
+ avg_es_search_duration_by_type_per_day:
+ dailyExecutionCounts.avgEsSearchDurationByType,
+ avg_total_search_duration_per_day: dailyExecutionCounts.avgTotalSearchDuration,
+ avg_total_search_duration_by_type_per_day:
+ dailyExecutionCounts.avgTotalSearchDurationByType,
},
runAt: getNextMidnight(),
};
diff --git a/x-pack/plugins/alerting/server/usage/types.ts b/x-pack/plugins/alerting/server/usage/types.ts
index ae951f5d65942..a03483bd54007 100644
--- a/x-pack/plugins/alerting/server/usage/types.ts
+++ b/x-pack/plugins/alerting/server/usage/types.ts
@@ -27,6 +27,10 @@ export interface AlertingUsage {
>;
avg_execution_time_per_day: number;
avg_execution_time_by_type_per_day: Record;
+ avg_es_search_duration_per_day: number;
+ avg_es_search_duration_by_type_per_day: Record;
+ avg_total_search_duration_per_day: number;
+ avg_total_search_duration_by_type_per_day: Record;
throttle_time: {
min: string;
avg: string;
diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json
index a822bd776134b..357f4ca940871 100644
--- a/x-pack/plugins/alerting/tsconfig.json
+++ b/x-pack/plugins/alerting/tsconfig.json
@@ -22,6 +22,7 @@
{ "path": "../task_manager/tsconfig.json" },
{ "path": "../event_log/tsconfig.json" },
{ "path": "../encrypted_saved_objects/tsconfig.json" },
+ { "path": "../monitoring_collection/tsconfig.json" },
{ "path": "../features/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" }
diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx
index 68315fc3b2b02..bfe73e4ab0869 100644
--- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx
@@ -81,6 +81,7 @@ export function TransactionOverview() {
kuery={kuery}
start={start}
end={end}
+ saveTableOptionsToUrl
/>
>
diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx
index a4c2b84d57b35..acc231615d42f 100644
--- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx
+++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx
@@ -8,7 +8,7 @@ import * as t from 'io-ts';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Outlet } from '@kbn/typed-react-router-config';
-import { toBooleanRt } from '@kbn/io-ts-utils';
+import { toBooleanRt, toNumberRt } from '@kbn/io-ts-utils';
import { comparisonTypeRt } from '../../../../common/runtime_types/comparison_type_rt';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { environmentRt } from '../../../../common/environment_rt';
@@ -99,17 +99,27 @@ export const serviceDetail = {
},
},
children: {
- '/services/{serviceName}/overview': page({
- element: ,
- tab: 'overview',
- title: i18n.translate('xpack.apm.views.overview.title', {
- defaultMessage: 'Overview',
+ '/services/{serviceName}/overview': {
+ ...page({
+ element: ,
+ tab: 'overview',
+ title: i18n.translate('xpack.apm.views.overview.title', {
+ defaultMessage: 'Overview',
+ }),
+ searchBarOptions: {
+ showTransactionTypeSelector: true,
+ showTimeComparison: true,
+ },
}),
- searchBarOptions: {
- showTransactionTypeSelector: true,
- showTimeComparison: true,
- },
- }),
+ params: t.partial({
+ query: t.partial({
+ page: toNumberRt,
+ pageSize: toNumberRt,
+ sortField: t.string,
+ sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
+ }),
+ }),
+ },
'/services/{serviceName}/transactions': {
...page({
tab: 'transactions',
@@ -122,6 +132,14 @@ export const serviceDetail = {
showTimeComparison: true,
},
}),
+ params: t.partial({
+ query: t.partial({
+ page: toNumberRt,
+ pageSize: toNumberRt,
+ sortField: t.string,
+ sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
+ }),
+ }),
children: {
'/services/{serviceName}/transactions/view': {
element: ,
@@ -167,10 +185,10 @@ export const serviceDetail = {
}),
params: t.partial({
query: t.partial({
- sortDirection: t.string,
+ page: toNumberRt,
+ pageSize: toNumberRt,
sortField: t.string,
- pageSize: t.string,
- page: t.string,
+ sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
}),
children: {
diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx
index 054514f430a07..da7aa46cab154 100644
--- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx
+++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx
@@ -6,6 +6,7 @@
*/
import {
+ EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
@@ -23,16 +24,15 @@ import {
asTransactionRate,
} from '../../../../common/utils/formatters';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
+import {
+ ChartType,
+ getTimeSeriesColor,
+} from '../charts/helper/get_timeseries_color';
import { ImpactBar } from '../impact_bar';
import { TransactionDetailLink } from '../links/apm/transaction_detail_link';
import { ListMetric } from '../list_metric';
-import { ITableColumn } from '../managed_table';
import { TruncateWithTooltip } from '../truncate_with_tooltip';
import { getLatencyColumnLabel } from './get_latency_column_label';
-import {
- ChartType,
- getTimeSeriesColor,
-} from '../charts/helper/get_timeseries_color';
type TransactionGroupMainStatistics =
APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>;
@@ -59,7 +59,7 @@ export function getColumns({
comparisonEnabled?: boolean;
shouldShowSparkPlots?: boolean;
comparisonType?: TimeRangeComparisonType;
-}): Array> {
+}): Array> {
return [
{
field: 'name',
diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx
index 66f068f6cb05c..149e7350cc36c 100644
--- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx
@@ -5,14 +5,20 @@
* 2.0.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
+import {
+ EuiBasicTable,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTitle,
+} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { orderBy } from 'lodash';
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import uuid from 'uuid';
import { EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCode } from '@elastic/eui';
+import { useHistory } from 'react-router-dom';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
@@ -22,9 +28,9 @@ import { OverviewTableContainer } from '../overview_table_container';
import { getColumns } from './get_columns';
import { ElasticDocsLink } from '../links/elastic_docs_link';
import { useBreakpoints } from '../../../hooks/use_breakpoints';
-import { ManagedTable } from '../managed_table';
import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
import { LatencyAggregationType } from '../../../../common/latency_aggregation_types';
+import { fromQuery, toQuery } from '../links/url_helpers';
type ApiResponse =
APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>;
@@ -64,6 +70,7 @@ interface Props {
kuery: string;
start: string;
end: string;
+ saveTableOptionsToUrl?: boolean;
}
export function TransactionsTable({
@@ -77,32 +84,45 @@ export function TransactionsTable({
kuery,
start,
end,
+ saveTableOptionsToUrl = false,
}: Props) {
- const [tableOptions] = useState<{
- pageIndex: number;
- sort: {
- direction: SortDirection;
- field: SortField;
- };
+ const history = useHistory();
+
+ const {
+ query: {
+ comparisonEnabled,
+ comparisonType,
+ latencyAggregationType,
+ page: urlPage = 0,
+ pageSize: urlPageSize = numberOfTransactionsPerPage,
+ sortField: urlSortField = 'impact',
+ sortDirection: urlSortDirection = 'desc',
+ },
+ } = useAnyOfApmParams(
+ '/services/{serviceName}/transactions',
+ '/services/{serviceName}/overview'
+ );
+
+ const [tableOptions, setTableOptions] = useState<{
+ page: { index: number; size: number };
+ sort: { direction: SortDirection; field: SortField };
}>({
- pageIndex: 0,
- sort: DEFAULT_SORT,
+ page: { index: urlPage, size: urlPageSize },
+ sort: {
+ field: urlSortField as SortField,
+ direction: urlSortDirection as SortDirection,
+ },
});
// SparkPlots should be hidden if we're in two-column view and size XL (1200px)
const { isXl } = useBreakpoints();
const shouldShowSparkPlots = isSingleColumn || !isXl;
- const { pageIndex, sort } = tableOptions;
+ const { page, sort } = tableOptions;
const { direction, field } = sort;
+ const { index, size } = page;
const { transactionType, serviceName } = useApmServiceContext();
- const {
- query: { comparisonEnabled, comparisonType, latencyAggregationType },
- } = useAnyOfApmParams(
- '/services/{serviceName}/transactions',
- '/services/{serviceName}/overview'
- );
const { comparisonStart, comparisonEnd } = getTimeRangeComparison({
start,
@@ -137,10 +157,7 @@ export function TransactionsTable({
response.transactionGroups,
field,
direction
- ).slice(
- pageIndex * numberOfTransactionsPerPage,
- (pageIndex + 1) * numberOfTransactionsPerPage
- );
+ ).slice(index * size, (index + 1) * size);
return {
// Everytime the main statistics is refetched, updates the requestId making the detailed API to be refetched.
@@ -162,7 +179,8 @@ export function TransactionsTable({
end,
transactionType,
latencyAggregationType,
- pageIndex,
+ index,
+ size,
direction,
field,
// not used, but needed to trigger an update when comparisonType is changed either manually by user or when time range is changed
@@ -240,6 +258,21 @@ export function TransactionsTable({
const isLoading = status === FETCH_STATUS.LOADING;
const isNotInitiated = status === FETCH_STATUS.NOT_INITIATED;
+ const pagination = useMemo(
+ () => ({
+ pageIndex: index,
+ pageSize: size,
+ totalItemCount: transactionGroupsTotalItems,
+ showPerPageOptions,
+ }),
+ [index, size, transactionGroupsTotalItems, showPerPageOptions]
+ );
+
+ const sorting = useMemo(
+ () => ({ sort: { field, direction } }),
+ [field, direction]
+ );
+
return (
- {
+ setTableOptions({
+ page: {
+ index: newTableOptions.page?.index ?? 0,
+ size:
+ newTableOptions.page?.size ?? numberOfTransactionsPerPage,
+ },
+ sort: newTableOptions.sort
+ ? {
+ field: newTableOptions.sort.field as SortField,
+ direction: newTableOptions.sort.direction,
+ }
+ : DEFAULT_SORT,
+ });
+ if (saveTableOptionsToUrl) {
+ history.push({
+ ...history.location,
+ search: fromQuery({
+ ...toQuery(history.location.search),
+ page: newTableOptions.page?.index,
+ pageSize: newTableOptions.page?.size,
+ sortField: newTableOptions.sort?.field,
+ sortDirection: newTableOptions.sort?.direction,
+ }),
+ });
+ }
+ }}
/>
diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts
index 68e29f7afcc79..d69740c51d04d 100644
--- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts
@@ -61,6 +61,7 @@ describe('APMEventClient', () => {
apm: {
events: [],
},
+ body: { size: 0 },
});
return res.ok({ body: 'ok' });
diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts
index fdf023e197b7c..4b8f63e33799c 100644
--- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts
@@ -40,6 +40,9 @@ export type APMEventESSearchRequest = Omit & {
events: ProcessorEvent[];
includeLegacyData?: boolean;
};
+ body: {
+ size: number;
+ };
};
export type APMEventESTermsEnumRequest = Omit & {
diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts
index d3f0fca0bb259..3b17c656b06e3 100644
--- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts
@@ -14,7 +14,10 @@ describe('unpackProcessorEvents', () => {
beforeEach(() => {
const request = {
apm: { events: ['transaction', 'error'] },
- body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } },
+ body: {
+ size: 0,
+ query: { bool: { filter: [{ terms: { foo: 'bar' } }] } },
+ },
} as APMEventESSearchRequest;
const indices = {
diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts
index 6d3789837d2d9..ae47abb01942e 100644
--- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts
@@ -108,7 +108,7 @@ describe('setupRequest', () => {
const { apmEventClient } = await setupRequest(mockResources);
await apmEventClient.search('foo', {
apm: { events: [ProcessorEvent.transaction] },
- body: { foo: 'bar' },
+ body: { size: 10 },
});
expect(
@@ -117,7 +117,7 @@ describe('setupRequest', () => {
{
index: ['apm-*'],
body: {
- foo: 'bar',
+ size: 10,
query: {
bool: {
filter: [{ terms: { 'processor.event': ['transaction'] } }],
@@ -172,6 +172,7 @@ describe('with includeFrozen=false', () => {
apm: {
events: [],
},
+ body: { size: 10 },
});
const params =
@@ -193,6 +194,7 @@ describe('with includeFrozen=true', () => {
await apmEventClient.search('foo', {
apm: { events: [] },
+ body: { size: 10 },
});
const params =
diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap
index 56d735b5df115..06e80110b6f20 100644
--- a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap
+++ b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap
@@ -31,6 +31,7 @@ Object {
],
},
},
+ "size": 1,
},
"terminate_after": 1,
}
@@ -55,6 +56,7 @@ Object {
],
},
},
+ "size": 1,
},
"terminate_after": 1,
}
@@ -82,6 +84,7 @@ Array [
],
},
},
+ "size": 1,
},
"terminate_after": 1,
},
@@ -100,6 +103,7 @@ Array [
"filter": Array [],
},
},
+ "size": 0,
},
"terminate_after": 1,
},
diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts
index 12c47936374e1..a28fe1ad1ecea 100644
--- a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts
@@ -64,6 +64,7 @@ async function getHasTransactions({
events: [ProcessorEvent.transaction],
},
body: {
+ size: 0,
query: {
bool: {
filter: [
diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts
index 577a7544d93ea..573cb0a3cf6b4 100644
--- a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts
+++ b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts
@@ -33,6 +33,7 @@ export async function getHasAggregatedTransactions({
events: [ProcessorEvent.metric],
},
body: {
+ size: 1,
query: {
bool: {
filter: [
diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts
index d252fd311b4fe..5558fba4cde2a 100644
--- a/x-pack/plugins/apm/server/projections/typings.ts
+++ b/x-pack/plugins/apm/server/projections/typings.ts
@@ -11,8 +11,9 @@ import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_
export type Projection = Omit & {
body: Omit<
Required['body'],
- 'aggs' | 'aggregations'
+ 'aggs' | 'aggregations' | 'size'
> & {
+ size?: number;
aggs?: {
[key: string]: {
terms: AggregationOptionsByType['terms'] & { field: string };
diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts
index 521a846c3e1df..d8e4cf7af0bc5 100644
--- a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts
+++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts
@@ -64,7 +64,7 @@ export async function getOverallLatencyDistribution(
{
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
- body: histogramIntervalRequestBody,
+ body: { size: 0, ...histogramIntervalRequestBody },
}
)) as {
aggregations?: {
@@ -101,7 +101,7 @@ export async function getOverallLatencyDistribution(
{
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
- body: transactionDurationRangesRequestBody,
+ body: { size: 0, ...transactionDurationRangesRequestBody },
}
)) as {
aggregations?: {
diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts
index 3961b1a2ca603..c40834919f7f5 100644
--- a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts
+++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts
@@ -31,7 +31,7 @@ export async function getPercentileThresholdValue(
{
// TODO: add support for metrics
apm: { events: [ProcessorEvent.transaction] },
- body: transactionDurationPercentilesRequestBody,
+ body: { size: 0, ...transactionDurationPercentilesRequestBody },
}
)) as {
aggregations?: {
diff --git a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts
index 009d974e33721..3713b4faa73d9 100644
--- a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts
+++ b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts
@@ -141,6 +141,7 @@ function getProfilesWithStacks({
events: [ProcessorEvent.profile],
},
body: {
+ size: 0,
query: {
bool: {
filter,
diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap
index 921129cf2c1da..06011abc193c5 100644
--- a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap
+++ b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap
@@ -42,8 +42,8 @@ Object {
],
},
},
+ "size": 1,
},
- "size": 1,
"terminate_after": 1,
}
`;
@@ -61,8 +61,8 @@ Object {
"filter": Array [],
},
},
+ "size": 1,
},
- "size": 1,
"terminate_after": 1,
}
`;
diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts
index 88d2ae9f339ac..d4e21f219f372 100644
--- a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts
+++ b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts
@@ -36,8 +36,8 @@ export async function getTransaction({
apm: {
events: [ProcessorEvent.transaction as const],
},
- size: 1,
body: {
+ size: 1,
query: {
bool: {
filter: esFilters,
diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx
index 7ba216358596d..85a30d3003402 100644
--- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx
+++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx
@@ -11,6 +11,7 @@ import PropTypes from 'prop-types';
import { Shortcuts } from 'react-shortcuts';
import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+
import {
AddFromLibraryButton,
QuickButtonGroup,
diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/benchmark.ts b/x-pack/plugins/cloud_security_posture/common/schemas/benchmark.ts
new file mode 100644
index 0000000000000..b220ab92d91b7
--- /dev/null
+++ b/x-pack/plugins/cloud_security_posture/common/schemas/benchmark.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { type TypeOf, schema } from '@kbn/config-schema';
+
+export const DEFAULT_BENCHMARKS_PER_PAGE = 20;
+export const BENCHMARK_PACKAGE_POLICY_PREFIX = 'package_policy.';
+export const benchmarksInputSchema = schema.object({
+ /**
+ * The page of objects to return
+ */
+ page: schema.number({ defaultValue: 1, min: 1 }),
+ /**
+ * The number of objects to include in each page
+ */
+ per_page: schema.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }),
+ /**
+ * Once of PackagePolicy fields for sorting the found objects.
+ * Sortable fields:
+ * - package_policy.id
+ * - package_policy.name
+ * - package_policy.policy_id
+ * - package_policy.namespace
+ * - package_policy.updated_at
+ * - package_policy.updated_by
+ * - package_policy.created_at
+ * - package_policy.created_by,
+ * - package_policy.package.name
+ * - package_policy.package.title
+ * - package_policy.package.version
+ */
+ sort_field: schema.oneOf(
+ [
+ schema.literal('package_policy.id'),
+ schema.literal('package_policy.name'),
+ schema.literal('package_policy.policy_id'),
+ schema.literal('package_policy.namespace'),
+ schema.literal('package_policy.updated_at'),
+ schema.literal('package_policy.updated_by'),
+ schema.literal('package_policy.created_at'),
+ schema.literal('package_policy.created_by'),
+ schema.literal('package_policy.package.name'),
+ schema.literal('package_policy.package.title'),
+ ],
+ { defaultValue: 'package_policy.name' }
+ ),
+ /**
+ * The order to sort by
+ */
+ sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], {
+ defaultValue: 'asc',
+ }),
+ /**
+ * Benchmark filter
+ */
+ benchmark_name: schema.maybe(schema.string()),
+});
+
+export type BenchmarksQuerySchema = TypeOf;
diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts
new file mode 100644
index 0000000000000..e6c7740f87fd3
--- /dev/null
+++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { schema as rt, TypeOf } from '@kbn/config-schema';
+
+const cspRuleTemplateSchema = rt.object({
+ name: rt.string(),
+ description: rt.string(),
+ rationale: rt.string(),
+ impact: rt.string(),
+ default_value: rt.string(),
+ remediation: rt.string(),
+ benchmark: rt.object({ name: rt.string(), version: rt.string() }),
+ severity: rt.string(),
+ benchmark_rule_id: rt.string(),
+ rego_rule_id: rt.string(),
+ tags: rt.arrayOf(rt.string()),
+});
+export const cloudSecurityPostureRuleTemplateSavedObjectType = 'csp-rule-template';
+export type CloudSecurityPostureRuleTemplateSchema = TypeOf;
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx
index f2e0e4605b81c..983c58f2d5d7c 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.test.tsx
@@ -5,7 +5,8 @@
* 2.0.
*/
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import type { UseQueryResult } from 'react-query/types/react/types';
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
@@ -14,7 +15,11 @@ import { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_be
import { createReactQueryResponse } from '../../test/fixtures/react_query';
import { TestProvider } from '../../test/test_provider';
import { Benchmarks, BENCHMARKS_TABLE_DATA_TEST_SUBJ } from './benchmarks';
-import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations';
+import {
+ ADD_A_CIS_INTEGRATION,
+ BENCHMARK_INTEGRATIONS,
+ TABLE_COLUMN_HEADERS,
+} from './translations';
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
jest.mock('./use_csp_benchmark_integrations');
@@ -77,4 +82,68 @@ describe(' ', () => {
expect(screen.getByTestId(BENCHMARKS_TABLE_DATA_TEST_SUBJ)).toBeInTheDocument();
});
+
+ it('supports sorting the table by integrations', () => {
+ renderBenchmarks(
+ createReactQueryResponse({
+ status: 'success',
+ data: [createCspBenchmarkIntegrationFixture()],
+ })
+ );
+
+ // The table is sorted by integrations ascending by default, asserting that
+ const sortedHeaderAscending = screen
+ .getAllByRole('columnheader')
+ .find((element) => element.getAttribute('aria-sort') === 'ascending');
+
+ expect(sortedHeaderAscending).toBeInTheDocument();
+ expect(
+ within(sortedHeaderAscending!).getByText(TABLE_COLUMN_HEADERS.INTEGRATION)
+ ).toBeInTheDocument();
+
+ // A click should now sort it by descending
+ userEvent.click(screen.getByText(TABLE_COLUMN_HEADERS.INTEGRATION));
+
+ const sortedHeaderDescending = screen
+ .getAllByRole('columnheader')
+ .find((element) => element.getAttribute('aria-sort') === 'descending');
+ expect(sortedHeaderDescending).toBeInTheDocument();
+ expect(
+ within(sortedHeaderDescending!).getByText(TABLE_COLUMN_HEADERS.INTEGRATION)
+ ).toBeInTheDocument();
+ });
+
+ it('supports sorting the table by integration type, created by, and created at columns', () => {
+ renderBenchmarks(
+ createReactQueryResponse({
+ status: 'success',
+ data: [createCspBenchmarkIntegrationFixture()],
+ })
+ );
+
+ [
+ TABLE_COLUMN_HEADERS.INTEGRATION_TYPE,
+ TABLE_COLUMN_HEADERS.CREATED_AT,
+ TABLE_COLUMN_HEADERS.CREATED_AT,
+ ].forEach((columnHeader) => {
+ const headerTextElement = screen.getByText(columnHeader);
+ expect(headerTextElement).toBeInTheDocument();
+
+ // Click on the header element to sort the column in ascending order
+ userEvent.click(headerTextElement!);
+
+ const sortedHeaderAscending = screen
+ .getAllByRole('columnheader')
+ .find((element) => element.getAttribute('aria-sort') === 'ascending');
+ expect(within(sortedHeaderAscending!).getByText(columnHeader)).toBeInTheDocument();
+
+ // Click on the header element again to sort the column in descending order
+ userEvent.click(headerTextElement!);
+
+ const sortedHeaderDescending = screen
+ .getAllByRole('columnheader')
+ .find((element) => element.getAttribute('aria-sort') === 'descending');
+ expect(within(sortedHeaderDescending!).getByText(columnHeader)).toBeInTheDocument();
+ });
+ });
});
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx
index a86877af4112c..e7f8991eedf8f 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx
@@ -24,7 +24,10 @@ import { useCISIntegrationLink } from '../../common/navigation/use_navigate_to_c
import { CspPageTemplate } from '../../components/page_template';
import { BenchmarksTable } from './benchmarks_table';
import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations';
-import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
+import {
+ useCspBenchmarkIntegrations,
+ UseCspBenchmarkIntegrationsProps,
+} from './use_csp_benchmark_integrations';
import { extractErrorMessage } from '../../../common/utils/helpers';
import { SEARCH_PLACEHOLDER } from './translations';
@@ -118,7 +121,13 @@ const PAGE_HEADER: EuiPageHeaderProps = {
};
export const Benchmarks = () => {
- const [query, setQuery] = useState({ name: '', page: 1, perPage: 5 });
+ const [query, setQuery] = useState({
+ name: '',
+ page: 1,
+ perPage: 5,
+ sortField: 'package_policy.name',
+ sortOrder: 'asc',
+ });
const queryResult = useCspBenchmarkIntegrations(query);
@@ -129,7 +138,7 @@ export const Benchmarks = () => {
return (
setQuery((current) => ({ ...current, name }))}
/>
@@ -142,13 +151,25 @@ export const Benchmarks = () => {
benchmarks={queryResult.data?.items || []}
data-test-subj={BENCHMARKS_TABLE_DATA_TEST_SUBJ}
error={queryResult.error ? extractErrorMessage(queryResult.error) : undefined}
- loading={queryResult.isLoading}
+ loading={queryResult.isFetching}
pageIndex={query.page}
pageSize={query.perPage}
+ sorting={{
+ // @ts-expect-error - EUI types currently do not support sorting by nested fields
+ sort: { field: query.sortField, direction: query.sortOrder },
+ allowNeutralSort: false,
+ }}
totalItemCount={totalItemCount}
- setQuery={({ page }) =>
- setQuery((current) => ({ ...current, page: page.index, perPage: page.size }))
- }
+ setQuery={({ page, sort }) => {
+ setQuery((current) => ({
+ ...current,
+ page: page.index,
+ perPage: page.size,
+ sortField:
+ (sort?.field as UseCspBenchmarkIntegrationsProps['sortField']) || current.sortField,
+ sortOrder: sort?.direction || current.sortOrder,
+ }));
+ }}
noItemsMessage={
queryResult.isSuccess && !queryResult.data.total ? (
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
index 475d6c9077359..865f5169e4f31 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
@@ -22,7 +22,7 @@ import { useKibana } from '../../common/hooks/use_kibana';
import { allNavigationItems } from '../../common/navigation/constants';
interface BenchmarksTableProps
- extends Pick, 'loading' | 'error' | 'noItemsMessage'>,
+ extends Pick, 'loading' | 'error' | 'noItemsMessage' | 'sorting'>,
Pagination {
benchmarks: Benchmark[];
setQuery(pagination: CriteriaWithPagination): void;
@@ -66,12 +66,14 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [
),
truncateText: true,
+ sortable: true,
},
{
field: 'package_policy.package.title',
name: TABLE_COLUMN_HEADERS.INTEGRATION_TYPE,
dataType: 'string',
truncateText: true,
+ sortable: true,
},
{
field: 'agent_policy.name',
@@ -91,6 +93,7 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [
name: TABLE_COLUMN_HEADERS.CREATED_BY,
dataType: 'string',
truncateText: true,
+ sortable: true,
},
{
field: 'package_policy.created_at',
@@ -98,6 +101,7 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [
dataType: 'date',
truncateText: true,
render: (date: Benchmark['package_policy']['created_at']) => moment(date).fromNow(),
+ sortable: true,
},
{
field: 'package_policy.rules', // TODO: add fields
@@ -117,6 +121,7 @@ export const BenchmarksTable = ({
error,
setQuery,
noItemsMessage,
+ sorting,
...rest
}: BenchmarksTableProps) => {
const history = useHistory();
@@ -137,9 +142,8 @@ export const BenchmarksTable = ({
totalItemCount,
};
- const onChange = ({ page }: CriteriaWithPagination) => {
- if (!page) return;
- setQuery({ page: { ...page, index: page.index + 1 } });
+ const onChange = ({ page, sort }: CriteriaWithPagination) => {
+ setQuery({ page: { ...page, index: page.index + 1 }, sort });
};
return (
@@ -155,6 +159,7 @@ export const BenchmarksTable = ({
loading={loading}
noItemsMessage={noItemsMessage}
error={error}
+ sorting={sorting}
/>
);
};
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/use_csp_benchmark_integrations.ts b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/use_csp_benchmark_integrations.ts
index 0300343ade6ee..345ea32af817a 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/use_csp_benchmark_integrations.ts
+++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/use_csp_benchmark_integrations.ts
@@ -8,28 +8,39 @@
import { useQuery } from 'react-query';
import type { ListResult } from '../../../../fleet/common';
import { BENCHMARKS_ROUTE_PATH } from '../../../common/constants';
+import { BenchmarksQuerySchema } from '../../../common/schemas/benchmark';
import { useKibana } from '../../common/hooks/use_kibana';
import type { Benchmark } from '../../../common/types';
const QUERY_KEY = 'csp_benchmark_integrations';
-interface Props {
+export interface UseCspBenchmarkIntegrationsProps {
name: string;
page: number;
perPage: number;
+ sortField: BenchmarksQuerySchema['sort_field'];
+ sortOrder: BenchmarksQuerySchema['sort_order'];
}
-export const useCspBenchmarkIntegrations = ({ name, perPage, page }: Props) => {
+export const useCspBenchmarkIntegrations = ({
+ name,
+ perPage,
+ page,
+ sortField,
+ sortOrder,
+}: UseCspBenchmarkIntegrationsProps) => {
const { http } = useKibana().services;
- return useQuery([QUERY_KEY, { name, perPage, page }], () =>
- http.get>(BENCHMARKS_ROUTE_PATH, {
- query: {
- benchmark_name: name,
- per_page: perPage,
- page,
- sort_field: 'name',
- sort_order: 'asc',
- },
- })
+ const query: BenchmarksQuerySchema = {
+ benchmark_name: name,
+ per_page: perPage,
+ page,
+ sort_field: sortField,
+ sort_order: sortOrder,
+ };
+
+ return useQuery(
+ [QUERY_KEY, query],
+ () => http.get>(BENCHMARKS_ROUTE_PATH, { query }),
+ { keepPreviousData: true }
);
};
diff --git a/x-pack/plugins/cloud_security_posture/server/config.ts b/x-pack/plugins/cloud_security_posture/server/config.ts
index 9c9ff926a2c38..e40adadc55e98 100644
--- a/x-pack/plugins/cloud_security_posture/server/config.ts
+++ b/x-pack/plugins/cloud_security_posture/server/config.ts
@@ -11,7 +11,6 @@ import type { PluginConfigDescriptor } from 'kibana/server';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
});
-
type CloudSecurityPostureConfig = TypeOf;
export const config: PluginConfigDescriptor = {
diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts
index f790ac5256ff8..82f2872a859f7 100755
--- a/x-pack/plugins/cloud_security_posture/server/index.ts
+++ b/x-pack/plugins/cloud_security_posture/server/index.ts
@@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '../../../../src/core/server';
import { CspPlugin } from './plugin';
export type { CspServerPluginSetup, CspServerPluginStart } from './types';
+export type { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template';
export const plugin = (initializerContext: PluginInitializerContext) =>
new CspPlugin(initializerContext);
diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts
index 2709518ffbc5f..386eb2373ad63 100755
--- a/x-pack/plugins/cloud_security_posture/server/plugin.ts
+++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts
@@ -21,8 +21,9 @@ import type {
CspRequestHandlerContext,
} from './types';
import { defineRoutes } from './routes';
-import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type';
-import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules';
+import { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template';
+import { cspRuleAssetType } from './saved_objects/csp_rule_type';
+import { initializeCspRules } from './saved_objects/initialize_rules';
import { initializeCspTransformsIndices } from './create_indices/create_transforms_indices';
export interface CspAppContext {
@@ -55,6 +56,7 @@ export class CspPlugin
};
core.savedObjects.registerType(cspRuleAssetType);
+ core.savedObjects.registerType(cspRuleTemplateAssetType);
const router = core.http.createRouter();
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts
index f6363794213ac..384c1d49f03b7 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts
@@ -17,9 +17,11 @@ import {
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { KibanaRequest } from 'src/core/server/http/router/request';
import {
- defineGetBenchmarksRoute,
benchmarksInputSchema,
DEFAULT_BENCHMARKS_PER_PAGE,
+} from '../../../common/schemas/benchmark';
+import {
+ defineGetBenchmarksRoute,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
getPackagePolicies,
getAgentPolicies,
@@ -84,7 +86,7 @@ describe('benchmarks API', () => {
};
defineGetBenchmarksRoute(router, cspContext);
- const [config, _] = router.get.mock.calls[0];
+ const [config] = router.get.mock.calls[0];
expect(config.path).toEqual('/api/csp/benchmarks');
});
@@ -180,7 +182,7 @@ describe('benchmarks API', () => {
it('should not throw when sort_field is a string', async () => {
expect(() => {
- benchmarksInputSchema.validate({ sort_field: 'name' });
+ benchmarksInputSchema.validate({ sort_field: 'package_policy.name' });
}).not.toThrow();
});
@@ -204,7 +206,7 @@ describe('benchmarks API', () => {
it('should not throw when fields is a known string literal', async () => {
expect(() => {
- benchmarksInputSchema.validate({ sort_field: 'name' });
+ benchmarksInputSchema.validate({ sort_field: 'package_policy.name' });
}).not.toThrow();
});
@@ -240,7 +242,7 @@ describe('benchmarks API', () => {
await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', {
page: 1,
per_page: 100,
- sort_field: 'name',
+ sort_field: 'package_policy.name',
sort_order: 'desc',
});
@@ -261,7 +263,7 @@ describe('benchmarks API', () => {
await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', {
page: 1,
per_page: 100,
- sort_field: 'name',
+ sort_field: 'package_policy.name',
sort_order: 'asc',
});
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts
index 366fcd9e409e9..9b862be55988a 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts
@@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+
import { uniq, map } from 'lodash';
import type { SavedObjectsClientContract } from 'src/core/server';
-import { schema as rt, TypeOf } from '@kbn/config-schema';
import { transformError } from '@kbn/securitysolution-es-utils';
import type {
PackagePolicyServiceInterface,
@@ -20,14 +20,16 @@ import type {
ListResult,
} from '../../../../fleet/common';
import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants';
+import {
+ BENCHMARK_PACKAGE_POLICY_PREFIX,
+ benchmarksInputSchema,
+ BenchmarksQuerySchema,
+} from '../../../common/schemas/benchmark';
import { CspAppContext } from '../../plugin';
import type { Benchmark } from '../../../common/types';
import { isNonNullable } from '../../../common/utils/helpers';
import { CspRouter } from '../../types';
-type BenchmarksQuerySchema = TypeOf;
-
-export const DEFAULT_BENCHMARKS_PER_PAGE = 20;
export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies';
const getPackageNameQuery = (packageName: string, benchmarkFilter?: string): string => {
@@ -43,17 +45,21 @@ export const getPackagePolicies = (
soClient: SavedObjectsClientContract,
packagePolicyService: PackagePolicyServiceInterface,
packageName: string,
- queryParams: BenchmarksQuerySchema
+ queryParams: Partial
): Promise> => {
if (!packagePolicyService) {
throw new Error('packagePolicyService is undefined');
}
+ const sortField = queryParams.sort_field?.startsWith(BENCHMARK_PACKAGE_POLICY_PREFIX)
+ ? queryParams.sort_field.substring(BENCHMARK_PACKAGE_POLICY_PREFIX.length)
+ : queryParams.sort_field;
+
return packagePolicyService?.list(soClient, {
kuery: getPackageNameQuery(packageName, queryParams.benchmark_name),
page: queryParams.page,
perPage: queryParams.per_page,
- sortField: queryParams.sort_field,
+ sortField,
sortOrder: queryParams.sort_order,
});
};
@@ -187,44 +193,3 @@ export const defineGetBenchmarksRoute = (router: CspRouter, cspContext: CspAppCo
}
}
);
-
-export const benchmarksInputSchema = rt.object({
- /**
- * The page of objects to return
- */
- page: rt.number({ defaultValue: 1, min: 1 }),
- /**
- * The number of objects to include in each page
- */
- per_page: rt.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }),
- /**
- * Once of PackagePolicy fields for sorting the found objects.
- * Sortable fields: id, name, policy_id, namespace, updated_at, updated_by, created_at, created_by,
- * package.name, package.title, package.version
- */
- sort_field: rt.maybe(
- rt.oneOf(
- [
- rt.literal('id'),
- rt.literal('name'),
- rt.literal('policy_id'),
- rt.literal('namespace'),
- rt.literal('updated_at'),
- rt.literal('updated_by'),
- rt.literal('created_at'),
- rt.literal('created_by'),
- rt.literal('package.name'),
- rt.literal('package.title'),
- ],
- { defaultValue: 'name' }
- )
- ),
- /**
- * The order to sort by
- */
- sort_order: rt.oneOf([rt.literal('asc'), rt.literal('desc')], { defaultValue: 'desc' }),
- /**
- * Benchmark filter
- */
- benchmark_name: rt.maybe(rt.string()),
-});
diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts
new file mode 100644
index 0000000000000..e1082cc59db3f
--- /dev/null
+++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { SavedObjectsType } from '../../../../../src/core/server';
+import {
+ type CloudSecurityPostureRuleTemplateSchema,
+ cloudSecurityPostureRuleTemplateSavedObjectType,
+} from '../../common/schemas/csp_rule_template';
+
+const ruleTemplateAssetSavedObjectMappings: SavedObjectsType['mappings'] =
+ {
+ dynamic: false,
+ properties: {},
+ };
+
+export const cspRuleTemplateAssetType: SavedObjectsType = {
+ name: cloudSecurityPostureRuleTemplateSavedObjectType,
+ hidden: false,
+ management: {
+ importableAndExportable: true,
+ visibleInManagement: true,
+ },
+ namespaceType: 'agnostic',
+ mappings: ruleTemplateAssetSavedObjectMappings,
+};
diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
similarity index 90%
rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts
rename to x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
index fcff7449fb3f5..4b323c127c0e6 100644
--- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts
+++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
@@ -6,15 +6,12 @@
*/
import { i18n } from '@kbn/i18n';
-import type {
- SavedObjectsType,
- SavedObjectsValidationMap,
-} from '../../../../../../src/core/server';
+import type { SavedObjectsType, SavedObjectsValidationMap } from '../../../../../src/core/server';
import {
type CspRuleSchema,
cspRuleSchema,
cspRuleAssetSavedObjectType,
-} from '../../../common/schemas/csp_rule';
+} from '../../common/schemas/csp_rule';
const validationMap: SavedObjectsValidationMap = {
'1.0.0': cspRuleSchema,
diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
similarity index 83%
rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts
rename to x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
index 1cb08ddc1be1a..71e7697296acb 100644
--- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts
+++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
@@ -6,8 +6,8 @@
*/
import type { ISavedObjectsRepository } from 'src/core/server';
-import { CIS_BENCHMARK_1_4_1_RULES } from './rules';
-import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule';
+import { CIS_BENCHMARK_1_4_1_RULES } from './cis_1_4_1/rules';
+import { cspRuleAssetSavedObjectType } from '../../common/schemas/csp_rule';
export const initializeCspRules = async (client: ISavedObjectsRepository) => {
const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 });
diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
index abfe089e82a38..aa8c2c0e3aa00 100644
--- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
+++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
@@ -17,6 +17,7 @@ export async function getSearchStatus(
asyncId: string
): Promise> {
// TODO: Handle strategies other than the default one
+ // https://github.com/elastic/kibana/issues/127880
try {
// @ts-expect-error start_time_in_millis: EpochMillis is string | number
const apiResponse: TransportResult = await client.asyncSearch.status(
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx
index 90da5bebe6d23..7184db03b615b 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx
@@ -24,6 +24,8 @@ import { DocumentDetail } from '.';
describe('DocumentDetail', () => {
const values = {
+ isMetaEngine: false,
+ isElasticsearchEngine: false,
dataLoading: false,
fields: [],
};
@@ -98,4 +100,22 @@ describe('DocumentDetail', () => {
expect(actions.deleteDocument).toHaveBeenCalledWith('1');
});
+
+ it('hides delete button when the document is a part of a meta engine', () => {
+ setMockValues({ ...values, isMetaEngine: true });
+ const wrapper = shallow( );
+
+ expect(
+ getPageHeaderActions(wrapper).find('[data-test-subj="DeleteDocumentButton"]')
+ ).toHaveLength(0);
+ });
+
+ it('hides delete button when the document is a part of an elasticsearch-indexed engine', () => {
+ setMockValues({ ...values, isElasticsearchEngine: true });
+ const wrapper = shallow( );
+
+ expect(
+ getPageHeaderActions(wrapper).find('[data-test-subj="DeleteDocumentButton"]')
+ ).toHaveLength(0);
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx
index 175fb1239d380..f8e73c28cc24d 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx
@@ -15,7 +15,7 @@ import { i18n } from '@kbn/i18n';
import { DELETE_BUTTON_LABEL } from '../../../shared/constants';
import { useDecodedParams } from '../../utils/encode_path_params';
-import { getEngineBreadcrumbs } from '../engine';
+import { EngineLogic, getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
import { ResultFieldValue } from '../result';
@@ -32,6 +32,8 @@ const DOCUMENT_DETAIL_TITLE = (documentId: string) =>
export const DocumentDetail: React.FC = () => {
const { dataLoading, fields } = useValues(DocumentDetailLogic);
const { deleteDocument, getDocumentDetails, setFields } = useActions(DocumentDetailLogic);
+ const { isMetaEngine, isElasticsearchEngine } = useValues(EngineLogic);
+ const showDeleteButton = !isMetaEngine && !isElasticsearchEngine;
const { documentId } = useParams() as { documentId: string };
const { documentId: documentTitle } = useDecodedParams();
@@ -60,21 +62,23 @@ export const DocumentDetail: React.FC = () => {
},
];
+ const deleteButton = (
+ deleteDocument(documentId)}
+ data-test-subj="DeleteDocumentButton"
+ >
+ {DELETE_BUTTON_LABEL}
+
+ );
+
return (
deleteDocument(documentId)}
- data-test-subj="DeleteDocumentButton"
- >
- {DELETE_BUTTON_LABEL}
- ,
- ],
+ rightSideItems: showDeleteButton ? [deleteButton] : [],
}}
isLoading={dataLoading}
>
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx
index 7e1b2acc81d18..6c772fdba23d0 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx
@@ -23,6 +23,7 @@ describe('Documents', () => {
const values = {
isMetaEngine: false,
myRole: { canManageEngineDocuments: true },
+ engine: { elasticsearchIndexName: 'my-elasticsearch-index' },
};
beforeEach(() => {
@@ -66,6 +67,17 @@ describe('Documents', () => {
const wrapper = shallow( );
expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false);
});
+
+ it('does not render a DocumentCreationButton for elasticsearch engines even if the user can manage engine documents', () => {
+ setMockValues({
+ ...values,
+ myRole: { canManageEngineDocuments: true },
+ isElasticsearchEngine: true,
+ });
+
+ const wrapper = shallow( );
+ expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false);
+ });
});
describe('Meta Engines', () => {
@@ -89,4 +101,26 @@ describe('Documents', () => {
expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false);
});
});
+
+ describe('Elasticsearch indices', () => {
+ it('renders an Elasticsearch indices message if this is an Elasticsearch index', () => {
+ setMockValues({
+ ...values,
+ isElasticsearchEngine: true,
+ });
+
+ const wrapper = shallow( );
+ expect(wrapper.find('[data-test-subj="ElasticsearchEnginesCallout"]').exists()).toBe(true);
+ });
+
+ it('does not render an Elasticsearch indices message if this is not an Elasticsearch index', () => {
+ setMockValues({
+ ...values,
+ isElasticsearchEngine: false,
+ });
+
+ const wrapper = shallow( );
+ expect(wrapper.find('[data-test-subj="ElasticsearchEnginesCallout"]').exists()).toBe(false);
+ });
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx
index 6bcbe9b06391e..3ef0c192a8f10 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx
@@ -21,16 +21,22 @@ import { DOCUMENTS_TITLE } from './constants';
import { SearchExperience } from './search_experience';
export const Documents: React.FC = () => {
- const { isMetaEngine, hasNoDocuments } = useValues(EngineLogic);
+ const {
+ isMetaEngine,
+ isElasticsearchEngine,
+ hasNoDocuments,
+ engine: { elasticsearchIndexName },
+ } = useValues(EngineLogic);
const { myRole } = useValues(AppLogic);
+ const showDocumentCreationButton =
+ myRole.canManageEngineDocuments && !isMetaEngine && !isElasticsearchEngine;
return (
] : [],
+ rightSideItems: showDocumentCreationButton ? [ ] : [],
}}
isEmptyState={hasNoDocuments}
emptyState={ }
@@ -57,6 +63,32 @@ export const Documents: React.FC = () => {
>
)}
+ {isElasticsearchEngine && (
+ <>
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.documents.elasticsearchEngineCallout',
+ {
+ defaultMessage:
+ "The engine is attached to {elasticsearchIndexName}. You can modify this index's data in Kibana.",
+ values: { elasticsearchIndexName },
+ }
+ )}
+
+
+
+ >
+ )}
);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts
index 2b0a4627a64b3..2b65c627793e2 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts
@@ -53,6 +53,7 @@ describe('EngineLogic', () => {
hasNoDocuments: true,
hasEmptySchema: true,
isMetaEngine: false,
+ isElasticsearchEngine: false,
isSampleEngine: false,
hasSchemaErrors: false,
hasSchemaConflicts: false,
@@ -383,6 +384,19 @@ describe('EngineLogic', () => {
});
});
+ describe('isElasticsearchEngine', () => {
+ it('should be set based on engine.type', () => {
+ const engine = { ...mockEngineData, type: EngineTypes.elasticsearch };
+ mount({ engine });
+
+ expect(EngineLogic.values).toEqual({
+ ...DEFAULT_VALUES_WITH_ENGINE,
+ engine,
+ isElasticsearchEngine: true,
+ });
+ });
+ });
+
describe('hasSchemaErrors', () => {
it('should be set based on engine.activeReindexJob.numDocumentsWithErrors', () => {
const engine = {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts
index 0cfe8d0c2f933..c3912fa8c741e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts
@@ -22,6 +22,7 @@ interface EngineValues {
hasNoDocuments: boolean;
hasEmptySchema: boolean;
isMetaEngine: boolean;
+ isElasticsearchEngine: boolean;
isSampleEngine: boolean;
hasSchemaErrors: boolean;
hasSchemaConflicts: boolean;
@@ -100,6 +101,10 @@ export const EngineLogic = kea>({
(engine) => Object.keys(engine.schema || {}).length === 0,
],
isMetaEngine: [() => [selectors.engine], (engine) => engine?.type === EngineTypes.meta],
+ isElasticsearchEngine: [
+ () => [selectors.engine],
+ (engine) => engine?.type === EngineTypes.elasticsearch,
+ ],
isSampleEngine: [() => [selectors.engine], (engine) => !!engine?.sample],
// Indexed engines
hasSchemaErrors: [
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx
index e088678a13562..f5baf9dcc9b3d 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx
@@ -92,6 +92,13 @@ describe('useEngineNav', () => {
expect(wrapper.find(EuiBadge).prop('children')).toEqual('META ENGINE');
});
+
+ it('renders an elasticsearch index badge for elasticsearch indexed engines', () => {
+ setMockValues({ ...values, isElasticsearchEngine: true });
+ const wrapper = renderEngineLabel(useEngineNav());
+
+ expect(wrapper.find(EuiBadge).prop('children')).toEqual('ELASTICSEARCH INDEX');
+ });
});
it('returns an analytics nav item', () => {
@@ -183,6 +190,11 @@ describe('useEngineNav', () => {
setMockValues({ ...values, myRole, isMetaEngine: true });
expect(useEngineNav()).toEqual(BASE_NAV);
});
+
+ it('does not return a crawler nav item for elasticsearch engines', () => {
+ setMockValues({ ...values, myRole, isElasticsearchEngine: true });
+ expect(useEngineNav()).toEqual(BASE_NAV);
+ });
});
describe('meta engine source engines', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
index 70f2d04a5123d..76a7278df6ee6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
@@ -68,6 +68,7 @@ export const useEngineNav = () => {
dataLoading,
isSampleEngine,
isMetaEngine,
+ isElasticsearchEngine,
hasSchemaErrors,
hasSchemaConflicts,
hasUnconfirmedSchemaFields,
@@ -99,6 +100,13 @@ export const useEngineNav = () => {
})}
)}
+ {isElasticsearchEngine && (
+
+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.elasticsearchEngineBadge', {
+ defaultMessage: 'ELASTICSEARCH INDEX',
+ })}
+
+ )}
),
'data-test-subj': 'EngineLabel',
@@ -185,7 +193,8 @@ export const useEngineNav = () => {
});
}
- if (canViewEngineCrawler && !isMetaEngine) {
+ const showCrawlerNavItem = canViewEngineCrawler && !isMetaEngine && !isElasticsearchEngine;
+ if (showCrawlerNavItem) {
navItems.push({
id: 'crawler',
name: CRAWLER_TITLE,
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
index acdeed4854ecd..c9214e3c6b0b6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
@@ -45,6 +45,7 @@ export interface EngineDetails extends Engine {
unsearchedUnconfirmedFields: boolean;
apiTokens: ApiToken[];
apiKey: string;
+ elasticsearchIndexName?: string;
schema: Schema;
schemaConflicts?: SchemaConflicts;
unconfirmedFields?: string[];
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
index 54bc7fb26e9d0..21f959a39e189 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
@@ -6,6 +6,7 @@
*/
import '../../__mocks__/engine_logic.mock';
+import { setMockValues } from '../../../__mocks__/kea_logic';
import React from 'react';
@@ -22,11 +23,23 @@ import { EmptyEngineOverview } from './engine_overview_empty';
describe('EmptyEngineOverview', () => {
let wrapper: ShallowWrapper;
+ const values = {
+ isElasticsearchEngine: false,
+ engine: {
+ elasticsearchIndexName: 'my-elasticsearch-index',
+ },
+ };
beforeAll(() => {
+ setMockValues(values);
wrapper = shallow( );
});
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setMockValues(values);
+ });
+
it('renders', () => {
expect(getPageTitle(wrapper)).toEqual('Engine setup');
});
@@ -41,4 +54,11 @@ describe('EmptyEngineOverview', () => {
expect(wrapper.find(DocumentCreationButtons)).toHaveLength(1);
expect(wrapper.find(DocumentCreationFlyout)).toHaveLength(1);
});
+
+ it('renders elasticsearch index empty state', () => {
+ setMockValues({ ...values, isElasticsearchEngine: true });
+ wrapper = shallow( );
+
+ expect(wrapper.find('[data-test-subj="ElasticsearchIndexEmptyState"]')).toHaveLength(1);
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
index ada2df654d52b..da95a2ab7b0ab 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
@@ -7,16 +7,70 @@
import React from 'react';
-import { EuiButton } from '@elastic/eui';
+import { useValues } from 'kea';
+
+import { EuiButton, EuiEmptyPrompt, EuiImage, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DOCS_URL } from '../../routes';
import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation';
+import illustration from '../document_creation/illustration.svg';
-import { getEngineBreadcrumbs } from '../engine';
+import { EngineLogic, getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
export const EmptyEngineOverview: React.FC = () => {
+ const {
+ isElasticsearchEngine,
+ engine: { elasticsearchIndexName },
+ } = useValues(EngineLogic);
+
+ const elasticsearchEmptyState = (
+
+ }
+ title={
+
+ {i18n.translate('xpack.enterpriseSearch.appSearch.elasticsearchEngine.emptyStateTitle', {
+ defaultMessage: 'Add documents to your index',
+ })}
+
+ }
+ layout="horizontal"
+ hasBorder
+ color="plain"
+ body={
+ <>
+
+ {i18n.translate('xpack.enterpriseSearch.appSearch.elasticsearchEngine.helperText', {
+ defaultMessage:
+ "Your Elasticsearch index, {elasticsearchIndexName}, doesn't have any documents yet. Open Index Management in Kibana to make changes to your Elasticsearch indices.",
+ values: { elasticsearchIndexName },
+ })}
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.elasticsearchEngine.emptyStateButton',
+ {
+ defaultMessage: 'Manage indices',
+ }
+ )}
+
+ >
+ }
+ />
+ );
+
return (
{
}}
data-test-subj="EngineOverview"
>
-
-
+ {isElasticsearchEngine ? (
+ elasticsearchEmptyState
+ ) : (
+ <>
+
+
+ >
+ )}
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts
index 9f03044476263..1080f7bd0dd3a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts
@@ -104,6 +104,7 @@ describe('MetaEngineCreationLogic', () => {
describe('listeners', () => {
describe('fetchIndexedEngineNames', () => {
beforeEach(() => {
+ mount();
jest.clearAllMocks();
});
@@ -124,6 +125,22 @@ describe('MetaEngineCreationLogic', () => {
expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).toHaveBeenCalledWith(['foo']);
});
+ it('filters out elasticsearch type engines', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'setIndexedEngineNames');
+ http.get.mockReturnValueOnce(
+ Promise.resolve({
+ results: [
+ { name: 'foo', type: 'default' },
+ { name: 'elasticsearch-engine', type: 'elasticsearch' },
+ ],
+ meta: { page: { total_pages: 1 } },
+ })
+ );
+ MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
+ await nextTick();
+ expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).toHaveBeenCalledWith(['foo']);
+ });
+
it('if there are remaining pages it should call fetchIndexedEngineNames recursively with an incremented page', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'fetchIndexedEngineNames');
http.get.mockReturnValueOnce(
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts
index 5296676a38b36..82804db757ce5 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts
@@ -16,7 +16,7 @@ import { HttpLogic } from '../../../shared/http';
import { KibanaLogic } from '../../../shared/kibana';
import { ENGINE_PATH } from '../../routes';
import { formatApiName } from '../../utils/format_api_name';
-import { EngineDetails } from '../engine/types';
+import { EngineDetails, EngineTypes } from '../engine/types';
import { META_ENGINE_CREATION_SUCCESS_MESSAGE } from './constants';
@@ -100,7 +100,9 @@ export const MetaEngineCreationLogic = kea<
}
if (response) {
- const engineNames = response.results.map((result) => result.name);
+ const engineNames = response.results
+ .filter(({ type }) => type !== EngineTypes.elasticsearch)
+ .map((result) => result.name);
actions.setIndexedEngineNames([...values.indexedEngineNames, ...engineNames]);
if (page < response.meta.page.total_pages) {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx
index e903010518b10..abe3793d10569 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx
@@ -96,4 +96,13 @@ describe('RelevanceTuning', () => {
expect(buttons.children().length).toBe(0);
});
});
+
+ it('will not render the PrecisionSlider for elasticsearch engines', () => {
+ setMockValues({
+ ...values,
+ isElasticsearchEngine: true,
+ });
+
+ expect(subject().find(PrecisionSlider).exists()).toBe(false);
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx
index b36ab2f12892d..cc1647470abf6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx
@@ -15,7 +15,7 @@ import { i18n } from '@kbn/i18n';
import { SAVE_BUTTON_LABEL } from '../../../shared/constants';
import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt';
import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants';
-import { getEngineBreadcrumbs } from '../engine';
+import { EngineLogic, getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
import { EmptyState } from './components';
@@ -31,20 +31,30 @@ export const RelevanceTuning: React.FC = () => {
const { dataLoading, engineHasSchemaFields, unsavedChanges } = useValues(RelevanceTuningLogic);
const { initializeRelevanceTuning, resetSearchSettings, updateSearchSettings } =
useActions(RelevanceTuningLogic);
+ const { isElasticsearchEngine } = useValues(EngineLogic);
useEffect(() => {
initializeRelevanceTuning();
}, []);
+ const APP_SEARCH_MANAGED_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.description',
+ { defaultMessage: 'Manage precision and relevance settings for your engine' }
+ );
+
+ const ELASTICSEARCH_MANAGED_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.elasticsearch.description',
+ { defaultMessage: 'Manage relevance settings for your engine' }
+ );
+
return (
{
-
-
+ {!isElasticsearchEngine && (
+ <>
+
+
+ >
+ )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx
index 3da0fe587c523..3ce32df8e9d97 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx
@@ -26,6 +26,8 @@ import { FIELD_NAME, FIELD_TYPE } from '../../../../shared/schema/constants';
import { AppLogic } from '../../../app_logic';
+import { EngineLogic } from '../../engine';
+
import { SchemaLogic } from '../schema_logic';
export const SchemaTable: React.FC = () => {
@@ -34,6 +36,7 @@ export const SchemaTable: React.FC = () => {
} = useValues(AppLogic);
const { schema, unconfirmedFields } = useValues(SchemaLogic);
const { updateSchemaFieldType } = useActions(SchemaLogic);
+ const { isElasticsearchEngine } = useValues(EngineLogic);
return (
@@ -80,7 +83,7 @@ export const SchemaTable: React.FC = () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx
index 9d4f6fc34a8c0..02d3fd19afffe 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx
@@ -16,7 +16,7 @@ import { shallow } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { SchemaAddFieldModal } from '../../../../shared/schema';
-import { getPageHeaderActions } from '../../../../test_helpers';
+import { getPageHeaderActions, getPageTitle, getPageDescription } from '../../../../test_helpers';
import { SchemaCallouts, SchemaTable } from '../components';
@@ -129,4 +129,13 @@ describe('Schema', () => {
expect(wrapper.find(SchemaAddFieldModal)).toHaveLength(1);
});
+
+ it('renders a read-only header for elasticsearch engines', () => {
+ setMockValues({ ...values, isElasticsearchEngine: true });
+ const title = getPageTitle(shallow( ));
+ const description = getPageDescription(shallow( ));
+
+ expect(title).toBe('Engine schema');
+ expect(description).toBe('View schema field types.');
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx
index dbf7f0a695a8b..bc0a001f8bf49 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx
@@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import { SchemaAddFieldModal } from '../../../../shared/schema';
import { AppLogic } from '../../../app_logic';
-import { getEngineBreadcrumbs } from '../../engine';
+import { EngineLogic, getEngineBreadcrumbs } from '../../engine';
import { AppSearchPageTemplate } from '../../layout';
import { SchemaCallouts, SchemaTable, EmptyState } from '../components';
@@ -29,6 +29,7 @@ export const Schema: React.FC = () => {
useActions(SchemaLogic);
const { dataLoading, isUpdating, hasSchema, hasSchemaChanged, isModalOpen } =
useValues(SchemaLogic);
+ const { isElasticsearchEngine } = useValues(EngineLogic);
useEffect(() => {
loadSchema();
@@ -60,19 +61,32 @@ export const Schema: React.FC = () => {
,
];
+ const editableSchemaHeader = {
+ pageTitle: i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.pageTitle', {
+ defaultMessage: 'Manage engine schema',
+ }),
+ description: i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.pageDescription', {
+ defaultMessage: 'Add new fields or change the types of existing ones.',
+ }),
+ rightSideItems: canManageEngines ? schemaActions : [],
+ };
+
+ const readOnlySchemaHeader = {
+ pageTitle: i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.readOnly.pageTitle', {
+ defaultMessage: 'Engine schema',
+ }),
+ description: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.schema.readOnly.pageDescription',
+ {
+ defaultMessage: 'View schema field types.',
+ }
+ ),
+ };
+
return (
}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts
index eaaf43259185e..7002eb25f4668 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts
@@ -109,6 +109,24 @@ describe('SourceEnginesLogic', () => {
selectableEngineNames: ['source-engine-1', 'source-engine-2'],
});
});
+
+ it('sets indexedEngines filters out elasticsearch type engines', () => {
+ mount();
+
+ SourceEnginesLogic.actions.setIndexedEngines([
+ { name: 'source-engine-1' },
+ { name: 'source-engine-2' },
+ { name: 'source-engine-elasticsearch', type: 'elasticsearch' },
+ ] as EngineDetails[]);
+
+ expect(SourceEnginesLogic.values).toEqual({
+ ...DEFAULT_VALUES,
+ indexedEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
+ // Selectors
+ indexedEngineNames: ['source-engine-1', 'source-engine-2'],
+ selectableEngineNames: ['source-engine-1', 'source-engine-2'],
+ });
+ });
});
describe('onSourceEnginesFetch', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts
index bae87f9370a34..1f12af3f20b44 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts
@@ -11,7 +11,7 @@ import { flashAPIErrors, flashSuccessToast } from '../../../shared/flash_message
import { HttpLogic } from '../../../shared/http';
import { recursivelyFetchEngines } from '../../utils/recursively_fetch_engines';
import { EngineLogic } from '../engine';
-import { EngineDetails } from '../engine/types';
+import { EngineDetails, EngineTypes } from '../engine/types';
import { ADD_SOURCE_ENGINES_SUCCESS_MESSAGE, REMOVE_SOURCE_ENGINE_SUCCESS_MESSAGE } from './i18n';
@@ -88,7 +88,8 @@ export const SourceEnginesLogic = kea<
indexedEngines: [
[],
{
- setIndexedEngines: (_, { indexedEngines }) => indexedEngines,
+ setIndexedEngines: (_, { indexedEngines }) =>
+ indexedEngines.filter(({ type }) => type !== EngineTypes.elasticsearch),
},
],
selectedEngineNamesToAdd: [
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
similarity index 88%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
index 6a93291a28cb3..4917877c0ec30 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
@@ -5,9 +5,9 @@
* 2.0.
*/
-import '../../../../../__mocks__/shallow_useeffect.mock';
-import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
-import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
+import '../../../../../../__mocks__/shallow_useeffect.mock';
+import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic';
+import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock';
import React from 'react';
@@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui';
import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
-} from '../../../../components/layout';
-import { staticSourceData } from '../../source_data';
+} from '../../../../../components/layout';
+import { staticSourceData } from '../../../source_data';
import { ExternalConnectorConfig } from './external_connector_config';
import { ExternalConnectorFormFields } from './external_connector_form_fields';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
similarity index 81%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
index 637be68929ac0..002cafa2e3229 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
@@ -21,17 +21,20 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { AppLogic } from '../../../../app_logic';
+import { AppLogic } from '../../../../../app_logic';
import {
PersonalDashboardLayout,
WorkplaceSearchPageTemplate,
-} from '../../../../components/layout';
-import { NAV, REMOVE_BUTTON } from '../../../../constants';
-import { SourceDataItem } from '../../../../types';
+} from '../../../../../components/layout';
+import { NAV, REMOVE_BUTTON } from '../../../../../constants';
+import { SourceDataItem } from '../../../../../types';
-import { AddSourceHeader } from './add_source_header';
-import { ConfigDocsLinks } from './config_docs_links';
-import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants';
+import { staticExternalSourceData } from '../../../source_data';
+
+import { AddSourceHeader } from './../add_source_header';
+import { ConfigDocsLinks } from './../config_docs_links';
+import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants';
+import { ExternalConnectorDocumentation } from './external_connector_documentation';
import { ExternalConnectorFormFields } from './external_connector_form_fields';
import { ExternalConnectorLogic } from './external_connector_logic';
@@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC = ({
const { name, categories } = sourceConfigData;
const {
- configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl },
+ configuration: { applicationLinkTitle, applicationPortalUrl },
} = sourceData;
const { isOrganization } = useValues(AppLogic);
+ const {
+ configuration: { documentationUrl },
+ } = staticExternalSourceData;
+
const saveButton = (
{OAUTH_SAVE_CONFIG_BUTTON}
@@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({
{header}
+
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx
new file mode 100644
index 0000000000000..13b8967637ee1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import { shallow } from 'enzyme';
+
+import { EuiText } from '@elastic/eui';
+
+import { ExternalConnectorDocumentation } from './external_connector_documentation';
+
+describe('ExternalDocumentation', () => {
+ it('renders', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(EuiText)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx
new file mode 100644
index 0000000000000..437bf6f683198
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import { EuiText, EuiLink } from '@elastic/eui';
+
+import { FormattedMessage } from '@kbn/i18n-react';
+
+interface ExternalConnectorDocumentationProps {
+ name: string;
+ documentationUrl: string;
+}
+
+export const ExternalConnectorDocumentation: React.FC = ({
+ name,
+ documentationUrl,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
similarity index 95%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
index 931a2f3517fbb..45a7dd122eabf 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
@@ -5,8 +5,8 @@
* 2.0.
*/
-import '../../../../../__mocks__/shallow_useeffect.mock';
-import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
+import '../../../../../../__mocks__/shallow_useeffect.mock';
+import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic';
import React from 'react';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx
similarity index 100%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
similarity index 97%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
index 38bf74052541c..0e9ad386a353d 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
@@ -10,18 +10,19 @@ import {
mockFlashMessageHelpers,
mockHttpValues,
mockKibanaValues,
-} from '../../../../../__mocks__/kea_logic';
-import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
+} from '../../../../../../__mocks__/kea_logic';
+import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock';
import { nextTick } from '@kbn/test-jest-helpers';
-import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
+import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers';
-jest.mock('../../../../app_logic', () => ({
+jest.mock('../../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
-import { AddSourceLogic, SourceConfigData } from './add_source_logic';
+import { AddSourceLogic, SourceConfigData } from '../add_source_logic';
+
import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic';
describe('ExternalConnectorLogic', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
similarity index 94%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
index 1f7edf0d8e2a9..3bf96a31dd8c5 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
@@ -13,14 +13,14 @@ import {
flashAPIErrors,
flashSuccessToast,
clearFlashMessages,
-} from '../../../../../shared/flash_messages';
-import { HttpLogic } from '../../../../../shared/http';
-import { KibanaLogic } from '../../../../../shared/kibana';
-import { AppLogic } from '../../../../app_logic';
+} from '../../../../../../shared/flash_messages';
+import { HttpLogic } from '../../../../../../shared/http';
+import { KibanaLogic } from '../../../../../../shared/kibana';
+import { AppLogic } from '../../../../../app_logic';
-import { getAddPath, getSourcesPath } from '../../../../routes';
+import { getAddPath, getSourcesPath } from '../../../../../routes';
-import { AddSourceLogic, SourceConfigData } from './add_source_logic';
+import { AddSourceLogic, SourceConfigData } from '../add_source_logic';
export interface ExternalConnectorActions {
fetchExternalSource: () => true;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts
new file mode 100644
index 0000000000000..7f2871a9f5c75
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { ExternalConnectorConfig } from './external_connector_config';
+export { ExternalConnectorFormFields } from './external_connector_form_fields';
+export { ExternalConnectorLogic } from './external_connector_logic';
+export { ExternalConnectorDocumentation } from './external_connector_documentation';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
index 21246defbb863..6b335b1f7ffe4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
@@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { SourcesLogic } from '../../sources_logic';
+import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic';
import {
AddSourceLogic,
AddSourceSteps,
@@ -38,7 +39,6 @@ import {
AddSourceValues,
AddSourceProps,
} from './add_source_logic';
-import { ExternalConnectorLogic } from './external_connector_logic';
describe('AddSourceLogic', () => {
const { mount } = new LogicMounter(AddSourceLogic);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
index 8693cffc17e21..c621e0ee16bd5 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
@@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { SourcesLogic } from '../../sources_logic';
-import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic';
+import {
+ ExternalConnectorLogic,
+ isValidExternalUrl,
+} from './add_external_connector/external_connector_logic';
export interface AddSourceProps {
sourceData: SourceDataItem;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
index 9a5673451cd1a..8d8311d2a0a6f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
@@ -30,7 +30,7 @@ interface CardProps {
description: string;
buttonText: string;
onClick: () => void;
- betaBadgeLabel?: string;
+ badgeLabel?: string;
}
export const ConfigurationChoice: React.FC = ({
@@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({
description,
buttonText,
onClick,
- betaBadgeLabel,
+ badgeLabel,
}: CardProps) => (
{buttonText}
@@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({
title: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title',
{
- defaultMessage: 'Default connector',
+ defaultMessage: 'Connector',
}
),
description: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description',
{
- defaultMessage: 'Use our out-of-the-box connector to get started quickly.',
+ defaultMessage:
+ 'Use this connector to get started quickly without deploying additional infrastructure.',
}
),
buttonText: i18n.translate(
@@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({
defaultMessage: 'Connect',
}
),
+ badgeLabel: i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel',
+ {
+ defaultMessage: 'Recommended',
+ }
+ ),
onClick: goToInternal,
};
@@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({
title: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title',
{
- defaultMessage: 'Custom connector',
+ defaultMessage: 'Connector Package',
}
),
description: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description',
{
- defaultMessage: 'Set up a custom connector for more configurability and control.',
+ defaultMessage:
+ 'Deploy this connector package on self-managed infrastructure for advanced use cases.',
}
),
buttonText: i18n.translate(
@@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({
}
),
onClick: goToExternal,
- betaBadgeLabel: i18n.translate(
+ badgeLabel: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel',
{
defaultMessage: 'Beta',
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
index 5c234be583b9d..3e35c608fcee8 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
@@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { ApiKey } from '../../../../components/shared/api_key';
import { staticSourceData } from '../../source_data';
+import { ExternalConnectorFormFields } from './add_external_connector';
import { ConfigDocsLinks } from './config_docs_links';
-import { ExternalConnectorFormFields } from './external_connector_form_fields';
import { SaveConfig } from './save_config';
describe('SaveConfig', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
index d56efcdab95d6..eb887a9f8cc42 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
@@ -35,10 +35,11 @@ import {
} from '../../../../constants';
import { Configuration } from '../../../../types';
+import { ExternalConnectorFormFields } from './add_external_connector';
+import { ExternalConnectorDocumentation } from './add_external_connector';
import { AddSourceLogic } from './add_source_logic';
import { ConfigDocsLinks } from './config_docs_links';
import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants';
-import { ExternalConnectorFormFields } from './external_connector_form_fields';
interface SaveConfigProps {
header: React.ReactNode;
@@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({
<>
{header}
+ {serviceType === 'external' && (
+ <>
+
+
+ >
+ )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
index 361eccbe8da38..5b1e4d97ef4cd 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
@@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links';
import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants';
import { FeatureIds, SourceDataItem } from '../../types';
+export const staticExternalSourceData: SourceDataItem = {
+ name: SOURCE_NAMES.SHAREPOINT,
+ iconName: SOURCE_NAMES.SHAREPOINT,
+ serviceType: 'external',
+ configuration: {
+ isPublicKey: false,
+ hasOauthRedirect: true,
+ needsBaseUrl: false,
+ documentationUrl: docLinks.workplaceSearchExternalSharePointOnline,
+ applicationPortalUrl: 'https://portal.azure.com/',
+ },
+ objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES],
+ features: {
+ basicOrgContext: [
+ FeatureIds.SyncFrequency,
+ FeatureIds.SyncedItems,
+ FeatureIds.GlobalAccessPermissions,
+ ],
+ basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
+ platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
+ platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
+ },
+ accountContextOnly: false,
+ internalConnectorAvailable: true,
+ externalConnectorAvailable: false,
+ customConnectorAvailable: false,
+ isBeta: true,
+};
+
export const staticSourceData: SourceDataItem[] = [
{
name: SOURCE_NAMES.BOX,
@@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [
internalConnectorAvailable: true,
externalConnectorAvailable: true,
},
- // TODO: temporary hack until backend sends us stuff
- {
- name: SOURCE_NAMES.SHAREPOINT,
- iconName: SOURCE_NAMES.SHAREPOINT,
- serviceType: 'external',
- configuration: {
- isPublicKey: false,
- hasOauthRedirect: true,
- needsBaseUrl: false,
- documentationUrl: docLinks.workplaceSearchExternalSharePointOnline,
- applicationPortalUrl: 'https://portal.azure.com/',
- },
- objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES],
- features: {
- basicOrgContext: [
- FeatureIds.SyncFrequency,
- FeatureIds.SyncedItems,
- FeatureIds.GlobalAccessPermissions,
- ],
- basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
- platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
- platinumPrivateContext: [
- FeatureIds.Private,
- FeatureIds.SyncFrequency,
- FeatureIds.SyncedItems,
- ],
- },
- accountContextOnly: false,
- internalConnectorAvailable: true,
- externalConnectorAvailable: false,
- customConnectorAvailable: false,
- isBeta: true,
- },
+ staticExternalSourceData,
{
name: SOURCE_NAMES.SHAREPOINT_SERVER,
iconName: SOURCE_NAMES.SHAREPOINT_SERVER,
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
index e735119f687cc..19af955f8780c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
@@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils';
import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source';
import { AddCustomSource } from './components/add_source/add_custom_source';
+import { ExternalConnectorConfig } from './components/add_source/add_external_connector';
import { ConfigurationChoice } from './components/add_source/configuration_choice';
-import { ExternalConnectorConfig } from './components/add_source/external_connector_config';
import { OrganizationSources } from './organization_sources';
import { PrivateSources } from './private_sources';
import { staticCustomSourceData, staticSourceData as sources } from './source_data';
diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md
index 0aac72d734f04..c1d5869e7ed48 100644
--- a/x-pack/plugins/event_log/README.md
+++ b/x-pack/plugins/event_log/README.md
@@ -1,12 +1,12 @@
# Event Log
The event log plugin provides a persistent history of alerting and action
-actitivies.
+activities.
## Overview
This plugin provides a persistent log of "events" that can be used by other
-plugins to record their processing, for later accces. It is used by:
+plugins to record their processing for later access. It is used by:
- `alerting` and `actions` plugins
- [work in progress] `security_solution` (detection rules execution log)
@@ -29,6 +29,7 @@ A client API is available for other plugins to:
- register the events they want to write
- write the events, with helpers for `duration` calculation, etc
- query the events
+- aggregate the events
HTTP APIs are also available to query the events.
@@ -132,7 +133,7 @@ Below is a document in the expected structure, with descriptions of the fields:
schedule_delay: "delay in nanoseconds between when this task was supposed to start and when it actually started",
},
alerting: {
- instance_id: "alert instance id, for relevant documents",
+ instance_id: "alert id, for relevant documents",
action_group_id: "alert action group, for relevant documents",
action_subgroup: "alert action subgroup, for relevant documents",
status: "overall alert status, after rule execution",
@@ -142,9 +143,27 @@ Below is a document in the expected structure, with descriptions of the fields:
rel: "'primary' | undefined; see below",
namespace: "${spaceId} | undefined",
id: "saved object id",
- type: " saved object type",
+ type: "saved object type",
+ type_id: "rule type id if saved object type is "alert"",
},
],
+ alert: {
+ rule: {
+ rule_type_id: "rule type id",
+ consumer: "rule consumer",
+ execution: {
+ uuid: "UUID of current rule execution cycle",
+ metrics: {
+ number_of_triggered_actions: "number of actions scheduled for execution during current rule execution cycle",
+ number_of_searches: "number of ES queries issued during current rule execution cycle",
+ es_search_duration_ms: "total time spent performing ES searches as measured by Elasticsearch",
+ total_search_duration_ms: "total time spent performing ES searches as measured by Kibana; includes network latency and time spent serializing/deserializing request/response",
+ total_indexing_duration_ms: "total time spent indexing documents during current rule execution cycle",
+ execution_gap_duration_s: "duration in seconds of execution gap"
+ }
+ }
+ }
+ },
version: "7.15.0"
},
}
@@ -174,13 +193,13 @@ plugins:
For the `saved_objects` array elements, these are references to saved objects
associated with the event. For the `alerting` provider, those are rule saved
ojects and for the `actions` provider those are connector saved objects. The
-`alerts:execute-action` event includes both the rule and connector saved object
+`alerting:execute-action` event includes both the rule and connector saved object
references. For that event, only the rule reference has the optional `rel`
property with a `primary` value. This property is used when searching the
event log to indicate which saved objects should be directly searchable via
-saved object references. For the `alerts:execute-action` event, only searching
+saved object references. For the `alerting:execute-action` event, only searching
via the rule saved object reference will return the event; searching via the
-connector save object reference will **NOT** return the event. The
+connector saved object reference will **NOT** return the event. The
`actions:execute` event also includes both the rule and connector saved object
references, and both of them have the `rel` property with a `primary` value,
allowing those events to be returned in searches of either the rule or
@@ -202,7 +221,7 @@ and `index.lifecycle.*` properties.
For ad-hoc diagnostic purposes, your go to tools are Discover and Lens. Your
user will need to have access to the index, which is considered a Kibana
-system index due to it's prefix.
+system index due to its prefix.
Add the event log index as a data view. The only customization needed is
to set the `event.duration` field to a duration in nanoseconds. You'll
@@ -217,7 +236,7 @@ to target a space other than the default space.
Usage of the event log allows you to retrieve the events for a given saved object type by the specified set of IDs.
The following API is experimental and can change or be removed in a future release.
-### `GET /api/event_log/{type}/{id}/_find`: Get events for a given saved object type by the ID
+### `GET /internal/event_log/{type}/{id}/_find`: Get events for a given saved object type by the ID
Collects event information from the event log for the selected saved object by type and ID.
@@ -234,8 +253,7 @@ Query:
|---|---|---|
|page|The page number.|number|
|per_page|The number of events to return per page.|number|
-|sort_field|Sorts the response. Could be an event fields returned in the response.|string|
-|sort_order|Sort direction, either `asc` or `desc`.|string|
+|sort|Array of sort fields and order for the response. Each sort object specifies `sort_field` and `sort_order` where `sort_order` is either `asc` or `desc`.|object|
|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string|
|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string|
|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string|
@@ -244,7 +262,7 @@ Response body:
See `QueryEventsBySavedObjectResult` in the Plugin Client APIs below.
-### `POST /api/event_log/{type}/_find`: Retrive events for a given saved object type by the IDs
+### `POST /internal/event_log/{type}/_find`: Retrive events for a given saved object type by the IDs
Collects event information from the event log for the selected saved object by type and by IDs.
@@ -260,8 +278,7 @@ Query:
|---|---|---|
|page|The page number.|number|
|per_page|The number of events to return per page.|number|
-|sort_field|Sorts the response. Could be an event field returned in the response.|string|
-|sort_order|Sort direction, either `asc` or `desc`.|string|
+|sort|Array of sort fields and order for the response. Each sort object specifies `sort_field` and `sort_order` where `sort_order` is either `asc` or `desc`.|object|
|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string|
|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string|
|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string|
@@ -288,6 +305,12 @@ interface EventLogClient {
options?: Partial,
legacyIds?: string[]
): Promise;
+ aggregateEventsBySavedObjectIds(
+ type: string,
+ ids: string[],
+ options?: Partial,
+ legacyIds?: string[]
+ ): Promise;
}
interface FindOptionsType { /* typed version of HTTP query parameters ^^^ */ }
@@ -298,6 +321,17 @@ interface QueryEventsBySavedObjectResult {
total: number;
data: Event[];
}
+
+interface AggregateOptionsType {
+ start?: Date,
+ end?: Date,
+ filter?: string;
+ aggs: Record;
+}
+
+interface AggregateEventsBySavedObjectResult {
+ aggregations: Record | undefined;
+}
```
## Generating Events
@@ -409,6 +443,12 @@ export interface IEventLogClient {
options?: Partial,
legacyIds?: string[]
): Promise;
+ aggregateEventsBySavedObjectIds(
+ type: string,
+ ids: string[],
+ options?: Partial,
+ legacyIds?: string[]
+ ): Promise;
}
```
diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json
index e879cbf405365..3187423e91b29 100644
--- a/x-pack/plugins/event_log/generated/mappings.json
+++ b/x-pack/plugins/event_log/generated/mappings.json
@@ -274,6 +274,10 @@
"properties": {
"rule": {
"properties": {
+ "consumer": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
"execution": {
"properties": {
"uuid": {
@@ -310,6 +314,10 @@
}
}
}
+ },
+ "rule_type_id": {
+ "type": "keyword",
+ "ignore_above": 1024
}
}
}
diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts
index 19856d89e9931..5a26cb92c636c 100644
--- a/x-pack/plugins/event_log/generated/schemas.ts
+++ b/x-pack/plugins/event_log/generated/schemas.ts
@@ -121,6 +121,7 @@ export const EventSchema = schema.maybe(
schema.object({
rule: schema.maybe(
schema.object({
+ consumer: ecsString(),
execution: schema.maybe(
schema.object({
uuid: ecsString(),
@@ -138,6 +139,7 @@ export const EventSchema = schema.maybe(
),
})
),
+ rule_type_id: ecsString(),
})
),
})
diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js
index 7bb6a69f5ab6d..cc255c2b15719 100644
--- a/x-pack/plugins/event_log/scripts/mappings.js
+++ b/x-pack/plugins/event_log/scripts/mappings.js
@@ -56,6 +56,10 @@ exports.EcsCustomPropertyMappings = {
properties: {
rule: {
properties: {
+ consumer: {
+ type: 'keyword',
+ ignore_above: 1024,
+ },
execution: {
properties: {
uuid: {
@@ -93,6 +97,10 @@ exports.EcsCustomPropertyMappings = {
},
},
},
+ rule_type_id: {
+ type: 'keyword',
+ ignore_above: 1024,
+ },
},
},
},
diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
index 6f48b15158f8d..3a2bdc1c00faf 100644
--- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
+++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
@@ -252,7 +252,9 @@ export const item: GetInfoResponse['item'] = {
lens: [],
map: [],
security_rule: [],
+ csp_rule_template: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
ingest_pipeline: [
diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
index 6b766c2d126df..7bba58dcaac7b 100644
--- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
+++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
@@ -104,7 +104,9 @@ export const item: GetInfoResponse['item'] = {
index_pattern: [],
lens: [],
ml_module: [],
+ osquery_pack_asset: [],
security_rule: [],
+ csp_rule_template: [],
tag: [],
},
elasticsearch: {
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json
index b355a62fbf241..e9bb796626f58 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.json
+++ b/x-pack/plugins/fleet/common/openapi/bundled.json
@@ -3585,7 +3585,8 @@
"map",
"lens",
"ml-module",
- "security-rule"
+ "security-rule",
+ "csp-rule-template"
]
},
"elasticsearch_asset_type": {
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml
index 9a352f94e8252..f7941f863c120 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.yaml
+++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml
@@ -2238,6 +2238,7 @@ components:
- lens
- ml-module
- security-rule
+ - csp_rule_template
elasticsearch_asset_type:
title: Elasticsearch asset type
type: string
diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
index 4ec82e7507166..1a7d29311e4fe 100644
--- a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
+++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
@@ -9,3 +9,4 @@ enum:
- lens
- ml-module
- security-rule
+ - csp_rule_template
diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
index 0cf8c3e88f568..edffbdabc6c4e 100644
--- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
+++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
@@ -25,6 +25,7 @@ describe('Fleet - packageToPackagePolicy', () => {
path: '',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -34,6 +35,7 @@ describe('Fleet - packageToPackagePolicy', () => {
ml_module: [],
security_rule: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
ingest_pipeline: [],
diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts
index dcff9f503bfe0..060606251a6a5 100644
--- a/x-pack/plugins/fleet/common/types/models/epm.ts
+++ b/x-pack/plugins/fleet/common/types/models/epm.ts
@@ -72,8 +72,10 @@ export enum KibanaAssetType {
map = 'map',
lens = 'lens',
securityRule = 'security_rule',
+ cloudSecurityPostureRuleTemplate = 'csp_rule_template',
mlModule = 'ml_module',
tag = 'tag',
+ osqueryPackAsset = 'osquery_pack_asset',
}
/*
@@ -88,7 +90,9 @@ export enum KibanaSavedObjectType {
lens = 'lens',
mlModule = 'ml-module',
securityRule = 'security-rule',
+ cloudSecurityPostureRuleTemplate = 'csp-rule-template',
tag = 'tag',
+ osqueryPackAsset = 'osquery-pack-asset',
}
export enum ElasticsearchAssetType {
diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx
index 8b949fe8634ee..713d026726926 100644
--- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx
+++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx
@@ -28,6 +28,7 @@ export const AssetsFacetGroup = ({ width }: Args) => {
{
security_rule: [],
ml_module: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
component_template: [],
diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx
index 3d241c668e32b..3af6002e014c1 100644
--- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx
+++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx
@@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import type { ServiceName } from '../../types';
import { ElasticsearchAssetType, KibanaAssetType } from '../../types';
-// only allow Kibana assets for the kibana key, ES asssets for elasticsearch, etc
+// only allow Kibana assets for the kibana key, ES assets for elasticsearch, etc
type ServiceNameToAssetTypes = Record, KibanaAssetType[]> &
Record, ElasticsearchAssetType[]>;
@@ -62,6 +62,9 @@ export const AssetTitleMap: Record = {
security_rule: i18n.translate('xpack.fleet.epm.assetTitles.securityRules', {
defaultMessage: 'Security rules',
}),
+ osquery_pack_asset: i18n.translate('xpack.fleet.epm.assetTitles.osqueryPackAsset', {
+ defaultMessage: 'Osquery packs',
+ }),
ml_module: i18n.translate('xpack.fleet.epm.assetTitles.mlModules', {
defaultMessage: 'ML modules',
}),
@@ -74,6 +77,12 @@ export const AssetTitleMap: Record = {
tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', {
defaultMessage: 'Tag',
}),
+ csp_rule_template: i18n.translate(
+ 'xpack.fleet.epm.assetTitles.cloudSecurityPostureRuleTemplate',
+ {
+ defaultMessage: 'Cloud Security Posture rule template',
+ }
+ ),
};
export const ServiceTitleMap: Record = {
@@ -89,8 +98,10 @@ export const AssetIcons: Record = {
map: 'emsApp',
lens: 'lensApp',
security_rule: 'securityApp',
+ csp_rule_template: 'securityApp', // TODO ICON
ml_module: 'mlApp',
tag: 'tagApp',
+ osquery_pack_asset: 'osqueryApp',
};
export const ServiceIcons: Record = {
diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
index e76e44476df03..b8ef796f8817c 100644
--- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
+++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
@@ -52,7 +52,10 @@ const KibanaSavedObjectTypeMapping: Record ArchiveAsset[]> = {
@@ -250,7 +253,7 @@ export async function installKibanaSavedObjects({
/*
A reference error here means that a saved object reference in the references
array cannot be found. This is an error in the package its-self but not a fatal
- one. For example a dashboard may still refer to the legacy `metricbeat-*` index
+ one. For example a dashboard may still refer to the legacy `metricbeat-*` index
pattern. We ignore reference errors here so that legacy version of a package
can still be installed, but if a warning is logged it should be reported to
the integrations team. */
diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts
index 074484529bfb8..fbf0dbe22c51a 100644
--- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts
+++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts
@@ -58,6 +58,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -67,6 +68,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
security_rule: [],
ml_module: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
component_template: [],
@@ -170,6 +172,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -179,6 +182,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
security_rule: [],
ml_module: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
component_template: [],
@@ -262,6 +266,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -271,6 +276,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
security_rule: [],
ml_module: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
component_template: [],
@@ -386,6 +392,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -395,6 +402,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
security_rule: [],
ml_module: [],
tag: [],
+ osquery_pack_asset: [],
},
elasticsearch: {
component_template: [],
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts
index 64b8b79d4b2a1..4726286319e52 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts
@@ -5,10 +5,11 @@
* 2.0.
*/
-import sinon, { SinonFakeServer } from 'sinon';
+import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { API_BASE_PATH } from '../../../common/constants';
type HttpResponse = Record | any[];
+type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST';
export interface ResponseError {
statusCode: number;
@@ -17,139 +18,105 @@ export interface ResponseError {
}
// Register helpers to mock HTTP Requests
-const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
- const setLoadTemplatesResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadIndicesResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/indices`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setReloadIndicesResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadDataStreamsResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadDataStreamResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setDeleteDataStreamResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setDeleteTemplateResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setCreateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.body.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setUpdateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setUpdateIndexSettingsResponse = (response?: HttpResponse, error?: ResponseError) => {
- const status = error ? error.statusCode || 400 : 200;
- const body = error ?? response;
-
- server.respondWith('PUT', `${API_BASE_PATH}/settings/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
+const registerHttpRequestMockHelpers = (
+ httpSetup: ReturnType
+) => {
+ const mockResponses = new Map>>(
+ ['GET', 'PUT', 'DELETE', 'POST'].map(
+ (method) => [method, new Map()] as [HttpMethod, Map>]
+ )
+ );
+
+ const mockMethodImplementation = (method: HttpMethod, path: string) => {
+ return mockResponses.get(method)?.get(path) ?? Promise.resolve({});
+ };
+
+ httpSetup.get.mockImplementation((path) =>
+ mockMethodImplementation('GET', path as unknown as string)
+ );
+ httpSetup.delete.mockImplementation((path) =>
+ mockMethodImplementation('DELETE', path as unknown as string)
+ );
+ httpSetup.post.mockImplementation((path) =>
+ mockMethodImplementation('POST', path as unknown as string)
+ );
+ httpSetup.put.mockImplementation((path) =>
+ mockMethodImplementation('PUT', path as unknown as string)
+ );
+
+ const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => {
+ const defuse = (promise: Promise) => {
+ promise.catch(() => {});
+ return promise;
+ };
+
+ return mockResponses
+ .get(method)!
+ .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response));
+ };
+
+ const setLoadTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/index_templates`, response, error);
+
+ const setLoadIndicesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/indices`, response, error);
+
+ const setReloadIndicesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/indices/reload`, response, error);
+
+ const setLoadDataStreamsResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/data_streams`, response, error);
+
+ const setLoadDataStreamResponse = (
+ dataStreamId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) =>
+ mockResponse(
+ 'GET',
+ `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}`,
+ response,
+ error
+ );
+
+ const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error);
+
+ const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error);
+
+ const setLoadTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('GET', `${API_BASE_PATH}/index_templates/${templateId}`, response, error);
+
+ const setCreateTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/index_templates`, response, error);
+
+ const setUpdateTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('PUT', `${API_BASE_PATH}/index_templates/${templateId}`, response, error);
+
+ const setUpdateIndexSettingsResponse = (
+ indexName: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('PUT', `${API_BASE_PATH}/settings/${indexName}`, response, error);
+
+ const setSimulateTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/index_templates/simulate`, response, error);
+
+ const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error);
+
+ const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/nodes/plugins`, response, error);
+
+ const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', '/api/ui_counters/_report', response, error);
return {
setLoadTemplatesResponse,
@@ -166,22 +133,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
setSimulateTemplateResponse,
setLoadComponentTemplatesResponse,
setLoadNodesPluginsResponse,
+ setLoadTelemetryResponse,
};
};
export const init = () => {
- const server = sinon.fakeServer.create();
- server.respondImmediately = true;
-
- // Define default response for unhandled requests.
- // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
- // and we can mock them all with a 200 instead of mocking each one individually.
- server.respondWith([200, {}, 'DefaultSinonMockServerResponse']);
-
- const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
+ const httpSetup = httpServiceMock.createSetupContract();
+ const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup);
return {
- server,
+ httpSetup,
httpRequestsMockHelpers,
};
};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
index 1682431900a84..c5b077ef00333 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,11 +6,10 @@
*/
import React from 'react';
-import axios from 'axios';
-import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { merge } from 'lodash';
import SemVer from 'semver/classes/semver';
+import { HttpSetup } from 'src/core/public';
import {
notificationServiceMock,
docLinksServiceMock,
@@ -36,7 +35,6 @@ import {
import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__';
import { init as initHttpRequests } from './http_requests';
-const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const { GlobalFlyoutProvider } = GlobalFlyout;
export const services = {
@@ -64,30 +62,24 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
});
export const setupEnvironment = () => {
- // Mock initialization of services
- // @ts-ignore
- httpService.setup(mockHttpClient);
breadcrumbService.setup(() => undefined);
documentationService.setup(docLinksServiceMock.createStartContract());
notificationService.setup(notificationServiceMock.createSetupContract());
- const { server, httpRequestsMockHelpers } = initHttpRequests();
-
- return {
- server,
- httpRequestsMockHelpers,
- };
+ return initHttpRequests();
};
export const WithAppDependencies =
- (Comp: any, overridingDependencies: any = {}) =>
+ (Comp: any, httpSetup: HttpSetup, overridingDependencies: any = {}) =>
(props: any) => {
+ httpService.setup(httpSetup);
const mergedDependencies = merge({}, appDependencies, overridingDependencies);
+
return (
-
+
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
index e3295a8f4fb18..9eeab1d3ca78b 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
@@ -15,6 +15,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { DataStream } from '../../../common';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
@@ -46,7 +47,10 @@ export interface DataStreamsTabTestBed extends TestBed {
findDetailPanelIndexTemplateLink: () => ReactWrapper;
}
-export const setup = async (overridingDependencies: any = {}): Promise => {
+export const setup = async (
+ httpSetup: HttpSetup,
+ overridingDependencies: any = {}
+): Promise => {
const testBedConfig: AsyncTestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
@@ -57,7 +61,7 @@ export const setup = async (overridingDependencies: any = {}): Promise {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: DataStreamsTabTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('when there are no data streams', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
@@ -53,7 +49,7 @@ describe('Data Streams tab', () => {
});
test('displays an empty prompt', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
url: urlServiceMock,
});
@@ -69,7 +65,7 @@ describe('Data Streams tab', () => {
});
test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: {},
url: urlServiceMock,
});
@@ -89,7 +85,7 @@ describe('Data Streams tab', () => {
});
test('when Fleet is enabled, links to Fleet', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: { isFleetEnabled: true },
url: urlServiceMock,
});
@@ -112,7 +108,7 @@ describe('Data Streams tab', () => {
});
httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: {},
url: urlServiceMock,
});
@@ -156,13 +152,13 @@ describe('Data Streams tab', () => {
}),
]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' });
setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] });
- setLoadTemplateResponse(indexTemplate);
+ setLoadTemplateResponse(indexTemplate.name, indexTemplate);
- testBed = await setup({ history: createMemoryHistory() });
+ testBed = await setup(httpSetup, { history: createMemoryHistory() });
await act(async () => {
testBed.actions.goToDataStreamsList();
});
@@ -181,7 +177,6 @@ describe('Data Streams tab', () => {
test('has a button to reload the data streams', async () => {
const { exists, actions } = testBed;
- const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
@@ -189,13 +184,14 @@ describe('Data Streams tab', () => {
actions.clickReloadButton();
});
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/data_streams`,
+ expect.anything()
+ );
});
test('has a switch that will reload the data streams with additional stats when clicked', async () => {
const { exists, actions, table, component } = testBed;
- const totalRequests = server.requests.length;
expect(exists('includeStatsSwitch')).toBe(true);
@@ -205,9 +201,10 @@ describe('Data Streams tab', () => {
});
component.update();
- // A request is sent, but sinon isn't capturing the query parameters for some reason.
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/data_streams`,
+ expect.anything()
+ );
// The table renders with the stats columns though.
const { tableCellsValues } = table.getMetaData('dataStreamTable');
@@ -279,19 +276,17 @@ describe('Data Streams tab', () => {
await clickConfirmDelete();
- const { method, url, requestBody } = server.requests[server.requests.length - 1];
-
- expect(method).toBe('POST');
- expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
- expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
- dataStreams: ['dataStream1'],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_data_streams`,
+ expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) })
+ );
});
});
describe('detail panel', () => {
test('opens when the data stream name in the table is clicked', async () => {
const { actions, findDetailPanel, findDetailPanelTitle } = testBed;
+ httpRequestsMockHelpers.setLoadDataStreamResponse('dataStream1');
await actions.clickNameAt(0);
expect(findDetailPanel().length).toBe(1);
expect(findDetailPanelTitle()).toBe('dataStream1');
@@ -315,13 +310,10 @@ describe('Data Streams tab', () => {
await clickConfirmDelete();
- const { method, url, requestBody } = server.requests[server.requests.length - 1];
-
- expect(method).toBe('POST');
- expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
- expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
- dataStreams: ['dataStream1'],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_data_streams`,
+ expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) })
+ );
});
test('clicking index template name navigates to the index template details', async () => {
@@ -358,9 +350,9 @@ describe('Data Streams tab', () => {
const dataStreamPercentSign = createDataStreamPayload({ name: '%dataStream' });
setLoadDataStreamsResponse([dataStreamPercentSign]);
- setLoadDataStreamResponse(dataStreamPercentSign);
+ setLoadDataStreamResponse(dataStreamPercentSign.name, dataStreamPercentSign);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -396,10 +388,11 @@ describe('Data Streams tab', () => {
name: 'dataStream1',
ilmPolicyName: 'my_ilm_policy',
});
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -417,10 +410,11 @@ describe('Data Streams tab', () => {
const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers;
const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' });
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -442,10 +436,11 @@ describe('Data Streams tab', () => {
name: 'dataStream1',
ilmPolicyName: 'my_ilm_policy',
});
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: {
locators: {
@@ -476,9 +471,10 @@ describe('Data Streams tab', () => {
},
});
const nonManagedDataStream = createDataStreamPayload({ name: 'non-managed-data-stream' });
+
httpRequestsMockHelpers.setLoadDataStreamsResponse([managedDataStream, nonManagedDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -520,9 +516,10 @@ describe('Data Streams tab', () => {
name: 'hidden-data-stream',
hidden: true,
});
+
httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -561,7 +558,7 @@ describe('Data Streams tab', () => {
beforeEach(async () => {
setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]);
- testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock });
+ testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock });
await act(async () => {
testBed.actions.goToDataStreamsList();
});
@@ -599,7 +596,7 @@ describe('Data Streams tab', () => {
actions: { clickNameAt },
find,
} = testBed;
- setLoadDataStreamResponse(dataStreamWithDelete);
+ setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete);
await clickNameAt(1);
expect(find('deleteDataStreamButton').exists()).toBeTruthy();
@@ -610,7 +607,7 @@ describe('Data Streams tab', () => {
actions: { clickNameAt },
find,
} = testBed;
- setLoadDataStreamResponse(dataStreamNoDelete);
+ setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete);
await clickNameAt(0);
expect(find('deleteDataStreamButton').exists()).toBeFalsy();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
index 46287fcdcf074..b73985dc8372b 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
@@ -19,8 +20,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig);
-
export interface HomeTestBed extends TestBed {
actions: {
selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void;
@@ -28,7 +27,11 @@ export interface HomeTestBed extends TestBed {
};
}
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(IndexManagementHome, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
const { find } = testBed;
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
index 60d4b7d3f2317..c3f8a5b17068d 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
@@ -20,18 +20,14 @@ import { stubWebWorker } from '@kbn/test-jest-helpers';
stubWebWorker();
describe(' ', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: HomeTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
await act(async () => {
const { component } = testBed;
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
index 69dcabc287d6b..a16ba0768e675 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
@@ -13,6 +13,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateList } from '../../../public/application/sections/home/template_list';
import { TemplateDeserialized } from '../../../common';
import { WithAppDependencies, TestSubjects } from '../helpers';
@@ -25,8 +26,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig);
-
const createActions = (testBed: TestBed) => {
/**
* Additional helpers
@@ -132,7 +131,11 @@ const createActions = (testBed: TestBed) => {
};
};
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateList, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
index bf1a78e3cfe90..3d1360d620ff5 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
@@ -24,19 +24,15 @@ const removeWhiteSpaceOnArrayValues = (array: any[]) =>
});
describe('Index Templates tab', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: IndexTemplatesTabTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('when there are no index templates of either kind', () => {
test('should display an empty prompt', async () => {
httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] });
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { exists, component } = testBed;
component.update();
@@ -54,7 +50,7 @@ describe('Index Templates tab', () => {
});
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { exists, component } = testBed;
component.update();
@@ -68,7 +64,8 @@ describe('Index Templates tab', () => {
describe('when there are index templates', () => {
// Add a default loadIndexTemplate response
- httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate());
+ const templateMock = fixtures.getTemplate();
+ httpRequestsMockHelpers.setLoadTemplateResponse(templateMock.name, templateMock);
const template1 = fixtures.getTemplate({
name: `a${getRandomString()}`,
@@ -132,7 +129,7 @@ describe('Index Templates tab', () => {
httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates });
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -194,7 +191,6 @@ describe('Index Templates tab', () => {
test('should have a button to reload the index templates', async () => {
const { exists, actions } = testBed;
- const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
@@ -202,9 +198,9 @@ describe('Index Templates tab', () => {
actions.clickReloadButton();
});
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(
- `${API_BASE_PATH}/index_templates`
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.anything()
);
});
@@ -235,6 +231,7 @@ describe('Index Templates tab', () => {
const { find, exists, actions, component } = testBed;
// Composable templates
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
expect(exists('templateList')).toBe(true);
expect(exists('templateDetails')).toBe(true);
@@ -246,6 +243,7 @@ describe('Index Templates tab', () => {
});
component.update();
+ httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplates[0].name, legacyTemplates[0]);
await actions.clickTemplateAt(0, true);
expect(exists('templateList')).toBe(true);
@@ -380,13 +378,14 @@ describe('Index Templates tab', () => {
confirmButton!.click();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- expect(latestRequest.method).toBe('POST');
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
- templates: [{ name: templates[0].name, isLegacy }],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ templates: [{ name: templates[0].name, isLegacy }],
+ }),
+ })
+ );
});
});
@@ -442,16 +441,14 @@ describe('Index Templates tab', () => {
confirmButton!.click();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- expect(latestRequest.method).toBe('POST');
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
-
- // Commenting as I don't find a way to make it work.
- // It keeps on returning the composable template instead of the legacy one
- // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
- // templates: [{ name: templateName, isLegacy }],
- // });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ templates: [{ name: templates[0].name, isLegacy: false }],
+ }),
+ })
+ );
});
});
@@ -463,7 +460,7 @@ describe('Index Templates tab', () => {
isLegacy: true,
});
- httpRequestsMockHelpers.setLoadTemplateResponse(template);
+ httpRequestsMockHelpers.setLoadTemplateResponse(template.name, template);
});
test('should show details when clicking on a template', async () => {
@@ -471,6 +468,7 @@ describe('Index Templates tab', () => {
expect(exists('templateDetails')).toBe(false);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
expect(exists('templateDetails')).toBe(true);
@@ -480,6 +478,7 @@ describe('Index Templates tab', () => {
beforeEach(async () => {
const { actions } = testBed;
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
});
@@ -544,7 +543,7 @@ describe('Index Templates tab', () => {
const { find, actions, exists } = testBed;
- httpRequestsMockHelpers.setLoadTemplateResponse(template);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, template);
httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' });
await actions.clickTemplateAt(0);
@@ -598,8 +597,10 @@ describe('Index Templates tab', () => {
const { actions, find, exists } = testBed;
- httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoOptionalFields);
-
+ httpRequestsMockHelpers.setLoadTemplateResponse(
+ templates[0].name,
+ templateWithNoOptionalFields
+ );
await actions.clickTemplateAt(0);
expect(find('templateDetails.tab').length).toBe(5);
@@ -621,13 +622,12 @@ describe('Index Templates tab', () => {
it('should render an error message if error fetching template details', async () => {
const { actions, exists } = testBed;
const error = {
- status: 404,
+ statusCode: 404,
error: 'Not found',
message: 'Template not found',
};
- httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error });
-
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, undefined, error);
await actions.clickTemplateAt(0);
expect(exists('sectionError')).toBe(true);
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
index 7daa3cc9e2221..5feb7840f259c 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
@@ -14,6 +14,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
@@ -42,9 +43,12 @@ export interface IndicesTestBed extends TestBed {
findDataStreamDetailPanelTitle: () => string;
}
-export const setup = async (overridingDependencies: any = {}): Promise => {
+export const setup = async (
+ httpSetup: HttpSetup,
+ overridingDependencies: any = {}
+): Promise => {
const initTestBed = registerTestBed(
- WithAppDependencies(IndexManagementHome, overridingDependencies),
+ WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies),
testBedConfig
);
const testBed = await initTestBed();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
index 8193d48629f6f..541f2b587b69f 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
@@ -49,22 +49,20 @@ stubWebWorker();
describe(' ', () => {
let testBed: IndicesTestBed;
- let server: ReturnType['server'];
+ let httpSetup: ReturnType['httpSetup'];
let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers'];
beforeEach(() => {
- ({ server, httpRequestsMockHelpers } = setupEnvironment());
- });
-
- afterAll(() => {
- server.restore();
+ const mockEnvironment = setupEnvironment();
+ httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers;
+ httpSetup = mockEnvironment.httpSetup;
});
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
await act(async () => {
const { component } = testBed;
@@ -118,10 +116,11 @@ describe(' ', () => {
httpRequestsMockHelpers.setLoadDataStreamsResponse([]);
httpRequestsMockHelpers.setLoadDataStreamResponse(
+ 'dataStream1',
createDataStreamPayload({ name: 'dataStream1' })
);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
});
@@ -162,7 +161,7 @@ describe(' ', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find } = testBed;
component.update();
@@ -174,32 +173,36 @@ describe(' ', () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('settings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when loading mappings in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('mappings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when loading stats in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('stats');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when editing settings in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('edit_settings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`
+ );
});
});
@@ -222,7 +225,7 @@ describe(' ', () => {
]);
httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexNameA, indexNameB] });
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find } = testBed;
component.update();
@@ -236,8 +239,14 @@ describe(' ', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('refreshIndexMenuButton');
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/refresh`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/refresh`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to close an open index', async () => {
@@ -246,13 +255,20 @@ describe(' ', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('closeIndexMenuButton');
- // A refresh call was added after closing an index so we need to check the second to last request.
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`);
+ // After the index is closed, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/close`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to open a closed index', async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find, actions } = testBed;
component.update();
@@ -262,9 +278,16 @@ describe(' ', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('openIndexMenuButton');
- // A refresh call was added after closing an index so we need to check the second to last request.
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/open`);
+ // After the index is opened, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/open`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to flush index', async () => {
@@ -273,11 +296,16 @@ describe(' ', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('flushIndexMenuButton');
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`);
- // After the indices are flushed, we imediately reload them. So we need to expect to see
+ // After the index is flushed, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/flush`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test("should be able to clear an index's cache", async () => {
@@ -287,8 +315,16 @@ describe(' ', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('clearCacheIndexMenuButton');
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/clear_cache`);
+ // After the index cache is cleared, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/clear_cache`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to unfreeze a frozen index', async () => {
@@ -302,11 +338,17 @@ describe(' ', () => {
expect(exists('unfreezeIndexMenuButton')).toBe(true);
await actions.clickContextMenuOption('unfreezeIndexMenuButton');
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/unfreeze`);
// After the index is unfrozen, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/unfreeze`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
+
// Open context menu once again, since clicking an action will close it.
await actions.clickManageContextMenuButton();
// The unfreeze action should not be present anymore
@@ -326,15 +368,33 @@ describe(' ', () => {
await actions.clickModalConfirm();
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/forcemerge`);
- // After the index is force merged, we immediately do a reload. So we need to expect to see
+ // After the index force merged, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/forcemerge`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
});
describe('Edit index settings', () => {
+ const indexName = 'test';
+
+ beforeEach(async () => {
+ httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]);
+
+ testBed = await setup(httpSetup);
+ const { component, find } = testBed;
+
+ component.update();
+
+ find('indexTableIndexNameLink').at(0).simulate('click');
+ });
+
test('shows error callout when request fails', async () => {
const { actions, find, component, exists } = testBed;
@@ -347,7 +407,7 @@ describe(' ', () => {
error: 'Bad Request',
message: 'invalid tier names found in ...',
};
- httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error);
+ httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexName, undefined, error);
await actions.selectIndexDetailsTab('edit_settings');
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
index 9aec6cae7a17e..2ee82c2b4c418 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
@@ -6,10 +6,11 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateClone } from '../../../public/application/sections/template_clone';
import { WithAppDependencies } from '../helpers';
-import { formSetup } from './template_form.helpers';
+import { formSetup, TestSubjects } from './template_form.helpers';
import { TEMPLATE_NAME } from './constants';
const testBedConfig: AsyncTestBedConfig = {
@@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateClone), testBedConfig);
+export const setup = async (httpSetup: HttpSetup) => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateClone, httpSetup),
+ testBedConfig
+ );
-export const setup: any = formSetup.bind(null, initTestBed);
+ return formSetup(initTestBed);
+};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
index 31e65625cfdd0..861b1041a4f14 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
+import { API_BASE_PATH } from '../../../common/constants';
import { getComposableTemplate } from '../../../test/fixtures';
import { setupEnvironment } from '../helpers';
@@ -44,23 +45,22 @@ const templateToClone = getComposableTemplate({
describe(' ', () => {
let testBed: TemplateFormTestBed;
-
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
+ httpRequestsMockHelpers.setLoadTelemetryResponse({});
httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone);
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -98,17 +98,19 @@ describe(' ', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- ...templateToClone,
- name: `${templateToClone.name}-copy`,
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- };
-
- delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ const { priority, version, _kbnMeta } = templateToClone;
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: `${templateToClone.name}-copy`,
+ indexPatterns: DEFAULT_INDEX_PATTERNS,
+ priority,
+ version,
+ _kbnMeta,
+ }),
+ })
+ );
});
});
});
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
index b039fa83000ed..e57e89a6762c2 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
@@ -6,12 +6,13 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateCreate } from '../../../public/application/sections/template_create';
import { WithAppDependencies } from '../helpers';
import { formSetup, TestSubjects } from './template_form.helpers';
-export const setup: any = (isLegacy: boolean = false) => {
+export const setup = async (httpSetup: HttpSetup, isLegacy: boolean = false) => {
const route = isLegacy
? { pathname: '/create_template', search: '?legacy=true' }
: { pathname: '/create_template' };
@@ -25,9 +26,9 @@ export const setup: any = (isLegacy: boolean = false) => {
};
const initTestBed = registerTestBed(
- WithAppDependencies(TemplateCreate),
+ WithAppDependencies(TemplateCreate, httpSetup),
testBedConfig
);
- return formSetup.call(null, initTestBed);
+ return formSetup(initTestBed);
};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
index 65d3678735689..078a171ac6a75 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
+import { API_BASE_PATH } from '../../../common/constants';
import { setupEnvironment } from '../helpers';
import {
@@ -76,7 +77,7 @@ const componentTemplates = [componentTemplate1, componentTemplate2];
describe(' ', () => {
let testBed: TemplateFormTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
@@ -89,7 +90,6 @@ describe(' ', () => {
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
(window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false;
});
@@ -97,7 +97,7 @@ describe(' ', () => {
describe('composable index template', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
});
@@ -130,7 +130,7 @@ describe(' ', () => {
describe('legacy index template', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup(true);
+ testBed = await setup(httpSetup, true);
});
});
@@ -150,7 +150,7 @@ describe(' ', () => {
describe('form validation', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -367,7 +367,7 @@ describe(' ', () => {
httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
await navigateToMappingsStep();
@@ -415,7 +415,7 @@ describe(' ', () => {
describe('review (step 6)', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -472,7 +472,7 @@ describe(' ', () => {
it('should render a warning message if a wildcard is used as an index pattern', async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -505,7 +505,7 @@ describe(' ', () => {
const MAPPING_FIELDS = [BOOLEAN_MAPPING_FIELD, TEXT_MAPPING_FIELD, KEYWORD_MAPPING_FIELD];
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -534,49 +534,50 @@ describe(' ', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: TEMPLATE_NAME,
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- composedOf: ['test_component_template_1'],
- template: {
- settings: SETTINGS,
- mappings: {
- properties: {
- [BOOLEAN_MAPPING_FIELD.name]: {
- type: BOOLEAN_MAPPING_FIELD.type,
- },
- [TEXT_MAPPING_FIELD.name]: {
- type: TEXT_MAPPING_FIELD.type,
- },
- [KEYWORD_MAPPING_FIELD.name]: {
- type: KEYWORD_MAPPING_FIELD.type,
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: TEMPLATE_NAME,
+ indexPatterns: DEFAULT_INDEX_PATTERNS,
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: false,
+ isLegacy: false,
+ },
+ composedOf: ['test_component_template_1'],
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ [TEXT_MAPPING_FIELD.name]: {
+ type: TEXT_MAPPING_FIELD.type,
+ },
+ [KEYWORD_MAPPING_FIELD.name]: {
+ type: KEYWORD_MAPPING_FIELD.type,
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- aliases: ALIASES,
- },
- _kbnMeta: {
- type: 'default',
- isLegacy: false,
- hasDatastream: false,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ }),
+ })
+ );
});
it('should surface the API errors from the put HTTP request', async () => {
const { component, actions, find, exists } = testBed;
const error = {
- status: 409,
+ statusCode: 409,
error: 'Conflict',
message: `There is already a template with name '${TEMPLATE_NAME}'`,
};
- httpRequestsMockHelpers.setCreateTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setCreateTemplateResponse(undefined, error);
await act(async () => {
actions.clickNextButton();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
index a7f87d828eb23..97166970568d3 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateEdit } from '../../../public/application/sections/template_edit';
import { WithAppDependencies } from '../helpers';
@@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateEdit), testBedConfig);
+export const setup = async (httpSetup: HttpSetup) => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateEdit, httpSetup),
+ testBedConfig
+ );
-export const setup: any = formSetup.bind(null, initTestBed);
+ return formSetup(initTestBed);
+};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
index d4680e7663322..4b94cb92c83d0 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
@@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
import * as fixtures from '../../../test/fixtures';
+import { API_BASE_PATH } from '../../../common/constants';
import { setupEnvironment, kibanaVersion } from '../helpers';
import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants';
@@ -48,7 +49,7 @@ jest.mock('@elastic/eui', () => {
describe(' ', () => {
let testBed: TemplateFormTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
@@ -56,7 +57,6 @@ describe(' ', () => {
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
});
@@ -71,12 +71,12 @@ describe(' ', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -117,24 +117,25 @@ describe(' ', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: 'test',
- indexPatterns: ['myPattern*'],
- dataStream: {
- hidden: true,
- anyUnknownKey: 'should_be_kept',
- },
- version: 1,
- _kbnMeta: {
- type: 'default',
- isLegacy: false,
- hasDatastream: true,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/test`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: 'test',
+ indexPatterns: ['myPattern*'],
+ version: 1,
+ dataStream: {
+ hidden: true,
+ anyUnknownKey: 'should_be_kept',
+ },
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ }),
+ })
+ );
});
});
@@ -148,12 +149,12 @@ describe(' ', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -225,40 +226,40 @@ describe(' ', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
- const { version } = templateToEdit;
-
- const expected = {
- name: TEMPLATE_NAME,
- version,
- priority: 3,
- indexPatterns: UPDATED_INDEX_PATTERN,
- template: {
- mappings: {
- properties: {
- [UPDATED_MAPPING_TEXT_FIELD_NAME]: {
- type: 'text',
- store: false,
- index: true,
- fielddata: false,
- eager_global_ordinals: false,
- index_phrases: false,
- norms: true,
- index_options: 'positions',
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: TEMPLATE_NAME,
+ indexPatterns: UPDATED_INDEX_PATTERN,
+ priority: 3,
+ version: templateToEdit.version,
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: false,
+ isLegacy: templateToEdit._kbnMeta.isLegacy,
+ },
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [UPDATED_MAPPING_TEXT_FIELD_NAME]: {
+ type: 'text',
+ index: true,
+ eager_global_ordinals: false,
+ index_phrases: false,
+ norms: true,
+ fielddata: false,
+ store: false,
+ index_options: 'positions',
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- settings: SETTINGS,
- aliases: ALIASES,
- },
- _kbnMeta: {
- type: 'default',
- isLegacy: templateToEdit._kbnMeta.isLegacy,
- hasDatastream: false,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ }),
+ })
+ );
});
});
});
@@ -277,12 +278,12 @@ describe(' ', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -305,24 +306,25 @@ describe(' ', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit;
- const expected = {
- name,
- indexPatterns,
- version,
- order,
- template: {
- aliases: undefined,
- mappings: template!.mappings,
- settings: undefined,
- },
- _kbnMeta,
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name,
+ indexPatterns,
+ version,
+ order,
+ template: {
+ aliases: undefined,
+ mappings: template!.mappings,
+ settings: undefined,
+ },
+ _kbnMeta,
+ }),
+ })
+ );
});
});
}
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
index 57d0b282d351d..9a68fe41fce27 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
@@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils';
import { TestBed, SetupFunc } from '@kbn/test-jest-helpers';
import { TemplateDeserialized } from '../../../common';
-interface MappingField {
+export interface MappingField {
name: string;
type: string;
}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
index f3957e0cc15c9..81f43a1b46073 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
@@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils';
import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
import '../../../../../../test/global_mocks';
import { setupEnvironment } from './helpers';
+import { API_BASE_PATH } from './helpers/constants';
import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers';
jest.mock('@elastic/eui', () => {
@@ -34,16 +35,12 @@ jest.mock('@elastic/eui', () => {
describe(' ', () => {
let testBed: ComponentTemplateCreateTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
-
- afterAll(() => {
- server.restore();
- });
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
describe('On component mount', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -108,7 +105,7 @@ describe(' ', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { actions, component } = testBed;
@@ -164,37 +161,38 @@ describe(' ', () => {
component.update();
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: COMPONENT_TEMPLATE_NAME,
- template: {
- settings: SETTINGS,
- mappings: {
- properties: {
- [BOOLEAN_MAPPING_FIELD.name]: {
- type: BOOLEAN_MAPPING_FIELD.type,
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- aliases: ALIASES,
- },
- _kbnMeta: { usedBy: [], isManaged: false },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ _kbnMeta: { usedBy: [], isManaged: false },
+ }),
+ })
+ );
});
test('should surface API errors if the request is unsuccessful', async () => {
const { component, actions, find, exists } = testBed;
const error = {
- status: 409,
+ statusCode: 409,
error: 'Conflict',
message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`,
};
- httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, error);
await act(async () => {
actions.clickNextButton();
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
index 36ea2c27ec4fe..95495af1272c3 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
@@ -32,19 +32,18 @@ const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = {
};
describe(' ', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateDetailsTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('With component template details', () => {
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ COMPONENT_TEMPLATE
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
@@ -104,11 +103,12 @@ describe(' ', () => {
describe('With only required component template fields', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name,
COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS
);
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name,
onClose: () => {},
});
@@ -156,10 +156,13 @@ describe(' ', () => {
describe('With actions', () => {
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ COMPONENT_TEMPLATE
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
actions: [
@@ -197,16 +200,20 @@ describe(' ', () => {
describe('Error handling', () => {
const error = {
- status: 500,
+ statusCode: 500,
error: 'Internal server error',
message: 'Internal server error',
};
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ undefined,
+ error
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
index 1f4abac806276..f3b5b52fe2c41 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
@@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils';
import '../../../../../../test/global_mocks';
import { setupEnvironment } from './helpers';
+import { API_BASE_PATH } from './helpers/constants';
import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers';
jest.mock('@elastic/eui', () => {
@@ -33,11 +34,7 @@ jest.mock('@elastic/eui', () => {
describe(' ', () => {
let testBed: ComponentTemplateEditTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
-
- afterAll(() => {
- server.restore();
- });
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
const COMPONENT_TEMPLATE_NAME = 'comp-1';
const COMPONENT_TEMPLATE_TO_EDIT = {
@@ -49,10 +46,13 @@ describe(' ', () => {
};
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE_TO_EDIT.name,
+ COMPONENT_TEMPLATE_TO_EDIT
+ );
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -98,17 +98,18 @@ describe(' ', () => {
component.update();
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- version: 1,
- ...COMPONENT_TEMPLATE_TO_EDIT,
- template: {
- ...COMPONENT_TEMPLATE_TO_EDIT.template,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates/${COMPONENT_TEMPLATE_TO_EDIT.name}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ ...COMPONENT_TEMPLATE_TO_EDIT,
+ template: {
+ ...COMPONENT_TEMPLATE_TO_EDIT.template,
+ },
+ version: 1,
+ }),
+ })
+ );
});
});
});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
index dee15f2ae3a45..a3e9524dcd3ca 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
@@ -16,16 +16,12 @@ import { API_BASE_PATH } from './helpers/constants';
const { setup } = pageHelpers.componentTemplateList;
describe(' ', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateListTestBed;
- afterAll(() => {
- server.restore();
- });
-
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -69,7 +65,6 @@ describe(' ', () => {
test('should reload the component templates data', async () => {
const { component, actions } = testBed;
- const totalRequests = server.requests.length;
await act(async () => {
actions.clickReloadButton();
@@ -77,9 +72,9 @@ describe(' ', () => {
component.update();
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(
- `${API_BASE_PATH}/component_templates`
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates`,
+ expect.anything()
);
});
@@ -103,7 +98,7 @@ describe(' ', () => {
expect(modal).not.toBe(null);
expect(modal!.textContent).toContain('Delete component template');
- httpRequestsMockHelpers.setDeleteComponentTemplateResponse({
+ httpRequestsMockHelpers.setDeleteComponentTemplateResponse(componentTemplateName, {
itemsDeleted: [componentTemplateName],
errors: [],
});
@@ -114,13 +109,10 @@ describe(' ', () => {
component.update();
- const deleteRequest = server.requests[server.requests.length - 2];
-
- expect(deleteRequest.method).toBe('DELETE');
- expect(deleteRequest.url).toBe(
- `${API_BASE_PATH}/component_templates/${componentTemplateName}`
+ expect(httpSetup.delete).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates/${componentTemplateName}`,
+ expect.anything()
);
- expect(deleteRequest.status).toEqual(200);
});
});
@@ -129,7 +121,7 @@ describe(' ', () => {
httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -147,15 +139,15 @@ describe(' ', () => {
describe('Error handling', () => {
beforeEach(async () => {
const error = {
- status: 500,
+ statusCode: 500,
error: 'Internal server error',
message: 'Internal server error',
};
- httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, error);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
index 18b5bbfd775bb..846c921e776c3 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { BASE_PATH } from '../../../../../../../common';
import { ComponentTemplateCreate } from '../../../component_template_wizard';
@@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig);
-
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateCreate, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
index cdf376028ff1d..18fe2b59f21c6 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details';
@@ -43,9 +44,9 @@ const createActions = (testBed: TestBed) =
};
};
-export const setup = (props: any): ComponentTemplateDetailsTestBed => {
+export const setup = (httpSetup: HttpSetup, props: any): ComponentTemplateDetailsTestBed => {
const setupTestBed = registerTestBed(
- WithAppDependencies(ComponentTemplateDetailsFlyoutContent),
+ WithAppDependencies(ComponentTemplateDetailsFlyoutContent, httpSetup),
{
memoryRouter: {
wrapComponent: false,
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
index 6e0f9d55ef7f0..dfc73e0ccafb0 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { BASE_PATH } from '../../../../../../../common';
import { ComponentTemplateEdit } from '../../../component_template_wizard';
@@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig);
-
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateEdit, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
index 2a01518e25466..3005eae0d6bf1 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
@@ -6,6 +6,7 @@
*/
import { act } from 'react-dom/test-utils';
+import { HttpSetup } from 'src/core/public';
import {
registerTestBed,
@@ -26,8 +27,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig);
-
export type ComponentTemplateListTestBed = TestBed & {
actions: ReturnType;
};
@@ -74,7 +73,11 @@ const createActions = (testBed: TestBed) => {
};
};
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateList, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
index 520da90c58862..025f34066908c 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
@@ -5,65 +5,74 @@
* 2.0.
*/
-import sinon, { SinonFakeServer } from 'sinon';
-import {
- ComponentTemplateListItem,
- ComponentTemplateDeserialized,
- ComponentTemplateSerialized,
-} from '../../../shared_imports';
+import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks';
import { API_BASE_PATH } from './constants';
+type HttpResponse = Record | any[];
+type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST';
+
+export interface ResponseError {
+ statusCode: number;
+ message: string | Error;
+ attributes?: Record;
+}
+
// Register helpers to mock HTTP Requests
-const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
- const setLoadComponentTemplatesResponse = (
- response?: ComponentTemplateListItem[],
- error?: any
- ) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
+const registerHttpRequestMockHelpers = (
+ httpSetup: ReturnType
+) => {
+ const mockResponses = new Map>>(
+ ['GET', 'PUT', 'DELETE', 'POST'].map(
+ (method) => [method, new Map()] as [HttpMethod, Map>]
+ )
+ );
- server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
+ const mockMethodImplementation = (method: HttpMethod, path: string) => {
+ return mockResponses.get(method)?.get(path) ?? Promise.resolve({});
};
- const setLoadComponentTemplateResponse = (
- response?: ComponentTemplateDeserialized,
- error?: any
- ) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
+ httpSetup.get.mockImplementation((path) =>
+ mockMethodImplementation('GET', path as unknown as string)
+ );
+ httpSetup.delete.mockImplementation((path) =>
+ mockMethodImplementation('DELETE', path as unknown as string)
+ );
+ httpSetup.post.mockImplementation((path) =>
+ mockMethodImplementation('POST', path as unknown as string)
+ );
+ httpSetup.put.mockImplementation((path) =>
+ mockMethodImplementation('PUT', path as unknown as string)
+ );
- server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
+ const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => {
+ const defuse = (promise: Promise) => {
+ promise.catch(() => {});
+ return promise;
+ };
- const setDeleteComponentTemplateResponse = (response?: object) => {
- server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
+ return mockResponses
+ .get(method)!
+ .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response));
};
- const setCreateComponentTemplateResponse = (
- response?: ComponentTemplateSerialized,
- error?: any
- ) => {
- const status = error ? error.body.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
+ const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error);
- server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
+ const setLoadComponentTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('GET', `${API_BASE_PATH}/component_templates/${templateId}`, response, error);
+
+ const setDeleteComponentTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) =>
+ mockResponse('DELETE', `${API_BASE_PATH}/component_templates/${templateId}`, response, error);
+
+ const setCreateComponentTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/component_templates`, response, error);
return {
setLoadComponentTemplatesResponse,
@@ -74,18 +83,11 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
};
export const init = () => {
- const server = sinon.fakeServer.create();
- server.respondImmediately = true;
-
- // Define default response for unhandled requests.
- // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
- // and we can mock them all with a 200 instead of mocking each one individually.
- server.respondWith([200, {}, 'DefaultMockedResponse']);
-
- const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
+ const httpSetup = httpServiceMock.createSetupContract();
+ const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup);
return {
- server,
+ httpSetup,
httpRequestsMockHelpers,
};
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
index d532eaaba8923..9c2017ad651f1 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,8 +6,6 @@
*/
import React from 'react';
-import axios from 'axios';
-import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { HttpSetup } from 'kibana/public';
import {
@@ -24,7 +22,6 @@ import { ComponentTemplatesProvider } from '../../../component_templates_context
import { init as initHttpRequests } from './http_requests';
import { API_BASE_PATH } from './constants';
-const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const { GlobalFlyoutProvider } = GlobalFlyout;
// We provide the minimum deps required to make the tests pass
@@ -32,30 +29,23 @@ const appDependencies = {
docLinks: {} as any,
} as any;
-export const componentTemplatesDependencies = {
- httpClient: mockHttpClient as unknown as HttpSetup,
+export const componentTemplatesDependencies = (httpSetup: HttpSetup) => ({
+ httpClient: httpSetup,
apiBasePath: API_BASE_PATH,
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
setBreadcrumbs: () => {},
getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp,
-};
+});
-export const setupEnvironment = () => {
- const { server, httpRequestsMockHelpers } = initHttpRequests();
+export const setupEnvironment = initHttpRequests;
- return {
- server,
- httpRequestsMockHelpers,
- };
-};
-
-export const WithAppDependencies = (Comp: any) => (props: any) =>
+export const WithAppDependencies = (Comp: any, httpSetup: HttpSetup) => (props: any) =>
(
-
+
diff --git a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts
index 83919c60de0af..dafc904b93b1d 100644
--- a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts
+++ b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts
@@ -48,6 +48,7 @@ export type LogIndexStatus = rt.TypeOf;
const logSourceStatusRT = rt.strict({
logIndexStatus: logIndexStatusRT,
+ indices: rt.string,
});
export type LogSourceStatus = rt.TypeOf;
diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.mock.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.mock.ts
index 204fae7dc0f2b..ad649ade7345a 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.mock.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.mock.ts
@@ -57,7 +57,7 @@ export const createLoadedUseLogSourceMock: CreateUseLogSource =
...createUninitializedUseLogSourceMock({ sourceId })(args),
sourceConfiguration: createBasicSourceConfiguration(sourceId),
sourceStatus: {
- logIndexFields: [],
+ indices: 'test-index',
logIndexStatus: 'available',
},
});
@@ -80,5 +80,6 @@ export const createBasicSourceConfiguration = (sourceId: string): LogSourceConfi
});
export const createAvailableSourceStatus = (): LogSourceStatus => ({
+ indices: 'test-index',
logIndexStatus: 'available',
});
diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx
index 9c437db85bcb3..ffa799d93a20a 100644
--- a/x-pack/plugins/infra/public/pages/metrics/index.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx
@@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import React, { useContext } from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
-import { EuiErrorBoundary, EuiHeaderLinks, EuiHeaderLink, EuiToolTip } from '@elastic/eui';
+import { EuiErrorBoundary, EuiHeaderLinks, EuiHeaderLink } from '@elastic/eui';
import { MetricsSourceConfigurationProperties } from '../../../common/metrics_sources';
import { DocumentTitle } from '../../components/document_title';
import { HelpCenterContent } from '../../components/help_center_content';
@@ -27,7 +27,7 @@ import { SnapshotPage } from './inventory_view';
import { MetricDetail } from './metric_detail';
import { MetricsSettingsPage } from './settings';
import { SourceLoadingPage } from '../../components/source_loading_page';
-import { RedirectAppLinks, useKibana } from '../../../../../../src/plugins/kibana_react/public';
+import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options';
import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time';
import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters';
@@ -37,7 +37,7 @@ import { SavedViewProvider } from '../../containers/saved_view/saved_view';
import { AlertPrefillProvider } from '../../alerting/use_alert_prefill';
import { InfraMLCapabilitiesProvider } from '../../containers/ml/infra_ml_capabilities';
import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout';
-import { createExploratoryViewUrl, HeaderMenuPortal } from '../../../../observability/public';
+import { HeaderMenuPortal } from '../../../../observability/public';
import { HeaderActionMenuContext } from '../../utils/header_action_menu_provider';
import { useLinkProps } from '../../../../observability/public';
import { CreateDerivedIndexPattern } from '../../containers/metrics_source';
@@ -63,25 +63,6 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
pathname: 'settings',
});
- const metricsExploratoryViewLink = createExploratoryViewUrl(
- {
- reportType: 'kpi-over-time',
- allSeries: [
- {
- dataType: 'infra_metrics',
- seriesType: 'area',
- time: { to: 'now', from: 'now-15m' },
- reportDefinitions: {
- 'agent.hostname': ['ALL_VALUES'],
- },
- selectedMetricField: 'system.cpu.total.norm.pct',
- name: 'Metrics-series',
- },
- ],
- },
- kibana.services.http?.basePath.get()
- );
-
return (
@@ -106,24 +87,6 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
{setHeaderActionMenu && theme$ && (
- {EXPLORE_MESSAGE}
}>
-
-
- {EXPLORE_DATA}
-
-
-
{settingsTabTitle}
@@ -195,12 +158,3 @@ const PageContent = (props: {
);
};
-
-const EXPLORE_DATA = i18n.translate('xpack.infra.metrics.exploreDataButtonLabel', {
- defaultMessage: 'Explore data',
-});
-
-const EXPLORE_MESSAGE = i18n.translate('xpack.infra.metrics.exploreDataButtonLabel.message', {
- defaultMessage:
- 'Explore Data allows you to select and filter result data in any dimension and look for the cause or impact of performance problems.',
-});
diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
index 6843bc631ce27..dd4bf2f8a8895 100644
--- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
+++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
@@ -38,7 +38,10 @@ export function getLogsHasDataFetcher(getStartServices: InfraClientCoreSetup['ge
return async () => {
const [core] = await getStartServices();
const sourceStatus = await callFetchLogSourceStatusAPI(DEFAULT_SOURCE_ID, core.http.fetch);
- return sourceStatus.data.logIndexStatus === 'available';
+ return {
+ hasData: sourceStatus.data.logIndexStatus === 'available',
+ indices: sourceStatus.data.indices,
+ };
};
}
diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts
index d57dc5690e9c2..1ae412a92e456 100644
--- a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts
+++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts
@@ -102,42 +102,42 @@ describe('Logs UI Observability Homepage Functions', () => {
const { mockedGetStartServices } = setup();
mockedCallFetchLogSourceStatusAPI.mockResolvedValue({
- data: { logIndexStatus: 'available' },
+ data: { logIndexStatus: 'available', indices: 'test-index' },
});
const hasData = getLogsHasDataFetcher(mockedGetStartServices);
const response = await hasData();
expect(mockedCallFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1);
- expect(response).toBe(true);
+ expect(response).toEqual({ hasData: true, indices: 'test-index' });
});
it('should return false when only empty indices exist', async () => {
const { mockedGetStartServices } = setup();
mockedCallFetchLogSourceStatusAPI.mockResolvedValue({
- data: { logIndexStatus: 'empty' },
+ data: { logIndexStatus: 'empty', indices: 'test-index' },
});
const hasData = getLogsHasDataFetcher(mockedGetStartServices);
const response = await hasData();
expect(mockedCallFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1);
- expect(response).toBe(false);
+ expect(response).toEqual({ hasData: false, indices: 'test-index' });
});
it('should return false when no index exists', async () => {
const { mockedGetStartServices } = setup();
mockedCallFetchLogSourceStatusAPI.mockResolvedValue({
- data: { logIndexStatus: 'missing' },
+ data: { logIndexStatus: 'missing', indices: 'test-index' },
});
const hasData = getLogsHasDataFetcher(mockedGetStartServices);
const response = await hasData();
expect(mockedCallFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1);
- expect(response).toBe(false);
+ expect(response).toEqual({ hasData: false, indices: 'test-index' });
});
});
diff --git a/x-pack/plugins/infra/server/routes/log_sources/status.ts b/x-pack/plugins/infra/server/routes/log_sources/status.ts
index b43cff83a63fa..e55e856483fc6 100644
--- a/x-pack/plugins/infra/server/routes/log_sources/status.ts
+++ b/x-pack/plugins/infra/server/routes/log_sources/status.ts
@@ -52,6 +52,7 @@ export const initLogSourceStatusRoutes = ({
body: getLogSourceStatusSuccessResponsePayloadRT.encode({
data: {
logIndexStatus,
+ indices: resolvedLogSourceConfiguration.indices,
},
}),
});
diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts
index 41bf51764b539..9c4d81cf087e0 100644
--- a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts
+++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts
@@ -6,6 +6,8 @@
*/
import { cloneDeep } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { prepareLogTable } from '../../../../../../src/plugins/visualizations/common/utils';
import { FormatFactory, LensMultiTable } from '../../types';
import { transposeTable } from './transpose_helpers';
import { computeSummaryRowForColumn } from './summary';
@@ -15,7 +17,6 @@ import type {
ExecutionContext,
} from '../../../../../../src/plugins/expressions';
import type { DatatableExpressionFunction } from './types';
-import { logDataTable } from '../expressions_utils';
function isRange(meta: { params?: { id?: string } } | undefined) {
return meta?.params?.id === 'range';
@@ -26,13 +27,26 @@ export const datatableFn =
getFormatFactory: (context: ExecutionContext) => FormatFactory | Promise
): DatatableExpressionFunction['fn'] =>
async (data, args, context) => {
+ const [firstTable] = Object.values(data.tables);
if (context?.inspectorAdapters?.tables) {
- logDataTable(context.inspectorAdapters.tables, data.tables);
+ const logTable = prepareLogTable(
+ Object.values(data.tables)[0],
+ [
+ [
+ args.columns.map((column) => column.columnId),
+ i18n.translate('xpack.lens.datatable.column.help', {
+ defaultMessage: 'Datatable column',
+ }),
+ ],
+ ],
+ true
+ );
+
+ context.inspectorAdapters.tables.logDatatable('default', logTable);
}
let untransposedData: LensMultiTable | undefined;
// do the sorting at this level to propagate it also at CSV download
- const [firstTable] = Object.values(data.tables);
const [layerId] = Object.keys(context.inspectorAdapters.tables || {});
const formatters: Record> = {};
const formatFactory = await getFormatFactory(context);
diff --git a/x-pack/plugins/lens/common/expressions/expressions_utils.ts b/x-pack/plugins/lens/common/expressions/expressions_utils.ts
deleted file mode 100644
index 795b23e26e830..0000000000000
--- a/x-pack/plugins/lens/common/expressions/expressions_utils.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import type { TablesAdapter } from '../../../../../src/plugins/expressions';
-import type { Datatable } from '../../../../../src/plugins/expressions';
-
-export const logDataTable = (
- tableAdapter: TablesAdapter,
- datatables: Record = {}
-) => {
- Object.entries(datatables).forEach(([key, table]) => tableAdapter.logDatatable(key, table));
-};
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
index 6d73e8eb9ba5f..3c68837defdd9 100644
--- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
@@ -10,14 +10,33 @@ import type { ExpressionValueSearchContext } from '../../../../../../src/plugins
import type { LensMultiTable } from '../../types';
import type { XYArgs } from './xy_args';
import { fittingFunctionDefinitions } from './fitting_function';
+import { prepareLogTable } from '../../../../../../src/plugins/visualizations/common/utils';
import { endValueDefinitions } from './end_value';
-import { logDataTable } from '../expressions_utils';
export interface XYChartProps {
data: LensMultiTable;
args: XYArgs;
}
+const strings = {
+ getMetricHelp: () =>
+ i18n.translate('xpack.lens.xy.logDatatable.metric', {
+ defaultMessage: 'Vertical axis',
+ }),
+ getXAxisHelp: () =>
+ i18n.translate('xpack.lens.xy.logDatatable.x', {
+ defaultMessage: 'Horizontal axis',
+ }),
+ getBreakdownHelp: () =>
+ i18n.translate('xpack.lens.xy.logDatatable.breakDown', {
+ defaultMessage: 'Break down by',
+ }),
+ getReferenceLineHelp: () =>
+ i18n.translate('xpack.lens.xy.logDatatable.breakDown', {
+ defaultMessage: 'Break down by',
+ }),
+};
+
export interface XYRender {
type: 'render';
as: 'lens_xy_chart_renderer';
@@ -174,7 +193,26 @@ export const xyChart: ExpressionFunctionDefinition<
},
fn(data: LensMultiTable, args: XYArgs, handlers) {
if (handlers?.inspectorAdapters?.tables) {
- logDataTable(handlers.inspectorAdapters.tables, data.tables);
+ args.layers.forEach((layer) => {
+ if (layer.layerType === 'annotations') {
+ return;
+ }
+ const { layerId, accessors, xAccessor, splitAccessor, layerType } = layer;
+ const logTable = prepareLogTable(
+ data.tables[layerId],
+ [
+ [
+ accessors ? accessors : undefined,
+ layerType === 'data' ? strings.getMetricHelp() : strings.getReferenceLineHelp(),
+ ],
+ [xAccessor ? [xAccessor] : undefined, strings.getXAxisHelp()],
+ [splitAccessor ? [splitAccessor] : undefined, strings.getBreakdownHelp()],
+ ],
+ true
+ );
+
+ handlers.inspectorAdapters.tables.logDatatable(layerId, logTable);
+ });
}
return {
type: 'render',
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
index a293af4d11bfe..056efbf379d8a 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import classNames from 'classnames';
import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { DraggingIdentifier } from '../../../../drag_drop';
import { Datasource, DropType, GetDropProps } from '../../../../types';
function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') {
@@ -130,12 +131,35 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => {
}
};
+const isOperationFromTheSameGroup = (
+ op1?: DraggingIdentifier,
+ op2?: { layerId: string; groupId: string; columnId: string }
+) => {
+ return (
+ op1 &&
+ op2 &&
+ 'columnId' in op1 &&
+ op1.columnId !== op2.columnId &&
+ 'groupId' in op1 &&
+ op1.groupId === op2.groupId &&
+ 'layerId' in op1 &&
+ op1.layerId === op2.layerId
+ );
+};
+
export const getDropProps = (
layerDatasource: Datasource,
- layerDatasourceDropProps: GetDropProps
-) => {
+ dropProps: GetDropProps,
+ isNew?: boolean
+): { dropTypes: DropType[]; nextLabel?: string } | undefined => {
if (layerDatasource) {
- return layerDatasource.getDropProps(layerDatasourceDropProps);
+ return layerDatasource.getDropProps(dropProps);
+ } else {
+ // TODO: refactor & test this - it's too annotations specific
+ // TODO: allow moving operations between layers for annotations
+ if (isOperationFromTheSameGroup(dropProps.dragging, dropProps)) {
+ return { dropTypes: [isNew ? 'duplicate_compatible' : 'reorder'], nextLabel: '' };
+ }
}
return;
};
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
index f2118bda216b8..867ce32ea700e 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
@@ -131,14 +131,18 @@ export function EmptyDimensionButton({
setNewColumnId(generateId());
}, [itemIndex]);
- const dropProps = getDropProps(layerDatasource, {
- ...(layerDatasourceDropProps || {}),
- dragging,
- columnId: newColumnId,
- filterOperations: group.filterOperations,
- groupId: group.groupId,
- dimensionGroups: groups,
- });
+ const dropProps = getDropProps(
+ layerDatasource,
+ {
+ ...(layerDatasourceDropProps || {}),
+ dragging,
+ columnId: newColumnId,
+ filterOperations: group.filterOperations,
+ groupId: group.groupId,
+ dimensionGroups: groups,
+ },
+ true
+ );
const dropTypes = dropProps?.dropTypes;
const nextLabel = dropProps?.nextLabel;
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
index 366d3f93bf842..e404faacb8f97 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
@@ -179,59 +179,69 @@ export function LayerPanel(
setNextFocusedButtonId(columnId);
}
- const group = groups.find(({ groupId: gId }) => gId === groupId);
-
- const filterOperations = group?.filterOperations || (() => false);
+ if (layerDatasource) {
+ const group = groups.find(({ groupId: gId }) => gId === groupId);
+ const filterOperations = group?.filterOperations || (() => false);
+ const dropResult = layerDatasourceOnDrop({
+ ...layerDatasourceDropProps,
+ droppedItem,
+ columnId,
+ layerId: targetLayerId,
+ filterOperations,
+ dimensionGroups: groups,
+ groupId,
+ dropType,
+ });
+ if (dropResult) {
+ let previousColumn =
+ typeof droppedItem.column === 'string' ? droppedItem.column : undefined;
- const dropResult = layerDatasource
- ? layerDatasourceOnDrop({
- ...layerDatasourceDropProps,
- droppedItem,
+ // make it inherit only for moving and duplicate
+ if (!previousColumn) {
+ // when duplicating check if the previous column is required
+ if (
+ dropType === 'duplicate_compatible' &&
+ typeof droppedItem.columnId === 'string' &&
+ group?.requiresPreviousColumnOnDuplicate
+ ) {
+ previousColumn = droppedItem.columnId;
+ } else {
+ previousColumn = typeof dropResult === 'object' ? dropResult.deleted : undefined;
+ }
+ }
+ const newVisState = setDimension({
columnId,
- layerId: targetLayerId,
- filterOperations,
- dimensionGroups: groups,
groupId,
- dropType,
- })
- : false;
- if (dropResult) {
- let previousColumn =
- typeof droppedItem.column === 'string' ? droppedItem.column : undefined;
-
- // make it inherit only for moving and duplicate
- if (!previousColumn) {
- // when duplicating check if the previous column is required
- if (
- dropType === 'duplicate_compatible' &&
- typeof droppedItem.columnId === 'string' &&
- group?.requiresPreviousColumnOnDuplicate
- ) {
- previousColumn = droppedItem.columnId;
+ layerId: targetLayerId,
+ prevState: props.visualizationState,
+ previousColumn,
+ frame: framePublicAPI,
+ });
+
+ if (typeof dropResult === 'object') {
+ // When a column is moved, we delete the reference to the old
+ updateVisualization(
+ removeDimension({
+ columnId: dropResult.deleted,
+ layerId: targetLayerId,
+ prevState: newVisState,
+ frame: framePublicAPI,
+ })
+ );
} else {
- previousColumn = typeof dropResult === 'object' ? dropResult.deleted : undefined;
+ updateVisualization(newVisState);
}
}
- const newVisState = setDimension({
- columnId,
- groupId,
- layerId: targetLayerId,
- prevState: props.visualizationState,
- previousColumn,
- frame: framePublicAPI,
- });
-
- if (typeof dropResult === 'object') {
- // When a column is moved, we delete the reference to the old
- updateVisualization(
- removeDimension({
- columnId: dropResult.deleted,
- layerId: targetLayerId,
- prevState: newVisState,
- frame: framePublicAPI,
- })
- );
- } else {
+ } else {
+ if (dropType === 'duplicate_compatible' || dropType === 'reorder') {
+ const newVisState = setDimension({
+ columnId,
+ groupId,
+ layerId: targetLayerId,
+ prevState: props.visualizationState,
+ previousColumn: droppedItem.id,
+ frame: framePublicAPI,
+ });
updateVisualization(newVisState);
}
}
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx
index c36488f29d238..fa41a752cc09b 100644
--- a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx
@@ -59,6 +59,7 @@ const groupVisibleConfigsByInterval = (
) => {
return layers
.flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden))
+ .sort((a, b) => moment(a.time).valueOf() - moment(b.time).valueOf())
.reduce>((acc, current) => {
const roundedTimestamp = getRoundedTimestamp(
moment(current.time).valueOf(),
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx
index 321090c94241a..c82228f088e47 100644
--- a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx
@@ -114,32 +114,42 @@ export const setAnnotationsDimension: Visualization['setDimension'] = (
if (!foundLayer || !isAnnotationsLayer(foundLayer)) {
return prevState;
}
- const dataLayers = getDataLayers(prevState.layers);
- const newLayer = { ...foundLayer } as XYAnnotationLayerConfig;
-
- const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId);
+ const inputAnnotations = foundLayer.annotations as XYAnnotationLayerConfig['annotations'];
+ const currentConfig = inputAnnotations?.find(({ id }) => id === columnId);
const previousConfig = previousColumn
- ? newLayer.annotations?.find(({ id }) => id === previousColumn)
- : false;
- if (!hasConfig) {
- const newTimestamp = getStaticDate(dataLayers, frame?.activeData);
- newLayer.annotations = [
- ...(newLayer.annotations || []),
- {
- label: defaultAnnotationLabel,
- key: {
- type: 'point_in_time',
- timestamp: newTimestamp,
- },
- icon: 'triangle',
- ...previousConfig,
- id: columnId,
+ ? inputAnnotations?.find(({ id }) => id === previousColumn)
+ : undefined;
+
+ let resultAnnotations = [...inputAnnotations] as XYAnnotationLayerConfig['annotations'];
+ if (!currentConfig) {
+ resultAnnotations.push({
+ label: defaultAnnotationLabel,
+ key: {
+ type: 'point_in_time',
+ timestamp: getStaticDate(getDataLayers(prevState.layers), frame?.activeData),
},
- ];
+ icon: 'triangle',
+ ...previousConfig,
+ id: columnId,
+ });
+ } else if (currentConfig && previousConfig) {
+ // TODO: reordering should not live in setDimension, to be refactored
+ resultAnnotations = inputAnnotations.filter((c) => c.id !== previousConfig.id);
+ const targetPosition = resultAnnotations.findIndex((c) => c.id === currentConfig.id);
+ const targetIndex = inputAnnotations.indexOf(previousConfig);
+ const sourceIndex = inputAnnotations.indexOf(currentConfig);
+ resultAnnotations.splice(
+ targetIndex < sourceIndex ? targetPosition + 1 : targetPosition,
+ 0,
+ previousConfig
+ );
}
+
return {
...prevState,
- layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)),
+ layers: prevState.layers.map((l) =>
+ l.layerId === layerId ? { ...foundLayer, annotations: resultAnnotations } : l
+ ),
};
};
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
index 03a180cc20a08..36e1155750ef0 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
@@ -3038,7 +3038,7 @@ describe('xy_expression', () => {
// checking tooltip
const renderLinks = mount({groupedAnnotation.prop('customTooltipDetails')!()}
);
expect(renderLinks.text()).toEqual(
- ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z'
+ ' Event 1 2022-03-18T08:25:00.000Z Event 3 2022-03-18T08:25:00.001Z Event 2 2022-03-18T08:25:00.020Z'
);
});
test('should render grouped annotations with default styles', () => {
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
index 8b62b8d0c120c..105b9d24bb09b 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
@@ -294,8 +294,8 @@ export function XYChart({
// This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers
const safeXAccessorLabelRenderer = (value: unknown): string =>
xAxisColumn && layersAlreadyFormatted[xAxisColumn.id]
- ? (value as string)
- : xAxisFormatter.convert(value);
+ ? String(value)
+ : String(xAxisFormatter.convert(value));
const chartHasMoreThanOneSeries =
filteredLayers.length > 1 ||
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
index b93cf317e1b2f..b5b17c4536288 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
@@ -35,6 +35,15 @@ const exampleAnnotation: EventAnnotationConfig = {
},
icon: 'circle',
};
+const exampleAnnotation2: EventAnnotationConfig = {
+ icon: 'circle',
+ id: 'an2',
+ key: {
+ timestamp: '2022-04-18T11:01:59.135Z',
+ type: 'point_in_time',
+ },
+ label: 'Annotation2',
+};
function exampleState(): State {
return {
@@ -460,6 +469,56 @@ describe('xy_visualization', () => {
],
});
});
+ it('should copy previous column if passed and assign a new id', () => {
+ expect(
+ xyVisualization.setDimension({
+ frame,
+ prevState: {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation2],
+ },
+ ],
+ },
+ layerId: 'annotation',
+ groupId: 'xAnnotation',
+ previousColumn: 'an2',
+ columnId: 'newColId',
+ }).layers[0]
+ ).toEqual({
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation2, { ...exampleAnnotation2, id: 'newColId' }],
+ });
+ });
+ it('should reorder a dimension to a annotation layer', () => {
+ expect(
+ xyVisualization.setDimension({
+ frame,
+ prevState: {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation, exampleAnnotation2],
+ },
+ ],
+ },
+ layerId: 'annotation',
+ groupId: 'xAnnotation',
+ previousColumn: 'an2',
+ columnId: 'an1',
+ }).layers[0]
+ ).toEqual({
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation2, exampleAnnotation],
+ });
+ });
});
});
diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts
index 9b2f06d888b07..403f0ed9bfb40 100644
--- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts
+++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts
@@ -6,9 +6,11 @@
*/
import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
+import { i18n } from '@kbn/i18n';
import type { LensMultiTable } from '../../../../lens/common';
import type { ChoroplethChartConfig, ChoroplethChartProps } from './types';
import { RENDERER_ID } from './expression_renderer';
+import { prepareLogTable } from '../../../../../../src/plugins/visualizations/common/utils';
interface ChoroplethChartRender {
type: 'render';
@@ -56,7 +58,29 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition<
},
},
inputTypes: ['lens_multitable'],
- fn(data, args) {
+ fn(data, args, handlers) {
+ if (handlers?.inspectorAdapters?.tables) {
+ const logTable = prepareLogTable(
+ Object.values(data.tables)[0],
+ [
+ [
+ args.valueAccessor ? [args.valueAccessor] : undefined,
+ i18n.translate('xpack.maps.logDatatable.value', {
+ defaultMessage: 'Value',
+ }),
+ ],
+ [
+ args.regionAccessor ? [args.regionAccessor] : undefined,
+ i18n.translate('xpack.maps.logDatatable.region', {
+ defaultMessage: 'Region key',
+ }),
+ ],
+ ],
+ true
+ );
+
+ handlers.inspectorAdapters.tables.logDatatable('default', logTable);
+ }
return {
type: 'render',
as: RENDERER_ID,
diff --git a/x-pack/plugins/ml/common/constants/charts.ts b/x-pack/plugins/ml/common/constants/charts.ts
new file mode 100644
index 0000000000000..971274efed70c
--- /dev/null
+++ b/x-pack/plugins/ml/common/constants/charts.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export const CHART_TYPE = {
+ EVENT_DISTRIBUTION: 'event_distribution',
+ POPULATION_DISTRIBUTION: 'population_distribution',
+ SINGLE_METRIC: 'single_metric',
+ GEO_MAP: 'geo_map',
+} as const;
+
+export type ChartType = typeof CHART_TYPE[keyof typeof CHART_TYPE];
diff --git a/x-pack/plugins/ml/common/types/anomalies.ts b/x-pack/plugins/ml/common/types/anomalies.ts
index 31f90e0887895..58d5e9df130af 100644
--- a/x-pack/plugins/ml/common/types/anomalies.ts
+++ b/x-pack/plugins/ml/common/types/anomalies.ts
@@ -26,6 +26,8 @@ export interface Influencer {
export type MLAnomalyDoc = AnomalyRecordDoc;
+export type RecordForInfluencer = AnomalyRecordDoc;
+
/**
* Anomaly record document. Records contain the detailed analytical results.
* They describe the anomalous activity that has been identified in the input data based on the detector configuration.
diff --git a/x-pack/plugins/ml/common/types/results.ts b/x-pack/plugins/ml/common/types/results.ts
index 3e18d85ce86a6..e5393515194a6 100644
--- a/x-pack/plugins/ml/common/types/results.ts
+++ b/x-pack/plugins/ml/common/types/results.ts
@@ -6,7 +6,12 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts';
+import type { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts';
+import type { ErrorType } from '../util/errors';
+import type { EntityField } from '../util/anomaly_utils';
+import type { Datafeed, JobId } from './anomaly_detection_jobs';
+import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../constants/aggregation_types';
+import type { RecordForInfluencer } from './anomalies';
export interface GetStoppedPartitionResult {
jobs: string[] | Record;
@@ -38,3 +43,86 @@ export const defaultSearchQuery: estypes.QueryDslQueryContainer = {
],
},
};
+
+export interface MetricData {
+ results: Record;
+ success: boolean;
+ error?: ErrorType;
+}
+
+export interface ResultResponse {
+ success: boolean;
+ error?: ErrorType;
+}
+
+export interface ModelPlotOutput extends ResultResponse {
+ results: Record;
+}
+
+export interface RecordsForCriteria extends ResultResponse {
+ records: any[];
+}
+
+export interface ScheduledEventsByBucket extends ResultResponse {
+ events: Record;
+}
+
+export interface SeriesConfig {
+ jobId: JobId;
+ detectorIndex: number;
+ metricFunction: ML_JOB_AGGREGATION.LAT_LONG | ES_AGGREGATION | null;
+ timeField: string;
+ interval: string;
+ datafeedConfig: Datafeed;
+ summaryCountFieldName?: string;
+ metricFieldName?: string;
+}
+
+export interface SeriesConfigWithMetadata extends SeriesConfig {
+ functionDescription?: string;
+ bucketSpanSeconds: number;
+ detectorLabel?: string;
+ fieldName: string;
+ entityFields: EntityField[];
+ infoTooltip?: InfoTooltip;
+ loading?: boolean;
+ chartData?: ChartPoint[] | null;
+ mapData?: Array;
+ plotEarliest?: number;
+ plotLatest?: number;
+}
+
+export interface ChartPoint {
+ date: number;
+ anomalyScore?: number;
+ actual?: number[];
+ multiBucketImpact?: number;
+ typical?: number[];
+ value?: number | null;
+ entity?: string;
+ byFieldName?: string;
+ numberOfCauses?: number;
+ scheduledEvents?: any[];
+}
+
+export interface InfoTooltip {
+ jobId: JobId;
+ aggregationInterval?: string;
+ chartFunction: string;
+ entityFields: EntityField[];
+}
+
+export interface ChartRecord extends RecordForInfluencer {
+ function: string;
+}
+
+export interface ExplorerChartSeriesErrorMessages {
+ [key: string]: JobId[];
+}
+export interface ExplorerChartsData {
+ chartsPerRow: number;
+ seriesToPlot: SeriesConfigWithMetadata[];
+ tooManyBuckets: boolean;
+ timeFieldName: string;
+ errorMessages: ExplorerChartSeriesErrorMessages | undefined;
+}
diff --git a/x-pack/plugins/ml/common/util/chart_utils.ts b/x-pack/plugins/ml/common/util/chart_utils.ts
new file mode 100644
index 0000000000000..d4d1814f3529c
--- /dev/null
+++ b/x-pack/plugins/ml/common/util/chart_utils.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CHART_TYPE, ChartType } from '../constants/charts';
+import type { SeriesConfigWithMetadata } from '../types/results';
+
+/**
+ * Get the chart type based on its configuration
+ * @param config
+ */
+export function getChartType(config: SeriesConfigWithMetadata): ChartType {
+ let chartType: ChartType = CHART_TYPE.SINGLE_METRIC;
+
+ if (config.functionDescription === 'lat_long' || config.mapData !== undefined) {
+ return CHART_TYPE.GEO_MAP;
+ }
+
+ if (
+ config.functionDescription === 'rare' &&
+ config.entityFields.some((f) => f.fieldType === 'over') === false
+ ) {
+ chartType = CHART_TYPE.EVENT_DISTRIBUTION;
+ } else if (
+ config.functionDescription !== 'rare' &&
+ config.entityFields.some((f) => f.fieldType === 'over') &&
+ config.metricFunction !== null // Event distribution chart relies on the ML function mapping to an ES aggregation
+ ) {
+ chartType = CHART_TYPE.POPULATION_DISTRIBUTION;
+ }
+
+ if (
+ chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
+ chartType === CHART_TYPE.POPULATION_DISTRIBUTION
+ ) {
+ // Check that the config does not use script fields defined in the datafeed config.
+ if (config.datafeedConfig !== undefined && config.datafeedConfig.script_fields !== undefined) {
+ const scriptFields = Object.keys(config.datafeedConfig.script_fields);
+ const checkFields = config.entityFields.map((entity) => entity.fieldName);
+ if (config.metricFieldName) {
+ checkFields.push(config.metricFieldName);
+ }
+ const usesScriptFields =
+ checkFields.find((fieldName) => scriptFields.includes(fieldName)) !== undefined;
+ if (usesScriptFields === true) {
+ // Only single metric chart type supports query of model plot data.
+ chartType = CHART_TYPE.SINGLE_METRIC;
+ }
+ }
+ }
+
+ return chartType;
+}
diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx
index aec95f51b52c3..bab6b6f132558 100644
--- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx
+++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx
@@ -15,15 +15,15 @@ import { useAnomalyExplorerContext } from '../../../explorer/anomaly_explorer_co
* React component for a checkbox element to toggle charts display.
*/
export const CheckboxShowCharts: FC = () => {
- const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext();
+ const { chartsStateService } = useAnomalyExplorerContext();
const showCharts = useObservable(
- anomalyExplorerCommonStateService.getShowCharts$(),
- anomalyExplorerCommonStateService.getShowCharts()
+ chartsStateService.getShowCharts$(),
+ chartsStateService.getShowCharts()
);
const onChange = useCallback((e: React.ChangeEvent) => {
- anomalyExplorerCommonStateService.setShowCharts(e.target.checked);
+ chartsStateService.setShowCharts(e.target.checked);
}, []);
const id = useMemo(() => htmlIdGenerator()(), []);
diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx
index 5aea43a9c815a..4a386c7524478 100644
--- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx
+++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx
@@ -81,9 +81,8 @@ export function optionValueToThreshold(value: number) {
const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0];
-export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] => {
- const [severity, updateCallback] = usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT);
- return [severity, updateCallback];
+export const useTableSeverity = () => {
+ return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT);
};
export const getSeverityOptions = () =>
diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts
index ef2988d8499d7..d384e17b2e8fc 100644
--- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts
+++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts
@@ -6,8 +6,15 @@
*/
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
+import { TimefilterContract } from '../../../../../../../../src/plugins/data/public';
-export const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter;
+export const timefilterMock = dataPluginMock.createStartContract().query.timefilter
+ .timefilter as jest.Mocked;
+
+export const createTimefilterMock = () => {
+ return dataPluginMock.createStartContract().query.timefilter
+ .timefilter as jest.Mocked;
+};
export const useTimefilter = jest.fn(() => {
return timefilterMock;
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
index cd1c2dda1e27a..38cc37aec646c 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
@@ -20,6 +20,7 @@ import { useMlKibana, useMlApiContext } from '../../../contexts/kibana';
import { MlPageHeader } from '../../../components/page_header';
import { AnalyticsIdSelector, AnalyticsSelectorIds } from '../components/analytics_selector';
import { AnalyticsEmptyPrompt } from '../analytics_management/components/empty_prompt';
+import { useUrlState } from '../../../util/url_state';
export const Page: FC<{
jobId: string;
@@ -37,6 +38,8 @@ export const Page: FC<{
const jobIdToUse = jobId ?? analyticsId?.job_id;
const analysisTypeToUse = analysisType || analyticsId?.analysis_type;
+ const [, setGlobalState] = useUrlState('_g');
+
const checkJobsExist = async () => {
try {
const { count } = await getDataFrameAnalytics(undefined, undefined, 0);
@@ -51,6 +54,20 @@ export const Page: FC<{
checkJobsExist();
}, []);
+ useEffect(
+ function updateUrl() {
+ if (analyticsId !== undefined) {
+ setGlobalState({
+ ml: {
+ ...(analyticsId.analysis_type ? { analysisType: analyticsId.analysis_type } : {}),
+ ...(analyticsId.job_id ? { jobId: analyticsId.job_id } : {}),
+ },
+ });
+ }
+ },
+ [analyticsId?.job_id, analyticsId?.model_id]
+ );
+
const getEmptyState = () => {
if (jobsExist === false) {
return ;
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx
index f845cff8322dd..568971ba6d7e2 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx
@@ -190,11 +190,13 @@ export function AnalyticsIdSelector({ setAnalyticsId, jobsOnly = false }: Props)
const selectionValue = {
selectable: (item: TableItem) => {
- const selectedId = selected?.job_id ?? selected?.model_id;
const isDFA = isDataFrameAnalyticsConfigs(item);
const itemId = isDFA ? item.id : item.model_id;
const isBuiltInModel = isDFA ? false : item.tags.includes(BUILT_IN_MODEL_TAG);
- return (selected === undefined || selectedId === itemId) && !isBuiltInModel;
+ return (
+ (selected === undefined || selected?.job_id === itemId || selected?.model_id === itemId) &&
+ !isBuiltInModel
+ );
},
onSelectionChange: (selectedItem: TableItem[]) => {
const item = selectedItem[0];
@@ -208,7 +210,7 @@ export function AnalyticsIdSelector({ setAnalyticsId, jobsOnly = false }: Props)
setSelected({
model_id: isDFA ? undefined : item.model_id,
- job_id: isDFA ? item.id : undefined,
+ job_id: isDFA ? item.id : item.metadata?.analytics_config.id,
analysis_type: analysisType,
});
},
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
index efdd386ca47b3..4f171d1108ad4 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
@@ -22,7 +22,7 @@ import { AnalyticsIdSelector, AnalyticsSelectorIds } from '../components/analyti
import { AnalyticsEmptyPrompt } from '../analytics_management/components/empty_prompt';
export const Page: FC = () => {
- const [globalState] = useUrlState('_g');
+ const [globalState, setGlobalState] = useUrlState('_g');
const [isLoading, setIsLoading] = useState(false);
const [jobsExist, setJobsExist] = useState(true);
const { refresh } = useRefreshAnalyticsList({ isLoading: setIsLoading });
@@ -51,6 +51,20 @@ export const Page: FC = () => {
checkJobsExist();
}, []);
+ useEffect(
+ function updateUrl() {
+ if (analyticsId !== undefined) {
+ setGlobalState({
+ ml: {
+ ...(analyticsId.job_id && !analyticsId.model_id ? { jobId: analyticsId.job_id } : {}),
+ ...(analyticsId.model_id ? { modelId: analyticsId.model_id } : {}),
+ },
+ });
+ }
+ },
+ [analyticsId?.job_id, analyticsId?.model_id]
+ );
+
const getEmptyState = () => {
if (jobsExist === false) {
return ;
diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts
index 87c331be855ef..217a13490771e 100644
--- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts
+++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts
@@ -10,10 +10,9 @@ import { isEqual } from 'lodash';
import useObservable from 'react-use/lib/useObservable';
import { forkJoin, of, Observable, Subject } from 'rxjs';
-import { switchMap, tap, map } from 'rxjs/operators';
+import { switchMap, map } from 'rxjs/operators';
import { useCallback, useMemo } from 'react';
-import { explorerService } from '../explorer_dashboard_service';
import {
getDateFormatTz,
getSelectionInfluencers,
@@ -29,13 +28,10 @@ import {
} from '../explorer_utils';
import { ExplorerState } from '../reducers';
import { useMlKibana, useTimefilter } from '../../contexts/kibana';
-import { AnomalyTimelineService } from '../../services/anomaly_timeline_service';
import { MlResultsService, mlResultsServiceProvider } from '../../services/results_service';
import { TimefilterContract } from '../../../../../../../src/plugins/data/public';
import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service';
-import type { CombinedJob } from '../../../../common/types/anomaly_detection_jobs';
import type { InfluencersFilterQuery } from '../../../../common/types/es_client';
-import { mlJobService } from '../../services/job_service';
import type { TimeBucketsInterval, TimeRangeBounds } from '../../util/time_buckets';
// Memoize the data fetching methods.
@@ -71,7 +67,7 @@ export interface LoadExplorerDataConfig {
influencersFilterQuery: InfluencersFilterQuery;
lastRefresh: number;
noInfluencersConfigured: boolean;
- selectedCells: AppStateSelectedCells | undefined;
+ selectedCells: AppStateSelectedCells | undefined | null;
selectedJobs: ExplorerJob[];
swimlaneBucketInterval: TimeBucketsInterval;
swimlaneLimit: number;
@@ -95,15 +91,9 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi
*/
const loadExplorerDataProvider = (
mlResultsService: MlResultsService,
- anomalyTimelineService: AnomalyTimelineService,
anomalyExplorerChartsService: AnomalyExplorerChartsService,
timefilter: TimefilterContract
) => {
- const memoizedAnomalyDataChange = memoize(
- anomalyExplorerChartsService.getAnomalyData,
- anomalyExplorerChartsService
- );
-
return (config: LoadExplorerDataConfig): Observable> => {
if (!isLoadExplorerDataConfig(config)) {
return of({});
@@ -115,46 +105,28 @@ const loadExplorerDataProvider = (
noInfluencersConfigured,
selectedCells,
selectedJobs,
- swimlaneBucketInterval,
tableInterval,
tableSeverity,
viewBySwimlaneFieldName,
- swimlaneContainerWidth,
} = config;
- const combinedJobRecords: Record = selectedJobs.reduce((acc, job) => {
- return { ...acc, [job.id]: mlJobService.getJob(job.id) };
- }, {});
-
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName);
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
const bounds = timefilter.getBounds() as Required;
- const timerange = getSelectionTimeRange(
- selectedCells,
- swimlaneBucketInterval.asSeconds(),
- bounds
- );
+ const timerange = getSelectionTimeRange(selectedCells, bounds);
const dateFormatTz = getDateFormatTz();
- const interval = swimlaneBucketInterval.asSeconds();
-
// First get the data where we have all necessary args at hand using forkJoin:
// annotationsData, anomalyChartRecords, influencers, overallState, tableData
return forkJoin({
- overallAnnotations: memoizedLoadOverallAnnotations(
- lastRefresh,
- selectedJobs,
- interval,
- bounds
- ),
+ overallAnnotations: memoizedLoadOverallAnnotations(lastRefresh, selectedJobs, bounds),
annotationsData: memoizedLoadAnnotationsTableData(
lastRefresh,
selectedCells,
selectedJobs,
- swimlaneBucketInterval.asSeconds(),
bounds
),
anomalyChartRecords: anomalyExplorerChartsService.loadDataForCharts$(
@@ -183,7 +155,6 @@ const loadExplorerDataProvider = (
selectedCells,
selectedJobs,
dateFormatTz,
- swimlaneBucketInterval.asSeconds(),
bounds,
viewBySwimlaneFieldName,
tableInterval,
@@ -191,21 +162,6 @@ const loadExplorerDataProvider = (
influencersFilterQuery
),
}).pipe(
- tap(({ anomalyChartRecords }) => {
- memoizedAnomalyDataChange(
- lastRefresh,
- explorerService,
- combinedJobRecords,
- swimlaneContainerWidth,
- selectedCells !== undefined && Array.isArray(anomalyChartRecords)
- ? anomalyChartRecords
- : [],
- timerange.earliestMs,
- timerange.latestMs,
- timefilter,
- tableSeverity
- );
- }),
switchMap(
({ overallAnnotations, anomalyChartRecords, influencers, annotationsData, tableData }) =>
forkJoin({
@@ -248,28 +204,18 @@ export const useExplorerData = (): [Partial | undefined, (d: any)
const {
services: {
mlServices: { mlApiServices },
- uiSettings,
},
} = useMlKibana();
const loadExplorerData = useMemo(() => {
const mlResultsService = mlResultsServiceProvider(mlApiServices);
- const anomalyTimelineService = new AnomalyTimelineService(
- timefilter,
- uiSettings,
- mlResultsService
- );
+
const anomalyExplorerChartsService = new AnomalyExplorerChartsService(
timefilter,
mlApiServices,
mlResultsService
);
- return loadExplorerDataProvider(
- mlResultsService,
- anomalyTimelineService,
- anomalyExplorerChartsService,
- timefilter
- );
+ return loadExplorerDataProvider(mlResultsService, anomalyExplorerChartsService, timefilter);
}, []);
const loadExplorerData$ = useMemo(() => new Subject(), []);
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts
new file mode 100644
index 0000000000000..eaa1572e6fb25
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs';
+import { distinctUntilChanged, map, skipWhile, switchMap } from 'rxjs/operators';
+import { StateService } from '../services/state_service';
+import type { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state';
+import type { AnomalyTimelineStateService } from './anomaly_timeline_state_service';
+import {
+ ExplorerChartsData,
+ getDefaultChartsData,
+} from './explorer_charts/explorer_charts_container_service';
+import { AnomalyExplorerChartsService } from '../services/anomaly_explorer_charts_service';
+import { getSelectionInfluencers } from './explorer_utils';
+import type { PageUrlStateService } from '../util/url_state';
+import type { TableSeverity } from '../components/controls/select_severity/select_severity';
+import { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state';
+
+export class AnomalyChartsStateService extends StateService {
+ private _isChartsDataLoading$ = new BehaviorSubject(false);
+ private _chartsData$ = new BehaviorSubject(getDefaultChartsData());
+ private _showCharts$ = new BehaviorSubject(true);
+
+ constructor(
+ private _anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService,
+ private _anomalyTimelineStateServices: AnomalyTimelineStateService,
+ private _anomalyExplorerChartsService: AnomalyExplorerChartsService,
+ private _anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService,
+ private _tableSeverityState: PageUrlStateService
+ ) {
+ super();
+ this._init();
+ }
+
+ protected _initSubscriptions(): Subscription {
+ const subscription = new Subscription();
+
+ subscription.add(
+ this._anomalyExplorerUrlStateService
+ .getPageUrlState$()
+ .pipe(
+ map((urlState) => urlState?.mlShowCharts ?? true),
+ distinctUntilChanged()
+ )
+ .subscribe(this._showCharts$)
+ );
+
+ subscription.add(this.initChartDataSubscribtion());
+
+ return subscription;
+ }
+
+ private initChartDataSubscribtion() {
+ return combineLatest([
+ this._anomalyExplorerCommonStateService.getSelectedJobs$(),
+ this._anomalyExplorerCommonStateService.getInfluencerFilterQuery$(),
+ this._anomalyTimelineStateServices.getContainerWidth$().pipe(skipWhile((v) => v === 0)),
+ this._anomalyTimelineStateServices.getSelectedCells$(),
+ this._anomalyTimelineStateServices.getViewBySwimlaneFieldName$(),
+ this._tableSeverityState.getPageUrlState$(),
+ ])
+ .pipe(
+ switchMap(
+ ([
+ selectedJobs,
+ influencerFilterQuery,
+ containerWidth,
+ selectedCells,
+ viewBySwimlaneFieldName,
+ severityState,
+ ]) => {
+ if (!selectedCells) return of(getDefaultChartsData());
+ const jobIds = selectedJobs.map((v) => v.id);
+ this._isChartsDataLoading$.next(true);
+
+ const selectionInfluencers = getSelectionInfluencers(
+ selectedCells,
+ viewBySwimlaneFieldName!
+ );
+
+ return this._anomalyExplorerChartsService.getAnomalyData$(
+ jobIds,
+ containerWidth!,
+ selectedCells?.times[0] * 1000,
+ selectedCells?.times[1] * 1000,
+ influencerFilterQuery,
+ selectionInfluencers,
+ severityState.val,
+ 6
+ );
+ }
+ )
+ )
+ .subscribe((v) => {
+ this._chartsData$.next(v);
+ this._isChartsDataLoading$.next(false);
+ });
+ }
+
+ public getChartsData$(): Observable {
+ return this._chartsData$.asObservable();
+ }
+
+ public getChartsData(): ExplorerChartsData {
+ return this._chartsData$.getValue();
+ }
+
+ public getShowCharts$(): Observable {
+ return this._showCharts$.asObservable();
+ }
+
+ public getShowCharts(): boolean {
+ return this._showCharts$.getValue();
+ }
+
+ public setShowCharts(update: boolean) {
+ this._anomalyExplorerUrlStateService.updateUrlState({ mlShowCharts: update });
+ }
+}
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx
index 2d307adce1076..c33cd52afaf17 100644
--- a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx
@@ -22,7 +22,7 @@ import { AddAnomalyChartsToDashboardControl } from './dashboard_controls/add_ano
interface AnomalyContextMenuProps {
selectedJobs: ExplorerJob[];
- selectedCells?: AppStateSelectedCells;
+ selectedCells?: AppStateSelectedCells | null;
bounds?: TimeRangeBounds;
interval?: number;
chartsCount: number;
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts
index 66c557230753a..45995fa94838c 100644
--- a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { distinctUntilChanged, map, skipWhile } from 'rxjs/operators';
import { isEqual } from 'lodash';
import type { ExplorerJob } from './explorer_utils';
@@ -13,6 +13,7 @@ import type { InfluencersFilterQuery } from '../../../common/types/es_client';
import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state';
import type { AnomalyExplorerFilterUrlState } from '../../../common/types/locator';
import type { KQLFilterSettings } from './components/explorer_query_bar/explorer_query_bar';
+import { StateService } from '../services/state_service';
export interface AnomalyExplorerState {
selectedJobs: ExplorerJob[];
@@ -27,10 +28,9 @@ export type FilterSettings = Required<
* Anomaly Explorer common state.
* Manages related values in the URL state and applies required formatting.
*/
-export class AnomalyExplorerCommonStateService {
+export class AnomalyExplorerCommonStateService extends StateService {
private _selectedJobs$ = new BehaviorSubject(undefined);
private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings());
- private _showCharts$ = new BehaviorSubject(true);
private _getDefaultFilterSettings(): FilterSettings {
return {
@@ -42,11 +42,12 @@ export class AnomalyExplorerCommonStateService {
}
constructor(private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService) {
+ super();
this._init();
}
- private _init() {
- this.anomalyExplorerUrlStateService
+ protected _initSubscriptions(): Subscription {
+ return this.anomalyExplorerUrlStateService
.getPageUrlState$()
.pipe(
map((urlState) => urlState?.mlExplorerFilter),
@@ -59,14 +60,6 @@ export class AnomalyExplorerCommonStateService {
};
this._filterSettings$.next(result);
});
-
- this.anomalyExplorerUrlStateService
- .getPageUrlState$()
- .pipe(
- map((urlState) => urlState?.mlShowCharts ?? true),
- distinctUntilChanged()
- )
- .subscribe(this._showCharts$);
}
public setSelectedJobs(explorerJobs: ExplorerJob[] | undefined) {
@@ -113,16 +106,4 @@ export class AnomalyExplorerCommonStateService {
public clearFilterSettings() {
this.anomalyExplorerUrlStateService.updateUrlState({ mlExplorerFilter: {} });
}
-
- public getShowCharts$(): Observable {
- return this._showCharts$.asObservable();
- }
-
- public getShowCharts(): boolean {
- return this._showCharts$.getValue();
- }
-
- public setShowCharts(update: boolean) {
- this.anomalyExplorerUrlStateService.updateUrlState({ mlShowCharts: update });
- }
}
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx
index f0d175e49dda6..0d529c1aac3e5 100644
--- a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx
@@ -12,11 +12,15 @@ import { useMlKibana, useTimefilter } from '../contexts/kibana';
import { mlResultsServiceProvider } from '../services/results_service';
import { AnomalyTimelineService } from '../services/anomaly_timeline_service';
import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state';
+import { AnomalyChartsStateService } from './anomaly_charts_state_service';
+import { AnomalyExplorerChartsService } from '../services/anomaly_explorer_charts_service';
+import { useTableSeverity } from '../components/controls/select_severity';
export type AnomalyExplorerContextValue =
| {
anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService;
anomalyTimelineStateService: AnomalyTimelineStateService;
+ chartsStateService: AnomalyChartsStateService;
}
| undefined;
@@ -55,6 +59,8 @@ export function useAnomalyExplorerContextValue(
},
} = useMlKibana();
+ const [, , tableSeverityState] = useTableSeverity();
+
const mlResultsService = useMemo(() => mlResultsServiceProvider(mlApiServices), []);
const anomalyTimelineService = useMemo(() => {
@@ -66,13 +72,31 @@ export function useAnomalyExplorerContextValue(
anomalyExplorerUrlStateService
);
+ const anomalyTimelineStateService = new AnomalyTimelineStateService(
+ anomalyExplorerUrlStateService,
+ anomalyExplorerCommonStateService,
+ anomalyTimelineService,
+ timefilter
+ );
+
+ const anomalyExplorerChartsService = new AnomalyExplorerChartsService(
+ timefilter,
+ mlApiServices,
+ mlResultsService
+ );
+
+ const chartsStateService = new AnomalyChartsStateService(
+ anomalyExplorerCommonStateService,
+ anomalyTimelineStateService,
+ anomalyExplorerChartsService,
+ anomalyExplorerUrlStateService,
+ tableSeverityState
+ );
+
return {
anomalyExplorerCommonStateService,
- anomalyTimelineStateService: new AnomalyTimelineStateService(
- anomalyExplorerCommonStateService,
- anomalyTimelineService,
- timefilter
- ),
+ anomalyTimelineStateService,
+ chartsStateService,
};
}, []);
}
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx
index b8deedf3bd369..78dabfddb78c1 100644
--- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx
@@ -104,7 +104,10 @@ export const AnomalyTimeline: FC = React.memo(
);
const viewBySwimlaneData = useObservable(anomalyTimelineStateService.getViewBySwimLaneData$());
- const selectedCells = useObservable(anomalyTimelineStateService.getSelectedCells$());
+ const selectedCells = useObservable(
+ anomalyTimelineStateService.getSelectedCells$(),
+ anomalyTimelineStateService.getSelectedCells()
+ );
const swimLaneSeverity = useObservable(anomalyTimelineStateService.getSwimLaneSeverity$());
const viewBySwimlaneFieldName = useObservable(
anomalyTimelineStateService.getViewBySwimlaneFieldName$()
diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts
index 19dab0be1ff9f..49e12fbd57f12 100644
--- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts
+++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { BehaviorSubject, combineLatest, from, Observable, of } from 'rxjs';
+import { BehaviorSubject, combineLatest, from, Observable, of, Subscription } from 'rxjs';
import {
switchMap,
map,
@@ -40,6 +40,8 @@ import { InfluencersFilterQuery } from '../../../common/types/es_client';
// FIXME get rid of the static import
import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
import type { Refresh } from '../routing/use_refresh';
+import { StateService } from '../services/state_service';
+import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state';
interface SwimLanePagination {
viewByFromPage: number;
@@ -49,10 +51,11 @@ interface SwimLanePagination {
/**
* Service for managing anomaly timeline state.
*/
-export class AnomalyTimelineStateService {
- private _explorerURLStateCallback:
- | ((update: AnomalyExplorerSwimLaneUrlState, replaceState?: boolean) => void)
- | null = null;
+export class AnomalyTimelineStateService extends StateService {
+ private readonly _explorerURLStateCallback: (
+ update: AnomalyExplorerSwimLaneUrlState,
+ replaceState?: boolean
+ ) => void;
private _overallSwimLaneData$ = new BehaviorSubject(null);
private _viewBySwimLaneData$ = new BehaviorSubject(undefined);
@@ -62,7 +65,9 @@ export class AnomalyTimelineStateService {
>(null);
private _containerWidth$ = new BehaviorSubject(0);
- private _selectedCells$ = new BehaviorSubject(undefined);
+ private _selectedCells$ = new BehaviorSubject(
+ undefined
+ );
private _swimLaneSeverity$ = new BehaviorSubject(0);
private _swimLanePaginations$ = new BehaviorSubject({
viewByFromPage: 1,
@@ -80,15 +85,32 @@ export class AnomalyTimelineStateService {
private _refreshSubject$: Observable;
constructor(
+ private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService,
private anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService,
private anomalyTimelineService: AnomalyTimelineService,
private timefilter: TimefilterContract
) {
+ super();
+
this._timeBounds$ = this.timefilter.getTimeUpdate$().pipe(
startWith(null),
map(() => this.timefilter.getBounds())
);
this._refreshSubject$ = mlTimefilterRefresh$.pipe(startWith({ lastRefresh: 0 }));
+
+ this._explorerURLStateCallback = (
+ update: AnomalyExplorerSwimLaneUrlState,
+ replaceState?: boolean
+ ) => {
+ const explorerUrlState = this.anomalyExplorerUrlStateService.getPageUrlState();
+ const mlExplorerSwimLaneState = explorerUrlState?.mlExplorerSwimlane;
+ const resultUpdate = replaceState ? update : { ...mlExplorerSwimLaneState, ...update };
+ return this.anomalyExplorerUrlStateService.updateUrlState({
+ ...explorerUrlState,
+ mlExplorerSwimlane: resultUpdate,
+ });
+ };
+
this._init();
}
@@ -96,36 +118,53 @@ export class AnomalyTimelineStateService {
* Initializes required subscriptions for fetching swim lanes data.
* @private
*/
- private _init() {
- this._initViewByData();
+ protected _initSubscriptions(): Subscription {
+ const subscription = new Subscription();
+
+ subscription.add(
+ this.anomalyExplorerUrlStateService
+ .getPageUrlState$()
+ .pipe(
+ map((v) => v?.mlExplorerSwimlane),
+ distinctUntilChanged(isEqual)
+ )
+ .subscribe(this._swimLaneUrlState$)
+ );
- this._swimLaneUrlState$
- .pipe(
- map((v) => v?.severity ?? 0),
- distinctUntilChanged()
- )
- .subscribe(this._swimLaneSeverity$);
+ subscription.add(this._initViewByData());
- this._initSwimLanePagination();
- this._initOverallSwimLaneData();
- this._initTopFieldValues();
- this._initViewBySwimLaneData();
+ subscription.add(
+ this._swimLaneUrlState$
+ .pipe(
+ map((v) => v?.severity ?? 0),
+ distinctUntilChanged()
+ )
+ .subscribe(this._swimLaneSeverity$)
+ );
- combineLatest([
- this.anomalyExplorerCommonStateService.getSelectedJobs$(),
- this.getContainerWidth$(),
- ]).subscribe(([selectedJobs, containerWidth]) => {
- if (!selectedJobs) return;
- this._swimLaneBucketInterval$.next(
- this.anomalyTimelineService.getSwimlaneBucketInterval(selectedJobs, containerWidth!)
- );
- });
+ subscription.add(this._initSwimLanePagination());
+ subscription.add(this._initOverallSwimLaneData());
+ subscription.add(this._initTopFieldValues());
+ subscription.add(this._initViewBySwimLaneData());
- this._initSelectedCells();
+ subscription.add(
+ combineLatest([
+ this.anomalyExplorerCommonStateService.getSelectedJobs$(),
+ this.getContainerWidth$(),
+ ]).subscribe(([selectedJobs, containerWidth]) => {
+ this._swimLaneBucketInterval$.next(
+ this.anomalyTimelineService.getSwimlaneBucketInterval(selectedJobs, containerWidth!)
+ );
+ })
+ );
+
+ subscription.add(this._initSelectedCells());
+
+ return subscription;
}
- private _initViewByData(): void {
- combineLatest([
+ private _initViewByData(): Subscription {
+ return combineLatest([
this._swimLaneUrlState$.pipe(
map((v) => v?.viewByFieldName),
distinctUntilChanged()
@@ -148,7 +187,7 @@ export class AnomalyTimelineStateService {
}
private _initSwimLanePagination() {
- combineLatest([
+ return combineLatest([
this._swimLaneUrlState$.pipe(
map((v) => {
return {
@@ -170,7 +209,7 @@ export class AnomalyTimelineStateService {
}
private _initOverallSwimLaneData() {
- combineLatest([
+ return combineLatest([
this.anomalyExplorerCommonStateService.getSelectedJobs$(),
this._swimLaneSeverity$,
this.getContainerWidth$(),
@@ -199,7 +238,7 @@ export class AnomalyTimelineStateService {
}
private _initTopFieldValues() {
- (
+ return (
combineLatest([
this.anomalyExplorerCommonStateService.getSelectedJobs$(),
this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(),
@@ -245,11 +284,7 @@ export class AnomalyTimelineStateService {
viewBySwimlaneFieldName
);
- const timerange = getSelectionTimeRange(
- selectedCells,
- swimLaneBucketInterval.asSeconds(),
- this.timefilter.getBounds()
- );
+ const timerange = getSelectionTimeRange(selectedCells, this.timefilter.getBounds());
return from(
this.anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime(
@@ -272,7 +307,7 @@ export class AnomalyTimelineStateService {
}
private _initViewBySwimLaneData() {
- combineLatest([
+ return combineLatest([
this._overallSwimLaneData$.pipe(skipWhile((v) => !v)),
this.anomalyExplorerCommonStateService.getSelectedJobs$(),
this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(),
@@ -328,7 +363,7 @@ export class AnomalyTimelineStateService {
}
private _initSelectedCells() {
- combineLatest([
+ return combineLatest([
this._viewBySwimlaneFieldName$,
this._swimLaneUrlState$,
this.getSwimLaneBucketInterval$(),
@@ -337,7 +372,7 @@ export class AnomalyTimelineStateService {
.pipe(
map(([viewByFieldName, swimLaneUrlState, swimLaneBucketInterval]) => {
if (!swimLaneUrlState?.selectedType) {
- return;
+ return null;
}
let times: AnomalyExplorerSwimLaneUrlState['selectedTimes'] =
@@ -355,7 +390,7 @@ export class AnomalyTimelineStateService {
times = this._getAdjustedTimeSelection(times, this.timefilter.getBounds());
if (!times) {
- return;
+ return null;
}
return {
@@ -422,7 +457,7 @@ export class AnomalyTimelineStateService {
filterActive: boolean,
filteredFields: string[],
isAndOperator: boolean,
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
selectedJobs: ExplorerJob[] | undefined
) {
const selectedJobIds = selectedJobs?.map((d) => d.id) ?? [];
@@ -564,10 +599,14 @@ export class AnomalyTimelineStateService {
/**
* Provides updates for swim lanes cells selection.
*/
- public getSelectedCells$(): Observable {
+ public getSelectedCells$(): Observable {
return this._selectedCells$.asObservable();
}
+ public getSelectedCells(): AppStateSelectedCells | undefined | null {
+ return this._selectedCells$.getValue();
+ }
+
public getSwimLaneSeverity$(): Observable {
return this._swimLaneSeverity$.asObservable();
}
@@ -589,7 +628,7 @@ export class AnomalyTimelineStateService {
if (resultUpdate.viewByPerPage) {
resultUpdate.viewByFromPage = 1;
}
- this._explorerURLStateCallback!(resultUpdate);
+ this._explorerURLStateCallback(resultUpdate);
}
public getSwimLaneCardinality$(): Observable {
@@ -616,22 +655,6 @@ export class AnomalyTimelineStateService {
return this._isViewBySwimLaneLoading$.asObservable();
}
- /**
- * Updates internal subject from the URL state.
- * @param value
- */
- public updateFromUrlState(value: AnomalyExplorerSwimLaneUrlState | undefined) {
- this._swimLaneUrlState$.next(value);
- }
-
- /**
- * Updates callback for setting URL app state.
- * @param callback
- */
- public updateSetStateCallback(callback: (update: AnomalyExplorerSwimLaneUrlState) => void) {
- this._explorerURLStateCallback = callback;
- }
-
/**
* Sets container width
* @param value
@@ -646,7 +669,7 @@ export class AnomalyTimelineStateService {
* @param value
*/
public setSeverity(value: number) {
- this._explorerURLStateCallback!({ severity: value, viewByFromPage: 1 });
+ this._explorerURLStateCallback({ severity: value, viewByFromPage: 1 });
}
/**
@@ -681,14 +704,14 @@ export class AnomalyTimelineStateService {
mlExplorerSwimlane.selectedTimes = swimLaneSelectedCells.times;
mlExplorerSwimlane.showTopFieldValues = swimLaneSelectedCells.showTopFieldValues;
- this._explorerURLStateCallback!(mlExplorerSwimlane);
+ this._explorerURLStateCallback(mlExplorerSwimlane);
} else {
delete mlExplorerSwimlane.selectedType;
delete mlExplorerSwimlane.selectedLanes;
delete mlExplorerSwimlane.selectedTimes;
delete mlExplorerSwimlane.showTopFieldValues;
- this._explorerURLStateCallback!(mlExplorerSwimlane, true);
+ this._explorerURLStateCallback(mlExplorerSwimlane, true);
}
}
@@ -697,7 +720,7 @@ export class AnomalyTimelineStateService {
* @param fieldName - Influencer field name of job id.
*/
public setViewBySwimLaneFieldName(fieldName: string) {
- this._explorerURLStateCallback!(
+ this._explorerURLStateCallback(
{
viewByFromPage: 1,
viewByPerPage: this._swimLanePaginations$.getValue().viewByPerPage,
diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx
index bb134666b08d1..be154a3726ae6 100644
--- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx
@@ -29,7 +29,7 @@ function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
export interface AddToDashboardControlProps {
jobIds: string[];
- selectedCells?: AppStateSelectedCells;
+ selectedCells?: AppStateSelectedCells | null;
bounds?: TimeRangeBounds;
interval?: number;
onClose: (callback?: () => Promise) => void;
@@ -50,8 +50,8 @@ export const AddAnomalyChartsToDashboardControl: FC
const getEmbeddableInput = useCallback(() => {
let timeRange: TimeRange | undefined;
- if (selectedCells !== undefined && interval !== undefined && bounds !== undefined) {
- const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, interval, bounds);
+ if (!!selectedCells && interval !== undefined && bounds !== undefined) {
+ const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, bounds);
timeRange = {
from: formatDate(earliestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
to: formatDate(latestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx
index 3d9c23b97de0c..9ee9aed31c7af 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx
@@ -131,7 +131,7 @@ interface ExplorerUIProps {
timefilter: TimefilterContract;
// TODO Remove
timeBuckets: TimeBuckets;
- selectedCells: AppStateSelectedCells | undefined;
+ selectedCells: AppStateSelectedCells | undefined | null;
swimLaneSeverity?: number;
}
@@ -149,7 +149,7 @@ export const Explorer: FC = ({
overallSwimlaneData,
}) => {
const { displayWarningToast, displayDangerToast } = useToastNotificationService();
- const { anomalyTimelineStateService, anomalyExplorerCommonStateService } =
+ const { anomalyTimelineStateService, anomalyExplorerCommonStateService, chartsStateService } =
useAnomalyExplorerContext();
const htmlIdGen = useMemo(() => htmlIdGenerator(), []);
@@ -246,7 +246,6 @@ export const Explorer: FC = ({
const {
annotations,
- chartsData,
filterPlaceHolder,
indexPattern,
influencers,
@@ -255,6 +254,11 @@ export const Explorer: FC = ({
tableData,
} = explorerState;
+ const chartsData = useObservable(
+ chartsStateService.getChartsData$(),
+ chartsStateService.getChartsData()
+ );
+
const { filterActive, queryString } = filterSettings;
const isOverallSwimLaneLoading = useObservable(
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js
index 8e39120e36411..84908775a14a8 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js
@@ -17,9 +17,9 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { ExplorerChartDistribution } from './explorer_chart_distribution';
-import { chartLimits } from '../../util/chart_utils';
import { timeBucketsMock } from '../../util/__mocks__/time_buckets';
import { kibanaContextMock } from '../../contexts/kibana/__mocks__/kibana_context';
+
const utilityProps = {
timeBuckets: timeBucketsMock,
chartTheme: kibanaContextMock.services.charts.theme.useChartsTheme(),
@@ -96,7 +96,6 @@ describe('ExplorerChart', () => {
const config = {
...seriesConfig,
chartData,
- chartLimits: chartLimits(chartData),
};
const mockTooltipService = {
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
index 2582dcfb05c16..890feb6efaf18 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
@@ -17,7 +17,6 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { ExplorerChartSingleMetric } from './explorer_chart_single_metric';
-import { chartLimits } from '../../util/chart_utils';
import { timeBucketsMock } from '../../util/__mocks__/time_buckets';
import { kibanaContextMock } from '../../contexts/kibana/__mocks__/kibana_context';
@@ -100,7 +99,7 @@ describe('ExplorerChart', () => {
const config = {
...seriesConfig,
chartData,
- chartLimits: chartLimits(chartData),
+ chartLimits: { min: 201039318, max: 625736376 },
};
const mockTooltipService = {
@@ -174,7 +173,8 @@ describe('ExplorerChart', () => {
expect([...chartMarkers].map((d) => +d.getAttribute('r'))).toEqual([7, 7, 7, 7]);
});
- it('Anomaly Explorer Chart with single data point', () => {
+ // TODO chart limits provided by the endpoint, mock data needs to be updated.
+ it.skip('Anomaly Explorer Chart with single data point', () => {
const chartData = [
{
date: new Date('2017-02-23T08:00:00.000Z'),
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
index fbb869cf34aa3..903a0d75e6f60 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
@@ -10,8 +10,6 @@ import { mount, shallow } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
-import { chartLimits } from '../../util/chart_utils';
-
import { getDefaultChartsData } from './explorer_charts_container_service';
import { ExplorerChartsContainer } from './explorer_charts_container';
@@ -79,7 +77,7 @@ describe('ExplorerChartsContainer', () => {
{
...seriesConfig,
chartData,
- chartLimits: chartLimits(chartData),
+ chartLimits: { min: 201039318, max: 625736376 },
},
],
chartsPerRow: 1,
@@ -107,7 +105,6 @@ describe('ExplorerChartsContainer', () => {
{
...seriesConfigRare,
chartData,
- chartLimits: chartLimits(chartData),
},
],
chartsPerRow: 1,
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts
index aa2eabbd4a38e..693052fff1d86 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts
@@ -13,10 +13,10 @@
*/
import type { JobId } from '../../../../common/types/anomaly_detection_jobs';
-import { SeriesConfigWithMetadata } from '../../services/anomaly_explorer_charts_service';
+import type { SeriesConfigWithMetadata } from '../../../../common/types/results';
export interface ExplorerChartSeriesErrorMessages {
- [key: string]: Set;
+ [key: string]: JobId[];
}
export declare interface ExplorerChartsData {
chartsPerRow: number;
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts
index 0a8f61fb80ff4..ee6d42af2ff62 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts
@@ -22,7 +22,6 @@ export const EXPLORER_ACTION = {
CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings',
CLEAR_JOBS: 'clearJobs',
JOB_SELECTION_CHANGE: 'jobSelectionChange',
- SET_CHARTS: 'setCharts',
SET_CHARTS_DATA_LOADING: 'setChartsDataLoading',
SET_EXPLORER_DATA: 'setExplorerData',
};
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts
index 0517f80e27429..5bcd305389d39 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts
@@ -15,7 +15,6 @@ import { from, isObservable, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, flatMap, scan, shareReplay } from 'rxjs/operators';
import { DeepPartial } from '../../../common/types/common';
import { jobSelectionActionCreator } from './actions';
-import type { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service';
import { EXPLORER_ACTION } from './explorer_constants';
import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers';
@@ -64,9 +63,6 @@ export const explorerService = {
updateJobSelection: (selectedJobIds: string[]) => {
explorerAction$.next(jobSelectionActionCreator(selectedJobIds));
},
- setCharts: (payload: ExplorerChartsData) => {
- explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS, payload });
- },
setExplorerData: (payload: DeepPartial) => {
explorerAction$.next(setExplorerDataActionCreator(payload));
},
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts
index 17406d7b5eadc..1700b85e62b68 100644
--- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts
+++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts
@@ -254,8 +254,7 @@ export function getFieldsByJob() {
}
export function getSelectionTimeRange(
- selectedCells: AppStateSelectedCells | undefined,
- interval: number,
+ selectedCells: AppStateSelectedCells | undefined | null,
bounds: TimeRangeBounds
): SelectionTimeRange {
// Returns the time range of the cell(s) currently selected in the swimlane.
@@ -267,7 +266,7 @@ export function getSelectionTimeRange(
let earliestMs = requiredBounds.min.valueOf();
let latestMs = requiredBounds.max.valueOf();
- if (selectedCells !== undefined && selectedCells.times !== undefined) {
+ if (selectedCells?.times !== undefined) {
// time property of the cell data is an array, with the elements being
// the start times of the first and last cell selected.
earliestMs =
@@ -285,11 +284,11 @@ export function getSelectionTimeRange(
}
export function getSelectionInfluencers(
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
fieldName: string
): EntityField[] {
if (
- selectedCells !== undefined &&
+ !!selectedCells &&
selectedCells.type !== SWIMLANE_TYPE.OVERALL &&
selectedCells.viewByFieldName !== undefined &&
selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL
@@ -301,11 +300,11 @@ export function getSelectionInfluencers(
}
export function getSelectionJobIds(
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
selectedJobs: ExplorerJob[]
): string[] {
if (
- selectedCells !== undefined &&
+ !!selectedCells &&
selectedCells.type !== SWIMLANE_TYPE.OVERALL &&
selectedCells.viewByFieldName !== undefined &&
selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
@@ -318,11 +317,10 @@ export function getSelectionJobIds(
export function loadOverallAnnotations(
selectedJobs: ExplorerJob[],
- interval: number,
bounds: TimeRangeBounds
): Promise {
const jobIds = selectedJobs.map((d) => d.id);
- const timeRange = getSelectionTimeRange(undefined, interval, bounds);
+ const timeRange = getSelectionTimeRange(undefined, bounds);
return new Promise((resolve) => {
ml.annotations
@@ -372,13 +370,12 @@ export function loadOverallAnnotations(
}
export function loadAnnotationsTableData(
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
selectedJobs: ExplorerJob[],
- interval: number,
bounds: Required
): Promise {
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
- const timeRange = getSelectionTimeRange(selectedCells, interval, bounds);
+ const timeRange = getSelectionTimeRange(selectedCells, bounds);
return new Promise((resolve) => {
ml.annotations
@@ -431,10 +428,9 @@ export function loadAnnotationsTableData(
}
export async function loadAnomaliesTableData(
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
selectedJobs: ExplorerJob[],
- dateFormatTz: any,
- interval: number,
+ dateFormatTz: string,
bounds: Required,
fieldName: string,
tableInterval: string,
@@ -443,7 +439,7 @@ export async function loadAnomaliesTableData(
): Promise {
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
const influencers = getSelectionInfluencers(selectedCells, fieldName);
- const timeRange = getSelectionTimeRange(selectedCells, interval, bounds);
+ const timeRange = getSelectionTimeRange(selectedCells, bounds);
return new Promise((resolve, reject) => {
ml.results
diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts
index 9b2665f8f21f8..0dac57753fee9 100644
--- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts
+++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts
@@ -13,7 +13,7 @@ export interface SelectionTimeRange {
}
export function getTimeBoundsFromSelection(
- selectedCells: AppStateSelectedCells | undefined
+ selectedCells: AppStateSelectedCells | undefined | null
): SelectionTimeRange | undefined {
if (selectedCells?.times === undefined) {
return;
diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
index 632ade186a44d..d0b44addc728a 100644
--- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
+++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
@@ -50,20 +50,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
};
break;
- case EXPLORER_ACTION.SET_CHARTS:
- nextState = {
- ...state,
- chartsData: {
- ...getDefaultChartsData(),
- chartsPerRow: payload.chartsPerRow,
- seriesToPlot: payload.seriesToPlot,
- // convert truthy/falsy value to Boolean
- tooManyBuckets: !!payload.tooManyBuckets,
- errorMessages: payload.errorMessages,
- },
- };
- break;
-
case EXPLORER_ACTION.SET_EXPLORER_DATA:
nextState = { ...state, ...payload };
break;
diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx
index 7f0d7038f3f04..c2978288b2ff3 100644
--- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx
+++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx
@@ -136,7 +136,7 @@ export interface SwimlaneProps {
showLegend?: boolean;
swimlaneData: OverallSwimlaneData | ViewBySwimLaneData;
swimlaneType: SwimlaneType;
- selection?: AppStateSelectedCells;
+ selection?: AppStateSelectedCells | null;
onCellsSelection?: (payload?: AppStateSelectedCells) => void;
'data-test-subj'?: string;
onResize: (width: number) => void;
diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx
index 38d4b9795abed..e67b793944e6b 100644
--- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx
+++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx
@@ -44,7 +44,6 @@ import {
AnomalyExplorerContext,
useAnomalyExplorerContextValue,
} from '../../explorer/anomaly_explorer_context';
-import type { AnomalyExplorerSwimLaneUrlState } from '../../../../common/types/locator';
export const explorerRouteFactory = (
navigateToPath: NavigateToPath,
@@ -97,7 +96,7 @@ interface ExplorerUrlStateManagerProps {
}
const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => {
- const [explorerUrlState, setExplorerUrlState, explorerUrlStateService] = useExplorerUrlState();
+ const [, , explorerUrlStateService] = useExplorerUrlState();
const anomalyExplorerContext = useAnomalyExplorerContextValue(explorerUrlStateService);
@@ -151,30 +150,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
}
}, []);
- const updateSwimLaneUrlState = useCallback(
- (update: AnomalyExplorerSwimLaneUrlState | undefined, replaceState = false) => {
- const ccc = explorerUrlState?.mlExplorerSwimlane;
- const resultUpdate = replaceState ? update : { ...ccc, ...update };
- return setExplorerUrlState({
- ...explorerUrlState,
- mlExplorerSwimlane: resultUpdate,
- });
- },
- [explorerUrlState, setExplorerUrlState]
- );
-
- useEffect(
- // TODO URL state service should provide observable with updates
- // and immutable method for updates
- function updateAnomalyTimelineStateFromUrl() {
- const { anomalyTimelineStateService } = anomalyExplorerContext;
-
- anomalyTimelineStateService.updateSetStateCallback(updateSwimLaneUrlState);
- anomalyTimelineStateService.updateFromUrlState(explorerUrlState?.mlExplorerSwimlane);
- },
- [explorerUrlState?.mlExplorerSwimlane, updateSwimLaneUrlState]
- );
-
useEffect(
function handleJobSelection() {
if (jobIds.length > 0) {
@@ -192,6 +167,10 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
// upon component unmounting
// clear any data to prevent next page from rendering old charts
explorerService.clearExplorerData();
+
+ anomalyExplorerContext.anomalyExplorerCommonStateService.destroy();
+ anomalyExplorerContext.anomalyTimelineStateService.destroy();
+ anomalyExplorerContext.chartsStateService.destroy();
};
}, []);
@@ -207,17 +186,13 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
const [tableSeverity] = useTableSeverity();
const showCharts = useObservable(
- anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts$(),
- anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts()
+ anomalyExplorerContext.chartsStateService.getShowCharts$(),
+ anomalyExplorerContext.chartsStateService.getShowCharts()
);
const selectedCells = useObservable(
- anomalyExplorerContext.anomalyTimelineStateService.getSelectedCells$()
- );
-
- const swimlaneContainerWidth = useObservable(
- anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth$(),
- anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth()
+ anomalyExplorerContext.anomalyTimelineStateService.getSelectedCells$(),
+ anomalyExplorerContext.anomalyTimelineStateService.getSelectedCells()
);
const viewByFieldName = useObservable(
@@ -229,11 +204,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneSeverity()
);
- const swimLaneBucketInterval = useObservable(
- anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval$(),
- anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval()
- );
-
const influencersFilterQuery = useObservable(
anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$()
);
@@ -246,11 +216,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
noInfluencersConfigured: explorerState.noInfluencersConfigured,
selectedCells,
selectedJobs: explorerState.selectedJobs,
- swimlaneBucketInterval: swimLaneBucketInterval,
tableInterval: tableInterval.val,
tableSeverity: tableSeverity.val,
viewBySwimlaneFieldName: viewByFieldName,
- swimlaneContainerWidth,
}
: undefined;
@@ -264,9 +232,8 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim
);
useEffect(() => {
- if (explorerState && loadExplorerDataConfig?.swimlaneContainerWidth! > 0) {
- loadExplorerData(loadExplorerDataConfig);
- }
+ if (!loadExplorerDataConfig || loadExplorerDataConfig?.selectedCells === undefined) return;
+ loadExplorerData(loadExplorerDataConfig);
}, [JSON.stringify(loadExplorerDataConfig)]);
const overallSwimlaneData = useObservable(
diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts
index 28140038d249b..638e02acab1b0 100644
--- a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts
+++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts
@@ -5,10 +5,13 @@
* 2.0.
*/
-export const createAnomalyExplorerChartsServiceMock = () => ({
- getCombinedJobs: jest.fn(),
- getAnomalyData: jest.fn(),
- setTimeRange: jest.fn(),
- getTimeBounds: jest.fn(),
- loadDataForCharts$: jest.fn(),
-});
+import type { AnomalyExplorerChartsService } from '../anomaly_explorer_charts_service';
+
+export const createAnomalyExplorerChartsServiceMock = () =>
+ ({
+ getCombinedJobs: jest.fn(),
+ getAnomalyData$: jest.fn(),
+ setTimeRange: jest.fn(),
+ getTimeBounds: jest.fn(),
+ loadDataForCharts$: jest.fn(),
+ } as unknown as jest.Mocked);
diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts
index b63ae2f859b65..435faf8f1c75b 100644
--- a/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts
+++ b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts
@@ -9,4 +9,7 @@ export const mlApiServicesMock = {
jobs: {
jobForCloning: jest.fn(),
},
+ results: {
+ getAnomalyCharts$: jest.fn(),
+ },
};
diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts
index 9d0f68b1e8bed..d707db3cb8c38 100644
--- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts
+++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts
@@ -6,152 +6,92 @@
*/
import { AnomalyExplorerChartsService } from './anomaly_explorer_charts_service';
-import mockAnomalyChartRecords from '../explorer/explorer_charts/__mocks__/mock_anomaly_chart_records.json';
-import mockJobConfig from '../explorer/explorer_charts/__mocks__/mock_job_config.json';
-import mockSeriesPromisesResponse from '../explorer/explorer_charts/__mocks__/mock_series_promises_response.json';
import { of } from 'rxjs';
-import { cloneDeep } from 'lodash';
-import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
-import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service';
import type { MlApiServices } from './ml_api_service';
import type { MlResultsService } from './results_service';
+import { createTimefilterMock } from '../contexts/kibana/__mocks__/use_timefilter';
+import moment from 'moment';
import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service';
-import { timefilterMock } from '../contexts/kibana/__mocks__/use_timefilter';
-import { mlApiServicesMock } from './__mocks__/ml_api_services';
-// Some notes on the tests and mocks:
-//
-// 'call anomalyChangeListener with actual series config'
-// This test uses the standard mocks and uses the data as is provided via the mock files.
-// The mocked services check for values in the data (e.g. 'mock-job-id', 'farequote-2017')
-// and return the mock data from the files.
-//
-// 'filtering should skip values of null'
-// This is is used to verify that values of `null` get filtered out but `0` is kept.
-// The test clones mock data from files and adjusts job_id and indices to trigger
-// suitable responses from the mocked services. The mocked services check against the
-// provided alternative values and return specific modified mock responses for the test case.
+export const mlResultsServiceMock = {};
-const mockJobConfigClone = cloneDeep(mockJobConfig);
-
-// adjust mock data to tests against null/0 values
-const mockMetricClone = cloneDeep(mockSeriesPromisesResponse[0][0]);
-// @ts-ignore
-mockMetricClone.results['1486712700000'] = null;
-// @ts-ignore
-mockMetricClone.results['1486713600000'] = 0;
-
-export const mlResultsServiceMock = {
- getMetricData: jest.fn((indices) => {
- // this is for 'call anomalyChangeListener with actual series config'
- if (indices[0] === 'farequote-2017') {
- return of(mockSeriesPromisesResponse[0][0]);
- }
- // this is for 'filtering should skip values of null'
- return of(mockMetricClone);
- }),
- getRecordsForCriteria: jest.fn(() => {
- return of(mockSeriesPromisesResponse[0][1]);
- }),
- getScheduledEventsByBucket: jest.fn(() => of(mockSeriesPromisesResponse[0][2])),
- getEventDistributionData: jest.fn((indices) => {
- // this is for 'call anomalyChangeListener with actual series config'
- if (indices[0] === 'farequote-2017') {
- return Promise.resolve([]);
- }
- // this is for 'filtering should skip values of null' and
- // resolves with a dummy object to trigger the processing
- // of the event distribution chartdata filtering
- return Promise.resolve([
- {
- entity: 'mock',
- },
- ]);
- }),
-};
-
-const assertAnomalyDataResult = (anomalyData: ExplorerChartsData) => {
- expect(anomalyData.chartsPerRow).toBe(1);
- expect(Array.isArray(anomalyData.seriesToPlot)).toBe(true);
- expect(anomalyData.seriesToPlot.length).toBe(1);
- expect(anomalyData.errorMessages).toMatchObject({});
- expect(anomalyData.tooManyBuckets).toBe(false);
- expect(anomalyData.timeFieldName).toBe('timestamp');
-};
describe('AnomalyExplorerChartsService', () => {
const jobId = 'mock-job-id';
- const combinedJobRecords = {
- [jobId]: mockJobConfigClone,
- };
- const anomalyExplorerService = new AnomalyExplorerChartsService(
- timefilterMock,
- mlApiServicesMock as unknown as MlApiServices,
- mlResultsServiceMock as unknown as MlResultsService
- );
+
+ let anomalyExplorerService: jest.Mocked;
+
+ let timefilterMock;
const timeRange = {
earliestMs: 1486656000000,
latestMs: 1486670399999,
};
+ const mlApiServicesMock = {
+ jobs: {
+ jobForCloning: jest.fn(),
+ },
+ results: {
+ getAnomalyCharts$: jest.fn(),
+ },
+ };
+
beforeEach(() => {
- mlApiServicesMock.jobs.jobForCloning.mockImplementation(() =>
- Promise.resolve({ job: mockJobConfigClone, datafeed: mockJobConfigClone.datafeed_config })
+ jest.useFakeTimers();
+
+ mlApiServicesMock.jobs.jobForCloning.mockImplementation(() => Promise.resolve({}));
+
+ mlApiServicesMock.results.getAnomalyCharts$.mockReturnValue(
+ of({
+ ...getDefaultChartsData(),
+ seriesToPlot: [{}],
+ })
);
- });
- test('should return anomaly data without explorer service', async () => {
- const anomalyData = (await anomalyExplorerService.getAnomalyData(
- undefined,
- combinedJobRecords as unknown as Record,
- 1000,
- mockAnomalyChartRecords,
- timeRange.earliestMs,
- timeRange.latestMs,
+ timefilterMock = createTimefilterMock();
+ timefilterMock.getActiveBounds.mockReturnValue({
+ min: moment(1486656000000),
+ max: moment(1486670399999),
+ });
+
+ anomalyExplorerService = new AnomalyExplorerChartsService(
timefilterMock,
- 0,
- 12
- )) as ExplorerChartsData;
- assertAnomalyDataResult(anomalyData);
+ mlApiServicesMock as unknown as MlApiServices,
+ mlResultsServiceMock as unknown as MlResultsService
+ ) as jest.Mocked;
});
- test('call anomalyChangeListener with empty series config', async () => {
- const anomalyData = (await anomalyExplorerService.getAnomalyData(
- undefined,
- // @ts-ignore
- combinedJobRecords as unknown as Record,
- 1000,
- [],
- timeRange.earliestMs,
- timeRange.latestMs,
- timefilterMock,
- 0,
- 12
- )) as ExplorerChartsData;
- expect(anomalyData).toStrictEqual({
- ...getDefaultChartsData(),
- chartsPerRow: 2,
- });
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
});
- test('field value with trailing dot should not throw an error', async () => {
- const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords);
- mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.';
+ test('fetches anomaly charts data', () => {
+ let result;
+ anomalyExplorerService
+ .getAnomalyData$([jobId], 1000, timeRange.earliestMs, timeRange.latestMs)
+ .subscribe((d) => {
+ result = d;
+ });
- const anomalyData = (await anomalyExplorerService.getAnomalyData(
- undefined,
- combinedJobRecords as unknown as Record,
- 1000,
- mockAnomalyChartRecordsClone,
- timeRange.earliestMs,
- timeRange.latestMs,
- timefilterMock,
+ expect(mlApiServicesMock.results.getAnomalyCharts$).toHaveBeenCalledWith(
+ [jobId],
+ [],
0,
- 12
- )) as ExplorerChartsData;
- expect(anomalyData).toBeDefined();
- expect(anomalyData!.chartsPerRow).toBe(2);
- expect(Array.isArray(anomalyData!.seriesToPlot)).toBe(true);
- expect(anomalyData!.seriesToPlot.length).toBe(2);
+ 1486656000000,
+ 1486670399999,
+ { max: 1486670399999, min: 1486656000000 },
+ 6,
+ 119,
+ undefined
+ );
+ expect(result).toEqual({
+ chartsPerRow: 1,
+ errorMessages: undefined,
+ seriesToPlot: [{}],
+ // default values, will update on every re-render
+ tooManyBuckets: false,
+ timeFieldName: 'timestamp',
+ });
});
});
diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts
index ca1183129cfa1..ff89d29ca4dbb 100644
--- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts
+++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts
@@ -5,108 +5,30 @@
* 2.0.
*/
-import { each, find, get, map, reduce, sortBy } from 'lodash';
-import { i18n } from '@kbn/i18n';
import { Observable, of } from 'rxjs';
-import { catchError, map as mapObservable } from 'rxjs/operators';
-import { RecordForInfluencer } from './results_service/results_service';
-import {
- isMappableJob,
- isModelPlotChartableForDetector,
- isModelPlotEnabled,
- isSourceDataChartableForDetector,
- mlFunctionToESAggregation,
-} from '../../../common/util/job_utils';
-import { EntityField, getEntityFieldList } from '../../../common/util/anomaly_utils';
-import { CombinedJob, Datafeed, JobId } from '../../../common/types/anomaly_detection_jobs';
-import { MlApiServices } from './ml_api_service';
-import { SWIM_LANE_LABEL_WIDTH } from '../explorer/swimlane_container';
-import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types';
-import { parseInterval } from '../../../common/util/parse_interval';
-import { _DOC_COUNT, DOC_COUNT } from '../../../common/constants/field_types';
-import { getChartType, chartLimits } from '../util/chart_utils';
-import { CriteriaField, MlResultsService } from './results_service';
-import { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public';
-import { CHART_TYPE, ChartType } from '../explorer/explorer_constants';
-import type { ChartRecord } from '../explorer/explorer_utils';
-import {
- RecordsForCriteria,
- ResultResponse,
- ScheduledEventsByBucket,
-} from './results_service/result_service_rx';
+import { map as mapObservable } from 'rxjs/operators';
+import type { RecordForInfluencer } from './results_service/results_service';
+import type { EntityField } from '../../../common/util/anomaly_utils';
+import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
+import type { MlApiServices } from './ml_api_service';
+import type { MlResultsService } from './results_service';
+import type { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public';
import { isPopulatedObject } from '../../../common/util/object_utils';
-import { AnomalyRecordDoc } from '../../../common/types/anomalies';
-import {
- ExplorerChartsData,
- getDefaultChartsData,
-} from '../explorer/explorer_charts/explorer_charts_container_service';
-import { TimeRangeBounds } from '../util/time_buckets';
+import { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service';
+import type { TimeRangeBounds } from '../util/time_buckets';
import { isDefined } from '../../../common/types/guards';
-import { AppStateSelectedCells } from '../explorer/explorer_utils';
-import { InfluencersFilterQuery } from '../../../common/types/es_client';
-import { ExplorerService } from '../explorer/explorer_dashboard_service';
-const CHART_MAX_POINTS = 500;
-const ANOMALIES_MAX_RESULTS = 500;
-const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
-const ML_TIME_FIELD_NAME = 'timestamp';
-const USE_OVERALL_CHART_LIMITS = false;
-const MAX_CHARTS_PER_ROW = 4;
-
-interface ChartPoint {
- date: number;
- anomalyScore?: number;
- actual?: number[];
- multiBucketImpact?: number;
- typical?: number[];
- value?: number | null;
- entity?: string;
- byFieldName?: string;
- numberOfCauses?: number;
- scheduledEvents?: any[];
-}
-interface MetricData extends ResultResponse {
- results: Record;
-}
-interface SeriesConfig {
- jobId: JobId;
- detectorIndex: number;
- metricFunction: ML_JOB_AGGREGATION.LAT_LONG | ES_AGGREGATION | null;
- timeField: string;
- interval: string;
- datafeedConfig: Datafeed;
- summaryCountFieldName?: string;
- metricFieldName?: string;
-}
+import type { AppStateSelectedCells } from '../explorer/explorer_utils';
+import type { InfluencersFilterQuery } from '../../../common/types/es_client';
+import type { SeriesConfigWithMetadata } from '../../../common/types/results';
+import { SWIM_LANE_LABEL_WIDTH } from '../explorer/swimlane_container';
-interface InfoTooltip {
- jobId: JobId;
- aggregationInterval?: string;
- chartFunction: string;
- entityFields: EntityField[];
-}
-export interface SeriesConfigWithMetadata extends SeriesConfig {
- functionDescription?: string;
- bucketSpanSeconds: number;
- detectorLabel?: string;
- fieldName: string;
- entityFields: EntityField[];
- infoTooltip?: InfoTooltip;
- loading?: boolean;
- chartData?: ChartPoint[] | null;
- mapData?: Array;
- plotEarliest?: number;
- plotLatest?: number;
-}
+const MAX_CHARTS_PER_ROW = 4;
+const OPTIMAL_CHART_WIDTH = 550;
export const isSeriesConfigWithMetadata = (arg: unknown): arg is SeriesConfigWithMetadata => {
return isPopulatedObject(arg, ['bucketSpanSeconds', 'detectorLabel']);
};
-interface ChartRange {
- min: number;
- max: number;
-}
-
export const DEFAULT_MAX_SERIES_TO_PLOT = 6;
/**
@@ -133,261 +55,6 @@ export class AnomalyExplorerChartsService {
: this.timeFilter.getBounds();
}
- public calculateChartRange(
- seriesConfigs: SeriesConfigWithMetadata[],
- selectedEarliestMs: number,
- selectedLatestMs: number,
- chartWidth: number,
- recordsToPlot: ChartRecord[],
- timeFieldName: string,
- timeFilter: TimefilterContract
- ) {
- let tooManyBuckets = false;
- // Calculate the time range for the charts.
- // Fit in as many points in the available container width plotted at the job bucket span.
- // Look for the chart with the shortest bucket span as this determines
- // the length of the time range that can be plotted.
- const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2);
- const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000;
- const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000;
-
- const pointsToPlotFullSelection = Math.ceil(
- (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs
- );
-
- // Optimally space points 5px apart.
- const optimumPointSpacing = 5;
- const optimumNumPoints = chartWidth / optimumPointSpacing;
-
- // Increase actual number of points if we can't plot the selected range
- // at optimal point spacing.
- const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection);
- const halfPoints = Math.ceil(plotPoints / 2);
- const bounds = timeFilter.getActiveBounds();
- const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined;
- const boundsMax = bounds?.max ? bounds.max.valueOf() : undefined;
- let chartRange: ChartRange = {
- min: boundsMin
- ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin)
- : midpointMs - halfPoints * minBucketSpanMs,
- max: boundsMax
- ? Math.min(midpointMs + halfPoints * minBucketSpanMs, boundsMax)
- : midpointMs + halfPoints * minBucketSpanMs,
- };
-
- if (plotPoints > CHART_MAX_POINTS) {
- // For each series being plotted, display the record with the highest score if possible.
- const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS;
- let minMs = recordsToPlot[0][timeFieldName];
- let maxMs = recordsToPlot[0][timeFieldName];
-
- each(recordsToPlot, (record) => {
- const diffMs = maxMs - minMs;
- if (diffMs < maxTimeSpan) {
- const recordTime = record[timeFieldName];
- if (recordTime < minMs) {
- if (maxMs - recordTime <= maxTimeSpan) {
- minMs = recordTime;
- }
- }
-
- if (recordTime > maxMs) {
- if (recordTime - minMs <= maxTimeSpan) {
- maxMs = recordTime;
- }
- }
- }
- });
-
- if (maxMs - minMs < maxTimeSpan) {
- // Expand out before and after the span with the highest scoring anomalies,
- // covering as much as the requested time span as possible.
- // Work out if the high scoring region is nearer the start or end of the selected time span.
- const diff = maxTimeSpan - (maxMs - minMs);
- if (minMs - 0.5 * diff <= selectedEarliestMs) {
- minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff);
- maxMs = minMs + maxTimeSpan;
- } else {
- maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff);
- minMs = maxMs - maxTimeSpan;
- }
- }
-
- chartRange = { min: minMs, max: maxMs };
- }
-
- // Elasticsearch aggregation returns points at start of bucket,
- // so align the min to the length of the longest bucket,
- // and use the start of the latest selected bucket in the check
- // for too many selected buckets, respecting the max bounds set in the view.
- chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs;
- if (boundsMin !== undefined && chartRange.min < boundsMin) {
- chartRange.min = chartRange.min + maxBucketSpanMs;
- }
-
- // When used as an embeddable, selectedEarliestMs is the start date on the time picker,
- // which may be earlier than the time of the first point plotted in the chart (as we plot
- // the first full bucket with a start date no earlier than the start).
- const selectedEarliestBucketCeil = boundsMin
- ? Math.ceil(Math.max(selectedEarliestMs, boundsMin) / maxBucketSpanMs) * maxBucketSpanMs
- : Math.ceil(selectedEarliestMs / maxBucketSpanMs) * maxBucketSpanMs;
-
- const selectedLatestBucketStart = boundsMax
- ? Math.floor(Math.min(selectedLatestMs, boundsMax) / maxBucketSpanMs) * maxBucketSpanMs
- : Math.floor(selectedLatestMs / maxBucketSpanMs) * maxBucketSpanMs;
-
- if (
- (chartRange.min > selectedEarliestBucketCeil || chartRange.max < selectedLatestBucketStart) &&
- chartRange.max - chartRange.min < selectedLatestBucketStart - selectedEarliestBucketCeil
- ) {
- tooManyBuckets = true;
- }
-
- return {
- chartRange,
- tooManyBuckets,
- };
- }
-
- public buildConfigFromDetector(job: CombinedJob, detectorIndex: number) {
- const analysisConfig = job.analysis_config;
- const detector = analysisConfig.detectors[detectorIndex];
-
- const config: SeriesConfig = {
- jobId: job.job_id,
- detectorIndex,
- metricFunction:
- detector.function === ML_JOB_AGGREGATION.LAT_LONG
- ? ML_JOB_AGGREGATION.LAT_LONG
- : mlFunctionToESAggregation(detector.function),
- timeField: job.data_description.time_field!,
- interval: job.analysis_config.bucket_span,
- datafeedConfig: job.datafeed_config,
- summaryCountFieldName: job.analysis_config.summary_count_field_name,
- metricFieldName: undefined,
- };
-
- if (detector.field_name !== undefined) {
- config.metricFieldName = detector.field_name;
- }
-
- // Extra checks if the job config uses a summary count field.
- const summaryCountFieldName = analysisConfig.summary_count_field_name;
- if (
- config.metricFunction === ES_AGGREGATION.COUNT &&
- summaryCountFieldName !== undefined &&
- summaryCountFieldName !== DOC_COUNT &&
- summaryCountFieldName !== _DOC_COUNT
- ) {
- // Check for a detector looking at cardinality (distinct count) using an aggregation.
- // The cardinality field will be in:
- // aggregations//aggregations//cardinality/field
- // or aggs//aggs//cardinality/field
- let cardinalityField;
- const topAgg = get(job.datafeed_config, 'aggregations') || get(job.datafeed_config, 'aggs');
- if (topAgg !== undefined && Object.values(topAgg).length > 0) {
- cardinalityField =
- get(Object.values(topAgg)[0], [
- 'aggregations',
- summaryCountFieldName,
- ES_AGGREGATION.CARDINALITY,
- 'field',
- ]) ||
- get(Object.values(topAgg)[0], [
- 'aggs',
- summaryCountFieldName,
- ES_AGGREGATION.CARDINALITY,
- 'field',
- ]);
- }
- if (
- (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT ||
- detector.function === ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT ||
- detector.function === ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT ||
- detector.function === ML_JOB_AGGREGATION.COUNT ||
- detector.function === ML_JOB_AGGREGATION.HIGH_COUNT ||
- detector.function === ML_JOB_AGGREGATION.LOW_COUNT) &&
- cardinalityField !== undefined
- ) {
- config.metricFunction = ES_AGGREGATION.CARDINALITY;
- config.metricFieldName = undefined;
- } else {
- // For count detectors using summary_count_field, plot sum(summary_count_field_name)
- config.metricFunction = ES_AGGREGATION.SUM;
- config.metricFieldName = summaryCountFieldName;
- }
- }
-
- return config;
- }
-
- public buildConfig(record: ChartRecord, job: CombinedJob): SeriesConfigWithMetadata {
- const detectorIndex = record.detector_index;
- const config: Omit<
- SeriesConfigWithMetadata,
- 'bucketSpanSeconds' | 'detectorLabel' | 'fieldName' | 'entityFields' | 'infoTooltip'
- > = {
- ...this.buildConfigFromDetector(job, detectorIndex),
- };
-
- const fullSeriesConfig: SeriesConfigWithMetadata = {
- bucketSpanSeconds: 0,
- entityFields: [],
- fieldName: '',
- ...config,
- };
- // Add extra properties used by the explorer dashboard charts.
- fullSeriesConfig.functionDescription = record.function_description;
-
- const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span);
- if (parsedBucketSpan !== null) {
- fullSeriesConfig.bucketSpanSeconds = parsedBucketSpan.asSeconds();
- }
-
- fullSeriesConfig.detectorLabel = record.function;
- const jobDetectors = job.analysis_config.detectors;
- if (jobDetectors) {
- fullSeriesConfig.detectorLabel = jobDetectors[detectorIndex].detector_description;
- } else {
- if (record.field_name !== undefined) {
- fullSeriesConfig.detectorLabel += ` ${fullSeriesConfig.fieldName}`;
- }
- }
-
- if (record.field_name !== undefined) {
- fullSeriesConfig.fieldName = record.field_name;
- fullSeriesConfig.metricFieldName = record.field_name;
- }
-
- // Add the 'entity_fields' i.e. the partition, by, over fields which
- // define the metric series to be plotted.
- fullSeriesConfig.entityFields = getEntityFieldList(record);
-
- if (record.function === ML_JOB_AGGREGATION.METRIC) {
- fullSeriesConfig.metricFunction = mlFunctionToESAggregation(record.function_description);
- }
-
- // Build the tooltip data for the chart info icon, showing further details on what is being plotted.
- let functionLabel = `${config.metricFunction}`;
- if (
- fullSeriesConfig.metricFieldName !== undefined &&
- fullSeriesConfig.metricFieldName !== null
- ) {
- functionLabel += ` ${fullSeriesConfig.metricFieldName}`;
- }
-
- fullSeriesConfig.infoTooltip = {
- jobId: record.job_id,
- aggregationInterval: fullSeriesConfig.interval,
- chartFunction: functionLabel,
- entityFields: fullSeriesConfig.entityFields.map((f) => ({
- fieldName: f.fieldName,
- fieldValue: f.fieldValue,
- })),
- };
-
- return fullSeriesConfig;
- }
public async getCombinedJobs(jobIds: string[]): Promise {
const combinedResults = await Promise.all(
// Getting only necessary job config and datafeed config without the stats
@@ -404,14 +71,10 @@ export class AnomalyExplorerChartsService {
earliestMs: number,
latestMs: number,
influencers: EntityField[] = [],
- selectedCells: AppStateSelectedCells | undefined,
+ selectedCells: AppStateSelectedCells | undefined | null,
influencersFilterQuery: InfluencersFilterQuery
): Observable {
- if (
- selectedCells === undefined &&
- influencers.length === 0 &&
- influencersFilterQuery === undefined
- ) {
+ if (!selectedCells && influencers.length === 0 && influencersFilterQuery === undefined) {
of([]);
}
@@ -427,10 +90,7 @@ export class AnomalyExplorerChartsService {
)
.pipe(
mapObservable((resp): RecordForInfluencer[] => {
- if (
- (selectedCells !== undefined && Object.keys(selectedCells).length > 0) ||
- influencersFilterQuery !== undefined
- ) {
+ if (isPopulatedObject(selectedCells) || influencersFilterQuery !== undefined) {
return resp.records;
}
@@ -439,751 +99,60 @@ export class AnomalyExplorerChartsService {
);
}
- public async getAnomalyData(
- explorerService: ExplorerService | undefined,
- combinedJobRecords: Record,
+ public getAnomalyData$(
+ jobIds: string[],
chartsContainerWidth: number,
- anomalyRecords: ChartRecord[] | undefined,
selectedEarliestMs: number,
selectedLatestMs: number,
- timefilter: TimefilterContract,
+ influencerFilterQuery?: InfluencersFilterQuery,
+ influencers?: EntityField[],
severity = 0,
- maxSeries = DEFAULT_MAX_SERIES_TO_PLOT
- ): Promise {
- const data = getDefaultChartsData();
+ maxSeries?: number
+ ): Observable {
+ const bounds = this.timeFilter.getActiveBounds();
+ const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined;
+ const boundsMax = bounds?.max ? bounds.max.valueOf() : undefined;
- const containerWith = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH;
- if (anomalyRecords === undefined) return;
- const filteredRecords = anomalyRecords.filter((record) => {
- return Number(record.record_score) >= severity;
- });
- const { records: allSeriesRecords, errors: errorMessages } = this.processRecordsForDisplay(
- combinedJobRecords,
- filteredRecords
- );
+ const containerWidth = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH;
- if (!Array.isArray(allSeriesRecords)) return;
// Calculate the number of charts per row, depending on the width available, to a max of 4.
- let chartsPerRow = Math.min(Math.max(Math.floor(containerWith / 550), 1), MAX_CHARTS_PER_ROW);
-
- // Expand the chart to full size if there's only one viewable chart
- if (allSeriesRecords.length === 1 || maxSeries === 1) {
- chartsPerRow = 1;
- }
+ let chartsPerRow = Math.min(
+ Math.max(Math.floor(containerWidth / OPTIMAL_CHART_WIDTH), 1),
+ MAX_CHARTS_PER_ROW
+ );
// Expand the charts to not have blank space in the row if necessary
- if (maxSeries < chartsPerRow) {
+ if (maxSeries && maxSeries < chartsPerRow) {
chartsPerRow = maxSeries;
}
- data.chartsPerRow = chartsPerRow;
-
- // Build the data configs of the anomalies to be displayed.
- // TODO - implement paging?
- // For now just take first 6 (or 8 if 4 charts per row).
- const maxSeriesToPlot = maxSeries ?? Math.max(chartsPerRow * 2, 6);
- const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot);
- const hasGeoData = recordsToPlot.find(
- (record) => (record.function_description || record.function) === ML_JOB_AGGREGATION.LAT_LONG
- );
- const seriesConfigs = recordsToPlot.map((record) =>
- this.buildConfig(record, combinedJobRecords[record.job_id])
- );
- const seriesConfigsNoGeoData = [];
- // initialize the charts with loading indicators
- data.seriesToPlot = seriesConfigs.map((config) => ({
- ...config,
- loading: true,
- chartData: null,
- }));
-
- const mapData: SeriesConfigWithMetadata[] = [];
-
- if (hasGeoData !== undefined) {
- for (let i = 0; i < seriesConfigs.length; i++) {
- const config = seriesConfigs[i];
- let records;
- if (
- (config.detectorLabel !== undefined &&
- config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) ||
- config?.metricFunction === ML_JOB_AGGREGATION.LAT_LONG
- ) {
- if (config.entityFields.length) {
- records = [
- recordsToPlot.find((record) => {
- const entityFieldName = config.entityFields[0].fieldName;
- const entityFieldValue = config.entityFields[0].fieldValue;
- return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue;
- }),
- ];
- } else {
- records = recordsToPlot;
- }
-
- mapData.push({
- ...config,
- loading: false,
- mapData: records,
- });
- } else {
- seriesConfigsNoGeoData.push(config);
- }
- }
- }
-
- // Calculate the time range of the charts, which is a function of the chart width and max job bucket span.
- data.tooManyBuckets = false;
- const chartWidth = Math.floor(containerWith / chartsPerRow);
- const { chartRange, tooManyBuckets } = this.calculateChartRange(
- seriesConfigs as SeriesConfigWithMetadata[],
- selectedEarliestMs,
- selectedLatestMs,
- chartWidth,
- recordsToPlot,
- data.timeFieldName,
- timefilter
- );
- data.tooManyBuckets = tooManyBuckets;
-
- if (errorMessages) {
- data.errorMessages = errorMessages;
- }
-
- // TODO: replace this temporary fix for flickering issue
- // https://github.com/elastic/kibana/issues/97266
- if (explorerService) {
- explorerService.setCharts({ ...data });
- }
- if (seriesConfigs.length === 0) {
- return data;
- }
-
- function handleError(errorMsg: string, jobId: string): void {
- // Group the jobIds by the type of error message
- if (!data.errorMessages) {
- data.errorMessages = {};
- }
-
- if (data.errorMessages[errorMsg]) {
- data.errorMessages[errorMsg].add(jobId);
- } else {
- data.errorMessages[errorMsg] = new Set([jobId]);
- }
- }
-
- // Query 1 - load the raw metric data.
- function getMetricData(
- mlResultsService: MlResultsService,
- config: SeriesConfigWithMetadata,
- range: ChartRange
- ): Promise {
- const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config;
-
- const job = combinedJobRecords[jobId];
-
- // If the job uses aggregation or scripted fields, and if it's a config we don't support
- // use model plot data if model plot is enabled
- // else if source data can be plotted, use that, otherwise model plot will be available.
- const useSourceData = isSourceDataChartableForDetector(job, detectorIndex);
- if (useSourceData === true) {
- const datafeedQuery = get(config, 'datafeedConfig.query', null);
- return mlResultsService
- .getMetricData(
- Array.isArray(config.datafeedConfig.indices)
- ? config.datafeedConfig.indices.join()
- : config.datafeedConfig.indices,
- entityFields,
- datafeedQuery,
- config.metricFunction,
- config.metricFieldName,
- config.summaryCountFieldName,
- config.timeField,
- range.min,
- range.max,
- bucketSpanSeconds * 1000,
- config.datafeedConfig
- )
- .pipe(
- catchError((error) => {
- handleError(
- i18n.translate('xpack.ml.timeSeriesJob.metricDataErrorMessage', {
- defaultMessage: 'an error occurred while retrieving metric data',
- }),
- job.job_id
- );
- return of({ success: false, results: {}, error });
- })
- )
- .toPromise();
- } else {
- // Extract the partition, by, over fields on which to filter.
- const criteriaFields: CriteriaField[] = [];
- const detector = job.analysis_config.detectors[detectorIndex];
- if (detector.partition_field_name !== undefined) {
- const partitionEntity = find(entityFields, {
- fieldName: detector.partition_field_name,
- });
- if (partitionEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
- { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
- );
- }
- }
-
- if (detector.over_field_name !== undefined) {
- const overEntity = find(entityFields, { fieldName: detector.over_field_name });
- if (overEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
- { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
- );
- }
- }
-
- if (detector.by_field_name !== undefined) {
- const byEntity = find(entityFields, { fieldName: detector.by_field_name });
- if (byEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
- { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
- );
- }
- }
-
- return new Promise((resolve, reject) => {
- const obj = {
- success: true,
- results: {} as Record,
- };
-
- return mlResultsService
- .getModelPlotOutput(
- jobId,
- detectorIndex,
- criteriaFields,
- range.min,
- range.max,
- bucketSpanSeconds * 1000
- )
- .toPromise()
- .then((resp) => {
- // Return data in format required by the explorer charts.
- const results = resp.results;
- Object.keys(results).forEach((time) => {
- obj.results[time] = results[time].actual;
- });
- resolve(obj);
- })
- .catch((error) => {
- handleError(
- i18n.translate('xpack.ml.timeSeriesJob.modelPlotDataErrorMessage', {
- defaultMessage: 'an error occurred while retrieving model plot data',
- }),
- job.job_id
- );
-
- reject(error);
- });
- });
- }
- }
-
- // Query 2 - load the anomalies.
- // Criteria to return the records for this series are the detector_index plus
- // the specific combination of 'entity' fields i.e. the partition / by / over fields.
- function getRecordsForCriteria(
- mlResultsService: MlResultsService,
- config: SeriesConfigWithMetadata,
- range: ChartRange
- ) {
- let criteria: EntityField[] = [];
- criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex });
- criteria = criteria.concat(config.entityFields);
- return mlResultsService
- .getRecordsForCriteria(
- [config.jobId],
- criteria,
- 0,
- range.min,
- range.max,
- ANOMALIES_MAX_RESULTS
- )
- .pipe(
- catchError((error) => {
- handleError(
- i18n.translate('xpack.ml.timeSeriesJob.recordsForCriteriaErrorMessage', {
- defaultMessage: 'an error occurred while retrieving anomaly records',
- }),
- config.jobId
- );
- return of({ success: false, records: [], error });
- })
- )
- .toPromise();
- }
-
- // Query 3 - load any scheduled events for the job.
- function getScheduledEvents(
- mlResultsService: MlResultsService,
- config: SeriesConfigWithMetadata,
- range: ChartRange
- ) {
- // FIXME performs an API call per chart. should perform 1 call for all charts
- return mlResultsService
- .getScheduledEventsByBucket(
- [config.jobId],
- range.min,
- range.max,
- config.bucketSpanSeconds * 1000,
- 1,
- MAX_SCHEDULED_EVENTS
- )
- .pipe(
- catchError((error) => {
- handleError(
- i18n.translate('xpack.ml.timeSeriesJob.scheduledEventsByBucketErrorMessage', {
- defaultMessage: 'an error occurred while retrieving scheduled events',
- }),
- config.jobId
- );
- return of({ success: false, events: {}, error });
- })
- )
- .toPromise();
- }
-
- // Query 4 - load context data distribution
- function getEventDistribution(
- mlResultsService: MlResultsService,
- config: SeriesConfigWithMetadata,
- range: ChartRange
- ) {
- const chartType = getChartType(config);
+ const chartWidth = Math.floor(containerWidth / chartsPerRow);
- let splitField;
- let filterField = null;
+ const optimumPointSpacing = 5;
+ const optimumNumPoints = Math.ceil(chartWidth / optimumPointSpacing);
- // Define splitField and filterField based on chartType
- if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
- splitField = config.entityFields.find((f) => f.fieldType === 'by');
- filterField = config.entityFields.find((f) => f.fieldType === 'partition');
- } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
- splitField = config.entityFields.find((f) => f.fieldType === 'over');
- filterField = config.entityFields.find((f) => f.fieldType === 'partition');
- }
+ const maxSeriesToPlot = maxSeries ?? Math.max(chartsPerRow * 2, DEFAULT_MAX_SERIES_TO_PLOT);
- const datafeedQuery = get(config, 'datafeedConfig.query', null);
+ return this.mlApiServices.results
+ .getAnomalyCharts$(
+ jobIds,
+ influencers ?? [],
+ severity,
+ selectedEarliestMs,
+ selectedLatestMs,
+ { min: boundsMin, max: boundsMax },
+ maxSeriesToPlot,
+ optimumNumPoints,
+ influencerFilterQuery
+ )
+ .pipe(
+ mapObservable((data) => {
+ chartsPerRow = Math.min(data.seriesToPlot.length, chartsPerRow);
- return mlResultsService
- .getEventDistributionData(
- Array.isArray(config.datafeedConfig.indices)
- ? config.datafeedConfig.indices.join()
- : config.datafeedConfig.indices,
- splitField,
- filterField,
- datafeedQuery,
- config.metricFunction,
- config.metricFieldName,
- config.timeField,
- range.min,
- range.max,
- config.bucketSpanSeconds * 1000
- )
- .catch((err) => {
- handleError(
- i18n.translate('xpack.ml.timeSeriesJob.eventDistributionDataErrorMessage', {
- defaultMessage: 'an error occurred while retrieving data',
- }),
- config.jobId
- );
- });
- }
+ data.chartsPerRow = chartsPerRow;
- // first load and wait for required data,
- // only after that trigger data processing and page render.
- // TODO - if query returns no results e.g. source data has been deleted,
- // display a message saying 'No data between earliest/latest'.
- const seriesPromises: Array<
- Promise<[MetricData, RecordsForCriteria, ScheduledEventsByBucket, any]>
- > = [];
- // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses
- const seriesConfigsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs;
- seriesConfigsForPromises.forEach((seriesConfig) => {
- seriesPromises.push(
- Promise.all([
- getMetricData(this.mlResultsService, seriesConfig, chartRange),
- getRecordsForCriteria(this.mlResultsService, seriesConfig, chartRange),
- getScheduledEvents(this.mlResultsService, seriesConfig, chartRange),
- getEventDistribution(this.mlResultsService, seriesConfig, chartRange),
- ])
+ return data;
+ })
);
- });
- function processChartData(
- response: [MetricData, RecordsForCriteria, ScheduledEventsByBucket, any],
- seriesIndex: number
- ) {
- const metricData = response[0].results;
- const records = response[1].records;
- const jobId = seriesConfigsForPromises[seriesIndex].jobId;
- const scheduledEvents = response[2].events[jobId];
- const eventDistribution = response[3];
- const chartType = getChartType(seriesConfigsForPromises[seriesIndex]);
-
- // Sort records in ascending time order matching up with chart data
- records.sort((recordA, recordB) => {
- return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME];
- });
-
- // Return dataset in format used by the chart.
- // i.e. array of Objects with keys date (timestamp), value,
- // plus anomalyScore for points with anomaly markers.
- let chartData: ChartPoint[] = [];
- if (metricData !== undefined) {
- if (records.length > 0) {
- const filterField = records[0].by_field_value || records[0].over_field_value;
- if (eventDistribution.length > 0) {
- chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField);
- }
- map(metricData, (value, time) => {
- // The filtering for rare/event_distribution charts needs to be handled
- // differently because of how the source data is structured.
- // For rare chart values we are only interested wether a value is either `0` or not,
- // `0` acts like a flag in the chart whether to display the dot/marker.
- // All other charts (single metric, population) are metric based and with
- // those a value of `null` acts as the flag to hide a data point.
- if (
- (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) ||
- (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null)
- ) {
- chartData.push({
- date: +time,
- value,
- entity: filterField,
- });
- }
- });
- } else {
- chartData = map(metricData, (value, time) => ({
- date: +time,
- value,
- }));
- }
- }
-
- // Iterate through the anomaly records, adding anomalyScore properties
- // to the chartData entries for anomalous buckets.
- const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType);
- each(records, (record) => {
- // Look for a chart point with the same time as the record.
- // If none found, insert a point for anomalies due to a gap in the data.
- const recordTime = record[ML_TIME_FIELD_NAME];
- let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime);
- if (chartPoint === undefined) {
- chartPoint = { date: recordTime, value: null };
- chartData.push(chartPoint);
- }
- if (chartPoint !== undefined) {
- chartPoint.anomalyScore = record.record_score;
-
- if (record.actual !== undefined) {
- chartPoint.actual = record.actual;
- chartPoint.typical = record.typical;
- } else {
- const causes = get(record, 'causes', []);
- if (causes.length > 0) {
- chartPoint.byFieldName = record.by_field_name;
- chartPoint.numberOfCauses = causes.length;
- if (causes.length === 1) {
- // If only a single cause, copy actual and typical values to the top level.
- const cause = record.causes[0];
- chartPoint.actual = cause.actual;
- chartPoint.typical = cause.typical;
- }
- }
- }
-
- if (record.multi_bucket_impact !== undefined) {
- chartPoint.multiBucketImpact = record.multi_bucket_impact;
- }
- }
- });
-
- // Add a scheduledEvents property to any points in the chart data set
- // which correspond to times of scheduled events for the job.
- if (scheduledEvents !== undefined) {
- each(scheduledEvents, (events, time) => {
- const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time));
- if (chartPoint !== undefined) {
- // Note if the scheduled event coincides with an absence of the underlying metric data,
- // we don't worry about plotting the event.
- chartPoint.scheduledEvents = events;
- }
- });
- }
-
- return chartData;
- }
-
- function getChartDataForPointSearch(
- chartData: ChartPoint[],
- record: AnomalyRecordDoc,
- chartType: ChartType
- ) {
- if (
- chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
- chartType === CHART_TYPE.POPULATION_DISTRIBUTION
- ) {
- return chartData.filter((d) => {
- return d.entity === (record && (record.by_field_value || record.over_field_value));
- });
- }
-
- return chartData;
- }
-
- function findChartPointForTime(chartData: ChartPoint[], time: number) {
- return chartData.find((point) => point.date === time);
- }
-
- return Promise.all(seriesPromises)
- .then((response) => {
- // calculate an overall min/max for all series
- const processedData = response.map(processChartData);
- const allDataPoints = reduce(
- processedData,
- (datapoints, series) => {
- each(series, (d) => datapoints.push(d));
- return datapoints;
- },
- [] as ChartPoint[]
- );
- const overallChartLimits = chartLimits(allDataPoints);
-
- data.seriesToPlot = response
- // Don't show the charts if there was an issue retrieving metric or anomaly data
- .filter((r) => r[0]?.success === true && r[1]?.success === true)
- .map((d, i) => {
- return {
- ...seriesConfigsForPromises[i],
- loading: false,
- chartData: processedData[i],
- plotEarliest: chartRange.min,
- plotLatest: chartRange.max,
- selectedEarliest: selectedEarliestMs,
- selectedLatest: selectedLatestMs,
- chartLimits: USE_OVERALL_CHART_LIMITS
- ? overallChartLimits
- : chartLimits(processedData[i]),
- };
- });
-
- if (mapData.length) {
- // push map data in if it's available
- data.seriesToPlot.push(...mapData);
- }
-
- // TODO: replace this temporary fix for flickering issue
- if (explorerService) {
- explorerService.setCharts({ ...data });
- }
-
- return Promise.resolve(data);
- })
- .catch((error) => {
- // eslint-disable-next-line no-console
- console.error(error);
- });
- }
-
- public processRecordsForDisplay(
- combinedJobRecords: Record,
- anomalyRecords: RecordForInfluencer[]
- ): { records: ChartRecord[]; errors: Record> | undefined } {
- // Aggregate the anomaly data by detector, and entity (by/over/partition).
- if (anomalyRecords.length === 0) {
- return { records: [], errors: undefined };
- }
- // Aggregate by job, detector, and analysis fields (partition, by, over).
- const aggregatedData: Record = {};
-
- const jobsErrorMessage: Record = {};
- each(anomalyRecords, (record) => {
- // Check if we can plot a chart for this record, depending on whether the source data
- // is chartable, and if model plot is enabled for the job.
-
- const job = combinedJobRecords[record.job_id];
-
- // if we already know this job has datafeed aggregations we cannot support
- // no need to do more checks
- if (jobsErrorMessage[record.job_id] !== undefined) {
- return;
- }
-
- let isChartable =
- isSourceDataChartableForDetector(job, record.detector_index) ||
- isMappableJob(job, record.detector_index);
-
- if (isChartable === false) {
- if (isModelPlotChartableForDetector(job, record.detector_index)) {
- // Check if model plot is enabled for this job.
- // Need to check the entity fields for the record in case the model plot config has a terms list.
- const entityFields = getEntityFieldList(record);
- if (isModelPlotEnabled(job, record.detector_index, entityFields)) {
- isChartable = true;
- } else {
- isChartable = false;
- jobsErrorMessage[record.job_id] = i18n.translate(
- 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage',
- {
- defaultMessage:
- 'source data is not viewable for this detector and model plot is disabled',
- }
- );
- }
- } else {
- jobsErrorMessage[record.job_id] = i18n.translate(
- 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage',
- {
- defaultMessage: 'both source data and model plot are not chartable for this detector',
- }
- );
- }
- }
-
- if (isChartable === false) {
- return;
- }
- const jobId = record.job_id;
- if (aggregatedData[jobId] === undefined) {
- aggregatedData[jobId] = {};
- }
- const detectorsForJob = aggregatedData[jobId];
-
- const detectorIndex = record.detector_index;
- if (detectorsForJob[detectorIndex] === undefined) {
- detectorsForJob[detectorIndex] = {};
- }
-
- // TODO - work out how best to display results from detectors with just an over field.
- const firstFieldName =
- record.partition_field_name || record.by_field_name || record.over_field_name;
- const firstFieldValue =
- record.partition_field_value || record.by_field_value || record.over_field_value;
- if (firstFieldName !== undefined && firstFieldValue !== undefined) {
- const groupsForDetector = detectorsForJob[detectorIndex];
-
- if (groupsForDetector[firstFieldName] === undefined) {
- groupsForDetector[firstFieldName] = {};
- }
- const valuesForGroup: Record = groupsForDetector[firstFieldName];
- if (valuesForGroup[firstFieldValue] === undefined) {
- valuesForGroup[firstFieldValue] = {};
- }
-
- const dataForGroupValue = valuesForGroup[firstFieldValue];
-
- let isSecondSplit = false;
- if (record.partition_field_name !== undefined) {
- const splitFieldName = record.over_field_name || record.by_field_name;
- if (splitFieldName !== undefined) {
- isSecondSplit = true;
- }
- }
-
- if (isSecondSplit === false) {
- if (dataForGroupValue.maxScoreRecord === undefined) {
- dataForGroupValue.maxScore = record.record_score;
- dataForGroupValue.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForGroupValue.maxScore) {
- dataForGroupValue.maxScore = record.record_score;
- dataForGroupValue.maxScoreRecord = record;
- }
- }
- } else {
- // Aggregate another level for the over or by field.
- const secondFieldName = record.over_field_name || record.by_field_name;
- const secondFieldValue = record.over_field_value || record.by_field_value;
-
- if (secondFieldName !== undefined && secondFieldValue !== undefined) {
- if (dataForGroupValue[secondFieldName] === undefined) {
- dataForGroupValue[secondFieldName] = {};
- }
-
- const splitsForGroup = dataForGroupValue[secondFieldName];
- if (splitsForGroup[secondFieldValue] === undefined) {
- splitsForGroup[secondFieldValue] = {};
- }
-
- const dataForSplitValue = splitsForGroup[secondFieldValue];
- if (dataForSplitValue.maxScoreRecord === undefined) {
- dataForSplitValue.maxScore = record.record_score;
- dataForSplitValue.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForSplitValue.maxScore) {
- dataForSplitValue.maxScore = record.record_score;
- dataForSplitValue.maxScoreRecord = record;
- }
- }
- }
- }
- } else {
- // Detector with no partition or by field.
- const dataForDetector = detectorsForJob[detectorIndex];
- if (dataForDetector.maxScoreRecord === undefined) {
- dataForDetector.maxScore = record.record_score;
- dataForDetector.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForDetector.maxScore) {
- dataForDetector.maxScore = record.record_score;
- dataForDetector.maxScoreRecord = record;
- }
- }
- }
- });
-
- // Group job id by error message instead of by job:
- const errorMessages: Record> | undefined = {};
- Object.keys(jobsErrorMessage).forEach((jobId) => {
- const msg = jobsErrorMessage[jobId];
- if (errorMessages[msg] === undefined) {
- errorMessages[msg] = new Set([jobId]);
- } else {
- errorMessages[msg].add(jobId);
- }
- });
- let recordsForSeries: ChartRecord[] = [];
- // Convert to an array of the records with the highest record_score per unique series.
- each(aggregatedData, (detectorsForJob) => {
- each(detectorsForJob, (groupsForDetector) => {
- if (groupsForDetector.errorMessage !== undefined) {
- recordsForSeries.push(groupsForDetector.errorMessage);
- } else {
- if (groupsForDetector.maxScoreRecord !== undefined) {
- // Detector with no partition / by field.
- recordsForSeries.push(groupsForDetector.maxScoreRecord);
- } else {
- each(groupsForDetector, (valuesForGroup) => {
- each(valuesForGroup, (dataForGroupValue) => {
- if (dataForGroupValue.maxScoreRecord !== undefined) {
- recordsForSeries.push(dataForGroupValue.maxScoreRecord);
- } else {
- // Second level of aggregation for partition and by/over.
- each(dataForGroupValue, (splitsForGroup) => {
- each(splitsForGroup, (dataForSplitValue) => {
- recordsForSeries.push(dataForSplitValue.maxScoreRecord);
- });
- });
- }
- });
- });
- }
- }
- });
- });
- recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse();
-
- return { records: recordsForSeries, errors: errorMessages };
}
}
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts
index 0d4c5eec81f86..ea84fcf72f2da 100644
--- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts
+++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts
@@ -22,6 +22,10 @@ import type {
} from '../../../../../../../src/core/types/elasticsearch';
import type { MLAnomalyDoc } from '../../../../common/types/anomalies';
import type { EntityField } from '../../../../common/util/anomaly_utils';
+import type { InfluencersFilterQuery } from '../../../../common/types/es_client';
+import type { ExplorerChartsData } from '../../../../common/types/results';
+
+export type ResultsApiService = ReturnType;
export const resultsApiProvider = (httpService: HttpService) => ({
getAnomaliesTableData(
@@ -163,4 +167,33 @@ export const resultsApiProvider = (httpService: HttpService) => ({
body,
});
},
+
+ getAnomalyCharts$(
+ jobIds: string[],
+ influencers: EntityField[],
+ threshold: number,
+ earliestMs: number,
+ latestMs: number,
+ timeBounds: { min?: number; max?: number },
+ maxResults: number,
+ numberOfPoints: number,
+ influencersFilterQuery?: InfluencersFilterQuery
+ ) {
+ const body = JSON.stringify({
+ jobIds,
+ influencers,
+ threshold,
+ earliestMs,
+ latestMs,
+ maxResults,
+ influencersFilterQuery,
+ numberOfPoints,
+ timeBounds,
+ });
+ return httpService.http$({
+ path: `${basePath()}/results/anomaly_charts`,
+ method: 'POST',
+ body,
+ });
+ },
});
diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts
index d6fef2f0a9657..e18eb309a1987 100644
--- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts
+++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts
@@ -12,7 +12,7 @@ import { InfluencersFilterQuery } from '../../../../common/types/es_client';
import { EntityField } from '../../../../common/util/anomaly_utils';
import { RuntimeMappings } from '../../../../common/types/fields';
-type RecordForInfluencer = AnomalyRecordDoc;
+export type RecordForInfluencer = AnomalyRecordDoc;
export function resultsServiceProvider(mlApiServices: MlApiServices): {
getScoresByBucket(
jobIds: string[],
diff --git a/x-pack/plugins/ml/public/application/services/state_service.ts b/x-pack/plugins/ml/public/application/services/state_service.ts
new file mode 100644
index 0000000000000..7adaac64e608f
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/services/state_service.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Subscription } from 'rxjs';
+
+export abstract class StateService {
+ private subscriptions$: Subscription = new Subscription();
+
+ protected _init() {
+ this.subscriptions$ = this._initSubscriptions();
+ }
+
+ /**
+ * Should return all active subscriptions.
+ * @protected
+ */
+ protected abstract _initSubscriptions(): Subscription;
+
+ public destroy() {
+ this.subscriptions$.unsubscribe();
+ }
+}
diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts
index bfb3b03d8024a..93f1d4ed3b896 100644
--- a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts
+++ b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts
@@ -9,7 +9,3 @@ import type { ChartType } from '../explorer/explorer_constants';
export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number;
export declare function getChartType(config: any): ChartType;
-export declare function chartLimits(data: any[]): {
- min: number;
- max: number;
-};
diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js
index dacdb5e5d5d10..b05c8b20b22ca 100644
--- a/x-pack/plugins/ml/public/application/util/chart_utils.js
+++ b/x-pack/plugins/ml/public/application/util/chart_utils.js
@@ -13,57 +13,12 @@ import { CHART_TYPE } from '../explorer/explorer_constants';
import { ML_PAGES } from '../../../common/constants/locator';
export const LINE_CHART_ANOMALY_RADIUS = 7;
-export const MULTI_BUCKET_SYMBOL_SIZE = 100; // In square pixels for use with d3 symbol.size
export const SCHEDULED_EVENT_SYMBOL_HEIGHT = 5;
export const ANNOTATION_SYMBOL_HEIGHT = 10;
+export const MULTI_BUCKET_SYMBOL_SIZE = 100; // In square pixels for use with d3 symbol.size
const MAX_LABEL_WIDTH = 100;
-export function chartLimits(data = []) {
- const domain = d3.extent(data, (d) => {
- let metricValue = d.value;
- if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
- // If an anomaly coincides with a gap in the data, use the anomaly actual value.
- metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
- }
- return metricValue;
- });
- const limits = { max: domain[1], min: domain[0] };
-
- if (limits.max === limits.min) {
- limits.max = d3.max(data, (d) => {
- if (d.typical) {
- return Math.max(d.value, d.typical);
- } else {
- // If analysis with by and over field, and more than one cause,
- // there will be no actual and typical value.
- // TODO - produce a better visual for population analyses.
- return d.value;
- }
- });
- limits.min = d3.min(data, (d) => {
- if (d.typical) {
- return Math.min(d.value, d.typical);
- } else {
- // If analysis with by and over field, and more than one cause,
- // there will be no actual and typical value.
- // TODO - produce a better visual for population analyses.
- return d.value;
- }
- });
- }
-
- // add padding of 5% of the difference between max and min
- // if we ended up with the same value for both of them
- if (limits.max === limits.min) {
- const padding = limits.max * 0.05;
- limits.max += padding;
- limits.min -= padding;
- }
-
- return limits;
-}
-
export function chartExtendedLimits(data = [], functionDescription) {
let _min = Infinity;
let _max = -Infinity;
diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.test.js b/x-pack/plugins/ml/public/application/util/chart_utils.test.js
index 41f4fe76109a5..0900bfacd354e 100644
--- a/x-pack/plugins/ml/public/application/util/chart_utils.test.js
+++ b/x-pack/plugins/ml/public/application/util/chart_utils.test.js
@@ -34,7 +34,6 @@ import React from 'react';
import { render } from '@testing-library/react';
import {
- chartLimits,
getChartType,
getTickValues,
getXTransform,
@@ -54,91 +53,6 @@ timefilter.setTime({
});
describe('ML - chart utils', () => {
- describe('chartLimits', () => {
- test('returns NaN when called without data', () => {
- const limits = chartLimits();
- expect(limits.min).toBeNaN();
- expect(limits.max).toBeNaN();
- });
-
- test('returns {max: 625736376, min: 201039318} for some test data', () => {
- const data = [
- {
- date: new Date('2017-02-23T08:00:00.000Z'),
- value: 228243469,
- anomalyScore: 63.32916,
- numberOfCauses: 1,
- actual: [228243469],
- typical: [133107.7703441773],
- },
- { date: new Date('2017-02-23T09:00:00.000Z'), value: null },
- { date: new Date('2017-02-23T10:00:00.000Z'), value: null },
- { date: new Date('2017-02-23T11:00:00.000Z'), value: null },
- {
- date: new Date('2017-02-23T12:00:00.000Z'),
- value: 625736376,
- anomalyScore: 97.32085,
- numberOfCauses: 1,
- actual: [625736376],
- typical: [132830.424736973],
- },
- {
- date: new Date('2017-02-23T13:00:00.000Z'),
- value: 201039318,
- anomalyScore: 59.83488,
- numberOfCauses: 1,
- actual: [201039318],
- typical: [132739.5267403542],
- },
- ];
-
- const limits = chartLimits(data);
-
- // {max: 625736376, min: 201039318}
- expect(limits.min).toBe(201039318);
- expect(limits.max).toBe(625736376);
- });
-
- test("adds 5% padding when min/max are the same, e.g. when there's only one data point", () => {
- const data = [
- {
- date: new Date('2017-02-23T08:00:00.000Z'),
- value: 100,
- anomalyScore: 50,
- numberOfCauses: 1,
- actual: [100],
- typical: [100],
- },
- ];
-
- const limits = chartLimits(data);
- expect(limits.min).toBe(95);
- expect(limits.max).toBe(105);
- });
-
- test('returns minimum of 0 when data includes an anomaly for missing data', () => {
- const data = [
- { date: new Date('2017-02-23T09:00:00.000Z'), value: 22.2 },
- { date: new Date('2017-02-23T10:00:00.000Z'), value: 23.3 },
- { date: new Date('2017-02-23T11:00:00.000Z'), value: 24.4 },
- {
- date: new Date('2017-02-23T12:00:00.000Z'),
- value: null,
- anomalyScore: 97.32085,
- actual: [0],
- typical: [22.2],
- },
- { date: new Date('2017-02-23T13:00:00.000Z'), value: 21.3 },
- { date: new Date('2017-02-23T14:00:00.000Z'), value: 21.2 },
- { date: new Date('2017-02-23T15:00:00.000Z'), value: 21.1 },
- ];
-
- const limits = chartLimits(data);
- expect(limits.min).toBe(0);
- expect(limits.max).toBe(24.4);
- });
- });
-
describe('getChartType', () => {
const singleMetricConfig = {
metricFunction: 'avg',
diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx
index 09be67a2203ef..42d5c012b9c14 100644
--- a/x-pack/plugins/ml/public/application/util/url_state.tsx
+++ b/x-pack/plugins/ml/public/application/util/url_state.tsx
@@ -205,6 +205,10 @@ export class PageUrlStateService {
return this._pageUrlState$.pipe(distinctUntilChanged(isEqual));
}
+ public getPageUrlState(): T | null {
+ return this._pageUrlState$.getValue();
+ }
+
public updateUrlState(update: Partial, replaceState?: boolean): void {
if (!this._pageUrlStateCallback) {
throw new Error('Callback has not been initialized.');
@@ -212,10 +216,18 @@ export class PageUrlStateService {
this._pageUrlStateCallback(update, replaceState);
}
+ /**
+ * Populates internal subject with currently active state.
+ * @param currentState
+ */
public setCurrentState(currentState: T): void {
this._pageUrlState$.next(currentState);
}
+ /**
+ * Sets the callback for the state update.
+ * @param callback
+ */
public setUpdateCallback(callback: (update: Partial, replaceState?: boolean) => void): void {
this._pageUrlStateCallback = callback;
}
diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts
index 4dde7b41148c2..c104c5da80545 100644
--- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts
+++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { renderHook, act } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react-hooks';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { AnomalyChartsEmbeddableInput, AnomalyChartsServices } from '../types';
import { CoreStart } from 'kibana/public';
@@ -64,14 +64,8 @@ describe('useAnomalyChartsInputResolver', () => {
max: end,
});
- anomalyExplorerChartsServiceMock.getCombinedJobs.mockImplementation(() =>
- Promise.resolve(
- jobIds.map((jobId) => ({ job_id: jobId, analysis_config: {}, datafeed_config: {} }))
- )
- );
-
- anomalyExplorerChartsServiceMock.getAnomalyData.mockImplementation(() =>
- Promise.resolve({
+ anomalyExplorerChartsServiceMock.getAnomalyData$.mockImplementation(() =>
+ of({
chartsPerRow: 2,
seriesToPlot: [],
tooManyBuckets: false,
@@ -80,42 +74,6 @@ describe('useAnomalyChartsInputResolver', () => {
})
);
- anomalyExplorerChartsServiceMock.loadDataForCharts$.mockImplementation(() =>
- Promise.resolve([
- {
- job_id: 'cw_multi_1',
- result_type: 'record',
- probability: 6.057139142746412e-13,
- multi_bucket_impact: -5,
- record_score: 89.71961,
- initial_record_score: 98.36826274948001,
- bucket_span: 900,
- detector_index: 0,
- is_interim: false,
- timestamp: 1572892200000,
- partition_field_name: 'instance',
- partition_field_value: 'i-d17dcd4c',
- function: 'mean',
- function_description: 'mean',
- typical: [1.6177685422858146],
- actual: [7.235333333333333],
- field_name: 'CPUUtilization',
- influencers: [
- {
- influencer_field_name: 'region',
- influencer_field_values: ['sa-east-1'],
- },
- {
- influencer_field_name: 'instance',
- influencer_field_values: ['i-d17dcd4c'],
- },
- ],
- instance: ['i-d17dcd4c'],
- region: ['sa-east-1'],
- },
- ])
- );
-
const coreStartMock = createCoreStartMock();
const mlStartMock = createMlStartDepsMock();
@@ -144,13 +102,14 @@ describe('useAnomalyChartsInputResolver', () => {
onInputChange = jest.fn();
});
+
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('should fetch jobs only when input job ids have been changed', async () => {
- const { result, waitForNextUpdate } = renderHook(() =>
+ const { result } = renderHook(() =>
useAnomalyChartsInputResolver(
embeddableInput as Observable,
onInputChange,
@@ -165,37 +124,31 @@ describe('useAnomalyChartsInputResolver', () => {
expect(result.current.error).toBe(undefined);
expect(result.current.isLoading).toBe(true);
- await act(async () => {
- jest.advanceTimersByTime(501);
- await waitForNextUpdate();
- });
+ jest.advanceTimersByTime(501);
const explorerServices = services[2];
expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1);
- expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- embeddableInput.next({
- id: 'test-explorer-charts-embeddable',
- jobIds: ['anotherJobId'],
- filters: [],
- query: { language: 'kuery', query: '' },
- maxSeriesToPlot: 6,
- timeRange: {
- from: 'now-3y',
- to: 'now',
- },
- });
- jest.advanceTimersByTime(501);
- await waitForNextUpdate();
+ expect(explorerServices.anomalyExplorerService.getAnomalyData$).toHaveBeenCalledTimes(1);
+
+ embeddableInput.next({
+ id: 'test-explorer-charts-embeddable',
+ jobIds: ['anotherJobId'],
+ filters: [],
+ query: { language: 'kuery', query: '' },
+ maxSeriesToPlot: 6,
+ timeRange: {
+ from: 'now-3y',
+ to: 'now',
+ },
});
+ jest.advanceTimersByTime(501);
expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
- expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(2);
+ expect(explorerServices.anomalyExplorerService.getAnomalyData$).toHaveBeenCalledTimes(2);
});
- test('should not complete the observable on error', async () => {
+ test.skip('should not complete the observable on error', async () => {
const { result } = renderHook(() =>
useAnomalyChartsInputResolver(
embeddableInput as Observable,
@@ -207,14 +160,13 @@ describe('useAnomalyChartsInputResolver', () => {
)
);
- await act(async () => {
- embeddableInput.next({
- id: 'test-explorer-charts-embeddable',
- jobIds: ['invalid-job-id'],
- filters: [],
- query: { language: 'kuery', query: '' },
- } as Partial);
- });
+ embeddableInput.next({
+ id: 'test-explorer-charts-embeddable',
+ jobIds: ['invalid-job-id'],
+ filters: [],
+ query: { language: 'kuery', query: '' },
+ } as Partial);
+
expect(result.current.error).toBeDefined();
});
});
diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts
index 86772dac40dc0..8195727b2635c 100644
--- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts
+++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts
@@ -6,12 +6,10 @@
*/
import { useEffect, useMemo, useState } from 'react';
-import { combineLatest, forkJoin, from, Observable, of, Subject } from 'rxjs';
+import { combineLatest, Observable, of, Subject } from 'rxjs';
import { catchError, debounceTime, skipWhile, startWith, switchMap, tap } from 'rxjs/operators';
import { CoreStart } from 'kibana/public';
-import { TimeBuckets } from '../../application/util/time_buckets';
import { MlStartDependencies } from '../../plugin';
-import { UI_SETTINGS } from '../../../../../../src/plugins/data/public';
import {
AppStateSelectedCells,
getSelectionInfluencers,
@@ -24,7 +22,6 @@ import {
AnomalyChartsEmbeddableOutput,
AnomalyChartsServices,
} from '..';
-import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
import type { ExplorerChartsData } from '../../application/explorer/explorer_charts/explorer_charts_container_service';
import { processFilters } from '../common/process_filters';
import { InfluencersFilterQuery } from '../../../common/types/es_client';
@@ -39,30 +36,20 @@ export function useAnomalyChartsInputResolver(
services: [CoreStart, MlStartDependencies, AnomalyChartsServices],
chartWidth: number,
severity: number
-): { chartsData: ExplorerChartsData; isLoading: boolean; error: Error | null | undefined } {
- const [
- { uiSettings },
- { data: dataServices },
- { anomalyDetectorService, anomalyExplorerService },
- ] = services;
- const { timefilter } = dataServices.query.timefilter;
-
- const [chartsData, setChartsData] = useState();
+): {
+ chartsData: ExplorerChartsData | undefined;
+ isLoading: boolean;
+ error: Error | null | undefined;
+} {
+ const [, , { anomalyDetectorService, anomalyExplorerService }] = services;
+
+ const [chartsData, setChartsData] = useState();
const [error, setError] = useState();
const [isLoading, setIsLoading] = useState(false);
const chartWidth$ = useMemo(() => new Subject(), []);
const severity$ = useMemo(() => new Subject(), []);
- const timeBuckets = useMemo(() => {
- return new TimeBuckets({
- 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
- 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
- dateFormat: uiSettings.get('dateFormat'),
- 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
- });
- }, []);
-
useEffect(() => {
const subscription = combineLatest([
getJobsObservable(embeddableInput, anomalyDetectorService, setError),
@@ -108,43 +95,17 @@ export function useAnomalyChartsInputResolver(
const jobIds = getSelectionJobIds(selections, explorerJobs);
- const bucketInterval = timeBuckets.getInterval();
-
- const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds);
- return forkJoin({
- combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds),
- anomalyChartRecords: anomalyExplorerService.loadDataForCharts$(
- jobIds,
- timeRange.earliestMs,
- timeRange.latestMs,
- selectionInfluencers,
- selections,
- influencersFilterQuery
- ),
- }).pipe(
- switchMap(({ combinedJobs, anomalyChartRecords }) => {
- const combinedJobRecords: Record = (
- combinedJobs as CombinedJob[]
- ).reduce((acc, job) => {
- return { ...acc, [job.job_id]: job };
- }, {});
-
- return forkJoin({
- chartsData: from(
- anomalyExplorerService.getAnomalyData(
- undefined,
- combinedJobRecords,
- embeddableContainerWidth,
- anomalyChartRecords,
- timeRange.earliestMs,
- timeRange.latestMs,
- timefilter,
- severityValue,
- maxSeriesToPlot
- )
- ),
- });
- })
+ const timeRange = getSelectionTimeRange(selections, bounds);
+
+ return anomalyExplorerService.getAnomalyData$(
+ jobIds,
+ embeddableContainerWidth,
+ timeRange.earliestMs,
+ timeRange.latestMs,
+ influencersFilterQuery,
+ selectionInfluencers,
+ severityValue ?? 0,
+ maxSeriesToPlot
);
}),
catchError((e) => {
@@ -155,7 +116,7 @@ export function useAnomalyChartsInputResolver(
.subscribe((results) => {
if (results !== undefined) {
setError(null);
- setChartsData(results.chartsData);
+ setChartsData(results);
setIsLoading(false);
}
});
diff --git a/x-pack/plugins/ml/server/models/results_service/anomaly_charts.test.ts b/x-pack/plugins/ml/server/models/results_service/anomaly_charts.test.ts
new file mode 100644
index 0000000000000..575702a3a3c43
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/results_service/anomaly_charts.test.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { chartLimits } from './anomaly_charts';
+import type { ChartPoint } from '../../../common/types/results';
+
+describe('chartLimits', () => {
+ test('returns NaN when called without data', () => {
+ const limits = chartLimits();
+ expect(limits.min).toBeNaN();
+ expect(limits.max).toBeNaN();
+ });
+
+ test('returns {max: 625736376, min: 201039318} for some test data', () => {
+ const data = [
+ {
+ date: new Date('2017-02-23T08:00:00.000Z'),
+ value: 228243469,
+ anomalyScore: 63.32916,
+ numberOfCauses: 1,
+ actual: [228243469],
+ typical: [133107.7703441773],
+ },
+ { date: new Date('2017-02-23T09:00:00.000Z'), value: null },
+ { date: new Date('2017-02-23T10:00:00.000Z'), value: null },
+ { date: new Date('2017-02-23T11:00:00.000Z'), value: null },
+ {
+ date: new Date('2017-02-23T12:00:00.000Z'),
+ value: 625736376,
+ anomalyScore: 97.32085,
+ numberOfCauses: 1,
+ actual: [625736376],
+ typical: [132830.424736973],
+ },
+ {
+ date: new Date('2017-02-23T13:00:00.000Z'),
+ value: 201039318,
+ anomalyScore: 59.83488,
+ numberOfCauses: 1,
+ actual: [201039318],
+ typical: [132739.5267403542],
+ },
+ ] as unknown as ChartPoint[];
+
+ const limits = chartLimits(data);
+
+ // {max: 625736376, min: 201039318}
+ expect(limits.min).toBe(201039318);
+ expect(limits.max).toBe(625736376);
+ });
+
+ test("adds 5% padding when min/max are the same, e.g. when there's only one data point", () => {
+ const data = [
+ {
+ date: new Date('2017-02-23T08:00:00.000Z'),
+ value: 100,
+ anomalyScore: 50,
+ numberOfCauses: 1,
+ actual: [100],
+ typical: [100],
+ },
+ ] as unknown as ChartPoint[];
+
+ const limits = chartLimits(data);
+ expect(limits.min).toBe(95);
+ expect(limits.max).toBe(105);
+ });
+
+ test('returns minimum of 0 when data includes an anomaly for missing data', () => {
+ const data = [
+ { date: new Date('2017-02-23T09:00:00.000Z'), value: 22.2 },
+ { date: new Date('2017-02-23T10:00:00.000Z'), value: 23.3 },
+ { date: new Date('2017-02-23T11:00:00.000Z'), value: 24.4 },
+ {
+ date: new Date('2017-02-23T12:00:00.000Z'),
+ value: null,
+ anomalyScore: 97.32085,
+ actual: [0],
+ typical: [22.2],
+ },
+ { date: new Date('2017-02-23T13:00:00.000Z'), value: 21.3 },
+ { date: new Date('2017-02-23T14:00:00.000Z'), value: 21.2 },
+ { date: new Date('2017-02-23T15:00:00.000Z'), value: 21.1 },
+ ] as unknown as ChartPoint[];
+
+ const limits = chartLimits(data);
+ expect(limits.min).toBe(0);
+ expect(limits.max).toBe(24.4);
+ });
+});
diff --git a/x-pack/plugins/ml/server/models/results_service/anomaly_charts.ts b/x-pack/plugins/ml/server/models/results_service/anomaly_charts.ts
new file mode 100644
index 0000000000000..84363c12699d5
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/results_service/anomaly_charts.ts
@@ -0,0 +1,1946 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { IScopedClusterClient } from 'kibana/server';
+import { i18n } from '@kbn/i18n';
+import { each, find, get, keyBy, map, reduce, sortBy } from 'lodash';
+import type * as estypes from '@elastic/elasticsearch/lib/api/types';
+import { extent, max, min } from 'd3';
+import type { MlClient } from '../../lib/ml_client';
+import { isPopulatedObject, isRuntimeMappings } from '../../../common';
+import type {
+ MetricData,
+ ModelPlotOutput,
+ RecordsForCriteria,
+ ScheduledEventsByBucket,
+ SeriesConfigWithMetadata,
+ ChartRecord,
+ ChartPoint,
+ SeriesConfig,
+ ExplorerChartsData,
+} from '../../../common/types/results';
+import {
+ isMappableJob,
+ isModelPlotChartableForDetector,
+ isModelPlotEnabled,
+ isSourceDataChartableForDetector,
+ ML_MEDIAN_PERCENTS,
+ mlFunctionToESAggregation,
+} from '../../../common/util/job_utils';
+import { CriteriaField } from './results_service';
+import {
+ aggregationTypeTransform,
+ EntityField,
+ getEntityFieldList,
+} from '../../../common/util/anomaly_utils';
+import { InfluencersFilterQuery } from '../../../common/types/es_client';
+import { isDefined } from '../../../common/types/guards';
+import { AnomalyRecordDoc, CombinedJob, Datafeed, RecordForInfluencer } from '../../shared';
+import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types';
+import { parseInterval } from '../../../common/util/parse_interval';
+import { _DOC_COUNT, DOC_COUNT } from '../../../common/constants/field_types';
+
+import { getDatafeedAggregations } from '../../../common/util/datafeed_utils';
+import { findAggField } from '../../../common/util/validation_utils';
+import { CHART_TYPE, ChartType } from '../../../common/constants/charts';
+import { getChartType } from '../../../common/util/chart_utils';
+import { MlJob } from '../../index';
+
+export function chartLimits(data: ChartPoint[] = []) {
+ const domain = extent(data, (d) => {
+ let metricValue = d.value as number;
+ if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
+ // If an anomaly coincides with a gap in the data, use the anomaly actual value.
+ metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
+ }
+ return metricValue;
+ });
+ const limits = { max: domain[1], min: domain[0] };
+
+ if (limits.max === limits.min) {
+ // @ts-ignore
+ limits.max = max(data, (d) => {
+ if (d.typical) {
+ return Math.max(d.value as number, ...d.typical);
+ } else {
+ // If analysis with by and over field, and more than one cause,
+ // there will be no actual and typical value.
+ // TODO - produce a better visual for population analyses.
+ return d.value;
+ }
+ });
+ // @ts-ignore
+ limits.min = min(data, (d) => {
+ if (d.typical) {
+ return Math.min(d.value as number, ...d.typical);
+ } else {
+ // If analysis with by and over field, and more than one cause,
+ // there will be no actual and typical value.
+ // TODO - produce a better visual for population analyses.
+ return d.value;
+ }
+ });
+ }
+
+ // add padding of 5% of the difference between max and min
+ // if we ended up with the same value for both of them
+ if (limits.max === limits.min) {
+ const padding = limits.max * 0.05;
+ limits.max += padding;
+ limits.min -= padding;
+ }
+
+ return limits;
+}
+
+const CHART_MAX_POINTS = 500;
+const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
+const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000;
+const ENTITY_AGGREGATION_SIZE = 10;
+const AGGREGATION_MIN_DOC_COUNT = 1;
+const CARDINALITY_PRECISION_THRESHOLD = 100;
+const USE_OVERALL_CHART_LIMITS = false;
+const ML_TIME_FIELD_NAME = 'timestamp';
+
+export interface ChartRange {
+ min: number;
+ max: number;
+}
+
+export function getDefaultChartsData(): ExplorerChartsData {
+ return {
+ chartsPerRow: 1,
+ errorMessages: undefined,
+ seriesToPlot: [],
+ // default values, will update on every re-render
+ tooManyBuckets: false,
+ timeFieldName: 'timestamp',
+ };
+}
+
+export function anomalyChartsDataProvider(mlClient: MlClient, client: IScopedClusterClient) {
+ let handleError: (errorMsg: string, jobId: string) => void = () => {};
+
+ async function fetchMetricData(
+ index: string,
+ entityFields: EntityField[],
+ query: object | undefined,
+ metricFunction: string | null, // ES aggregation name
+ metricFieldName: string | undefined,
+ summaryCountFieldName: string | undefined,
+ timeFieldName: string,
+ earliestMs: number,
+ latestMs: number,
+ intervalMs: number,
+ datafeedConfig?: Datafeed
+ ): Promise {
+ const scriptFields = datafeedConfig?.script_fields;
+ const aggFields = getDatafeedAggregations(datafeedConfig);
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the time range, entity fields,
+ // plus any additional supplied query.
+ const shouldCriteria: object[] = [];
+
+ const mustCriteria: object[] = [
+ {
+ range: {
+ [timeFieldName]: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ ...(query ? [query] : []),
+ ];
+
+ entityFields.forEach((entity) => {
+ if (entity.fieldValue && entity.fieldValue.toString().length !== 0) {
+ mustCriteria.push({
+ term: {
+ [entity.fieldName]: entity.fieldValue,
+ },
+ });
+ } else {
+ // Add special handling for blank entity field values, checking for either
+ // an empty string or the field not existing.
+ shouldCriteria.push({
+ bool: {
+ must: [
+ {
+ term: {
+ [entity.fieldName]: '',
+ },
+ },
+ ],
+ },
+ });
+ shouldCriteria.push({
+ bool: {
+ must_not: [
+ {
+ exists: { field: entity.fieldName },
+ },
+ ],
+ },
+ });
+ }
+ });
+
+ const esSearchRequest: estypes.SearchRequest = {
+ index,
+ query: {
+ bool: {
+ must: mustCriteria,
+ },
+ },
+ aggs: {
+ byTime: {
+ date_histogram: {
+ field: timeFieldName,
+ fixed_interval: `${intervalMs}ms`,
+ min_doc_count: 0,
+ },
+ },
+ },
+ ...(isRuntimeMappings(datafeedConfig?.runtime_mappings)
+ ? { runtime_mappings: datafeedConfig?.runtime_mappings }
+ : {}),
+ size: 0,
+ _source: false,
+ };
+
+ if (shouldCriteria.length > 0) {
+ esSearchRequest.query!.bool!.should = shouldCriteria;
+ esSearchRequest.query!.bool!.minimum_should_match = shouldCriteria.length / 2;
+ }
+
+ esSearchRequest.aggs!.byTime.aggs = {};
+
+ if (metricFieldName !== undefined && metricFieldName !== '' && metricFunction) {
+ const metricAgg: any = {
+ [metricFunction]: {},
+ };
+ if (scriptFields !== undefined && scriptFields[metricFieldName] !== undefined) {
+ metricAgg[metricFunction].script = scriptFields[metricFieldName].script;
+ } else {
+ metricAgg[metricFunction].field = metricFieldName;
+ }
+
+ if (metricFunction === 'percentiles') {
+ metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS];
+ }
+
+ // when the field is an aggregation field, because the field doesn't actually exist in the indices
+ // we need to pass all the sub aggs from the original datafeed config
+ // so that we can access the aggregated field
+ if (isPopulatedObject(aggFields)) {
+ // first item under aggregations can be any name, not necessarily 'buckets'
+ const accessor = Object.keys(aggFields)[0];
+ const tempAggs = { ...(aggFields[accessor].aggs ?? aggFields[accessor].aggregations) };
+ const foundValue = findAggField(tempAggs, metricFieldName);
+
+ if (foundValue !== undefined) {
+ tempAggs.metric = foundValue;
+ delete tempAggs[metricFieldName];
+ }
+ esSearchRequest.aggs!.byTime.aggs = tempAggs;
+ } else {
+ esSearchRequest.aggs!.byTime.aggs.metric = metricAgg;
+ }
+ } else {
+ // if metricFieldName is not defined, it's probably a variation of the non zero count function
+ // refer to buildConfigFromDetector
+ if (summaryCountFieldName !== undefined && metricFunction === ES_AGGREGATION.CARDINALITY) {
+ // if so, check if summaryCountFieldName is an aggregation field
+ if (typeof aggFields === 'object' && Object.keys(aggFields).length > 0) {
+ // first item under aggregations can be any name, not necessarily 'buckets'
+ const accessor = Object.keys(aggFields)[0];
+ const tempAggs = { ...(aggFields[accessor].aggs ?? aggFields[accessor].aggregations) };
+ const foundCardinalityField = findAggField(tempAggs, summaryCountFieldName);
+ if (foundCardinalityField !== undefined) {
+ tempAggs.metric = foundCardinalityField;
+ }
+ esSearchRequest.aggs!.byTime.aggs = tempAggs;
+ }
+ }
+ }
+
+ const resp = await client?.asCurrentUser.search(esSearchRequest);
+
+ const obj: MetricData = { success: true, results: {} };
+ // @ts-ignore
+ const dataByTime = resp?.aggregations?.byTime?.buckets ?? [];
+ dataByTime.forEach((dataForTime: any) => {
+ if (metricFunction === 'count') {
+ obj.results[dataForTime.key] = dataForTime.doc_count;
+ } else {
+ const value = dataForTime?.metric?.value;
+ const values = dataForTime?.metric?.values;
+ if (dataForTime.doc_count === 0) {
+ // @ts-ignore
+ obj.results[dataForTime.key] = null;
+ } else if (value !== undefined) {
+ obj.results[dataForTime.key] = value;
+ } else if (values !== undefined) {
+ // Percentiles agg currently returns NaN rather than null when none of the docs in the
+ // bucket contain the field used in the aggregation
+ // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066).
+ // Store as null, so values can be handled in the same manner downstream as other aggs
+ // (min, mean, max) which return null.
+ const medianValues = values[ML_MEDIAN_PERCENTS];
+ obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null;
+ } else {
+ // @ts-ignore
+ obj.results[dataForTime.key] = null;
+ }
+ }
+ });
+
+ return obj;
+ }
+
+ /**
+ * TODO Make an API endpoint (also used by the SMV).
+ * @param jobId
+ * @param detectorIndex
+ * @param criteriaFields
+ * @param earliestMs
+ * @param latestMs
+ * @param intervalMs
+ * @param aggType
+ */
+ async function getModelPlotOutput(
+ jobId: string,
+ detectorIndex: number,
+ criteriaFields: CriteriaField[],
+ earliestMs: number,
+ latestMs: number,
+ intervalMs: number,
+ aggType?: { min: any; max: any }
+ ): Promise {
+ const obj: ModelPlotOutput = {
+ success: true,
+ results: {},
+ };
+
+ // if an aggType object has been passed in, use it.
+ // otherwise default to min and max aggs for the upper and lower bounds
+ const modelAggs =
+ aggType === undefined
+ ? { max: 'max', min: 'min' }
+ : {
+ max: aggType.max,
+ min: aggType.min,
+ };
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the job ID and time range.
+ const mustCriteria: object[] = [
+ {
+ term: { job_id: jobId },
+ },
+ {
+ range: {
+ timestamp: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ ];
+
+ // Add in term queries for each of the specified criteria.
+ each(criteriaFields, (criteria) => {
+ mustCriteria.push({
+ term: {
+ [criteria.fieldName]: criteria.fieldValue,
+ },
+ });
+ });
+
+ // Add criteria for the detector index. Results from jobs created before 6.1 will not
+ // contain a detector_index field, so use a should criteria with a 'not exists' check.
+ const shouldCriteria = [
+ {
+ term: { detector_index: detectorIndex },
+ },
+ {
+ bool: {
+ must_not: [
+ {
+ exists: { field: 'detector_index' },
+ },
+ ],
+ },
+ },
+ ];
+
+ const searchRequest: estypes.SearchRequest = {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ result_type: 'model_plot',
+ },
+ },
+ {
+ bool: {
+ must: mustCriteria,
+ should: shouldCriteria,
+ minimum_should_match: 1,
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ times: {
+ date_histogram: {
+ field: 'timestamp',
+ fixed_interval: `${intervalMs}ms`,
+ min_doc_count: 0,
+ },
+ aggs: {
+ actual: {
+ avg: {
+ field: 'actual',
+ },
+ },
+ modelUpper: {
+ [modelAggs.max]: {
+ field: 'model_upper',
+ },
+ },
+ modelLower: {
+ [modelAggs.min]: {
+ field: 'model_lower',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const resp = await mlClient.anomalySearch(searchRequest, [jobId]);
+
+ const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []);
+ each(aggregationsByTime, (dataForTime: any) => {
+ const time = dataForTime.key;
+ const modelUpper: number | undefined = get(dataForTime, ['modelUpper', 'value']);
+ const modelLower: number | undefined = get(dataForTime, ['modelLower', 'value']);
+ const actual = get(dataForTime, ['actual', 'value']);
+
+ obj.results[time] = {
+ actual,
+ modelUpper: modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper,
+ modelLower: modelLower === undefined || isFinite(modelLower) === false ? null : modelLower,
+ };
+ });
+
+ return obj;
+ }
+
+ function processRecordsForDisplay(
+ combinedJobRecords: Record,
+ anomalyRecords: RecordForInfluencer[]
+ ): { records: ChartRecord[]; errors: Record> | undefined } {
+ // Aggregate the anomaly data by detector, and entity (by/over/partition).
+ if (anomalyRecords.length === 0) {
+ return { records: [], errors: undefined };
+ }
+ // Aggregate by job, detector, and analysis fields (partition, by, over).
+ const aggregatedData: Record = {};
+
+ const jobsErrorMessage: Record = {};
+ each(anomalyRecords, (record) => {
+ // Check if we can plot a chart for this record, depending on whether the source data
+ // is chartable, and if model plot is enabled for the job.
+
+ const job = combinedJobRecords[record.job_id];
+
+ // if we already know this job has datafeed aggregations we cannot support
+ // no need to do more checks
+ if (jobsErrorMessage[record.job_id] !== undefined) {
+ return;
+ }
+
+ let isChartable =
+ isSourceDataChartableForDetector(job as CombinedJob, record.detector_index) ||
+ isMappableJob(job as CombinedJob, record.detector_index);
+
+ if (isChartable === false) {
+ if (isModelPlotChartableForDetector(job, record.detector_index)) {
+ // Check if model plot is enabled for this job.
+ // Need to check the entity fields for the record in case the model plot config has a terms list.
+ const entityFields = getEntityFieldList(record);
+ if (isModelPlotEnabled(job, record.detector_index, entityFields)) {
+ isChartable = true;
+ } else {
+ isChartable = false;
+ jobsErrorMessage[record.job_id] = i18n.translate(
+ 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage',
+ {
+ defaultMessage:
+ 'source data is not viewable for this detector and model plot is disabled',
+ }
+ );
+ }
+ } else {
+ jobsErrorMessage[record.job_id] = i18n.translate(
+ 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage',
+ {
+ defaultMessage: 'both source data and model plot are not chartable for this detector',
+ }
+ );
+ }
+ }
+
+ if (isChartable === false) {
+ return;
+ }
+ const jobId = record.job_id;
+ if (aggregatedData[jobId] === undefined) {
+ aggregatedData[jobId] = {};
+ }
+ const detectorsForJob = aggregatedData[jobId];
+
+ const detectorIndex = record.detector_index;
+ if (detectorsForJob[detectorIndex] === undefined) {
+ detectorsForJob[detectorIndex] = {};
+ }
+
+ // TODO - work out how best to display results from detectors with just an over field.
+ const firstFieldName =
+ record.partition_field_name || record.by_field_name || record.over_field_name;
+ const firstFieldValue =
+ record.partition_field_value || record.by_field_value || record.over_field_value;
+ if (firstFieldName !== undefined && firstFieldValue !== undefined) {
+ const groupsForDetector = detectorsForJob[detectorIndex];
+
+ if (groupsForDetector[firstFieldName] === undefined) {
+ groupsForDetector[firstFieldName] = {};
+ }
+ const valuesForGroup: Record = groupsForDetector[firstFieldName];
+ if (valuesForGroup[firstFieldValue] === undefined) {
+ valuesForGroup[firstFieldValue] = {};
+ }
+
+ const dataForGroupValue = valuesForGroup[firstFieldValue];
+
+ let isSecondSplit = false;
+ if (record.partition_field_name !== undefined) {
+ const splitFieldName = record.over_field_name || record.by_field_name;
+ if (splitFieldName !== undefined) {
+ isSecondSplit = true;
+ }
+ }
+
+ if (isSecondSplit === false) {
+ if (dataForGroupValue.maxScoreRecord === undefined) {
+ dataForGroupValue.maxScore = record.record_score;
+ dataForGroupValue.maxScoreRecord = record;
+ } else {
+ if (record.record_score > dataForGroupValue.maxScore) {
+ dataForGroupValue.maxScore = record.record_score;
+ dataForGroupValue.maxScoreRecord = record;
+ }
+ }
+ } else {
+ // Aggregate another level for the over or by field.
+ const secondFieldName = record.over_field_name || record.by_field_name;
+ const secondFieldValue = record.over_field_value || record.by_field_value;
+
+ if (secondFieldName !== undefined && secondFieldValue !== undefined) {
+ if (dataForGroupValue[secondFieldName] === undefined) {
+ dataForGroupValue[secondFieldName] = {};
+ }
+
+ const splitsForGroup = dataForGroupValue[secondFieldName];
+ if (splitsForGroup[secondFieldValue] === undefined) {
+ splitsForGroup[secondFieldValue] = {};
+ }
+
+ const dataForSplitValue = splitsForGroup[secondFieldValue];
+ if (dataForSplitValue.maxScoreRecord === undefined) {
+ dataForSplitValue.maxScore = record.record_score;
+ dataForSplitValue.maxScoreRecord = record;
+ } else {
+ if (record.record_score > dataForSplitValue.maxScore) {
+ dataForSplitValue.maxScore = record.record_score;
+ dataForSplitValue.maxScoreRecord = record;
+ }
+ }
+ }
+ }
+ } else {
+ // Detector with no partition or by field.
+ const dataForDetector = detectorsForJob[detectorIndex];
+ if (dataForDetector.maxScoreRecord === undefined) {
+ dataForDetector.maxScore = record.record_score;
+ dataForDetector.maxScoreRecord = record;
+ } else {
+ if (record.record_score > dataForDetector.maxScore) {
+ dataForDetector.maxScore = record.record_score;
+ dataForDetector.maxScoreRecord = record;
+ }
+ }
+ }
+ });
+
+ // Group job id by error message instead of by job:
+ const errorMessages: Record> | undefined = {};
+ Object.keys(jobsErrorMessage).forEach((jobId) => {
+ const msg = jobsErrorMessage[jobId];
+ if (errorMessages[msg] === undefined) {
+ errorMessages[msg] = new Set([jobId]);
+ } else {
+ errorMessages[msg].add(jobId);
+ }
+ });
+ let recordsForSeries: ChartRecord[] = [];
+ // Convert to an array of the records with the highest record_score per unique series.
+ each(aggregatedData, (detectorsForJob) => {
+ each(detectorsForJob, (groupsForDetector) => {
+ if (groupsForDetector.errorMessage !== undefined) {
+ recordsForSeries.push(groupsForDetector.errorMessage);
+ } else {
+ if (groupsForDetector.maxScoreRecord !== undefined) {
+ // Detector with no partition / by field.
+ recordsForSeries.push(groupsForDetector.maxScoreRecord);
+ } else {
+ each(groupsForDetector, (valuesForGroup) => {
+ each(valuesForGroup, (dataForGroupValue) => {
+ if (dataForGroupValue.maxScoreRecord !== undefined) {
+ recordsForSeries.push(dataForGroupValue.maxScoreRecord);
+ } else {
+ // Second level of aggregation for partition and by/over.
+ each(dataForGroupValue, (splitsForGroup) => {
+ each(splitsForGroup, (dataForSplitValue) => {
+ recordsForSeries.push(dataForSplitValue.maxScoreRecord);
+ });
+ });
+ }
+ });
+ });
+ }
+ }
+ });
+ });
+ recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse();
+
+ return { records: recordsForSeries, errors: errorMessages };
+ }
+
+ function buildConfigFromDetector(job: MlJob, detectorIndex: number) {
+ const analysisConfig = job.analysis_config;
+ const detector = analysisConfig.detectors[detectorIndex];
+
+ const config: SeriesConfig = {
+ jobId: job.job_id,
+ detectorIndex,
+ metricFunction:
+ detector.function === ML_JOB_AGGREGATION.LAT_LONG
+ ? ML_JOB_AGGREGATION.LAT_LONG
+ : mlFunctionToESAggregation(detector.function),
+ timeField: job.data_description.time_field!,
+ interval: job.analysis_config.bucket_span,
+ datafeedConfig: job.datafeed_config!,
+ summaryCountFieldName: job.analysis_config.summary_count_field_name,
+ metricFieldName: undefined,
+ };
+
+ if (detector.field_name !== undefined) {
+ config.metricFieldName = detector.field_name;
+ }
+
+ // Extra checks if the job config uses a summary count field.
+ const summaryCountFieldName = analysisConfig.summary_count_field_name;
+ if (
+ config.metricFunction === ES_AGGREGATION.COUNT &&
+ summaryCountFieldName !== undefined &&
+ summaryCountFieldName !== DOC_COUNT &&
+ summaryCountFieldName !== _DOC_COUNT
+ ) {
+ // Check for a detector looking at cardinality (distinct count) using an aggregation.
+ // The cardinality field will be in:
+ // aggregations//aggregations//cardinality/field
+ // or aggs//aggs//cardinality/field
+ let cardinalityField;
+ const topAgg = get(job.datafeed_config, 'aggregations') || get(job.datafeed_config, 'aggs');
+ if (topAgg !== undefined && Object.values(topAgg).length > 0) {
+ cardinalityField =
+ get(Object.values(topAgg)[0], [
+ 'aggregations',
+ summaryCountFieldName,
+ ES_AGGREGATION.CARDINALITY,
+ 'field',
+ ]) ||
+ get(Object.values(topAgg)[0], [
+ 'aggs',
+ summaryCountFieldName,
+ ES_AGGREGATION.CARDINALITY,
+ 'field',
+ ]);
+ }
+ if (
+ (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT ||
+ detector.function === ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT ||
+ detector.function === ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT ||
+ detector.function === ML_JOB_AGGREGATION.COUNT ||
+ detector.function === ML_JOB_AGGREGATION.HIGH_COUNT ||
+ detector.function === ML_JOB_AGGREGATION.LOW_COUNT) &&
+ cardinalityField !== undefined
+ ) {
+ config.metricFunction = ES_AGGREGATION.CARDINALITY;
+ config.metricFieldName = undefined;
+ } else {
+ // For count detectors using summary_count_field, plot sum(summary_count_field_name)
+ config.metricFunction = ES_AGGREGATION.SUM;
+ config.metricFieldName = summaryCountFieldName;
+ }
+ }
+
+ return config;
+ }
+
+ function buildConfig(record: ChartRecord, job: MlJob): SeriesConfigWithMetadata {
+ const detectorIndex = record.detector_index;
+ const config: Omit<
+ SeriesConfigWithMetadata,
+ 'bucketSpanSeconds' | 'detectorLabel' | 'fieldName' | 'entityFields' | 'infoTooltip'
+ > = {
+ ...buildConfigFromDetector(job, detectorIndex),
+ };
+
+ const fullSeriesConfig: SeriesConfigWithMetadata = {
+ bucketSpanSeconds: 0,
+ entityFields: [],
+ fieldName: '',
+ ...config,
+ };
+ // Add extra properties used by the explorer dashboard charts.
+ fullSeriesConfig.functionDescription = record.function_description;
+
+ const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span);
+ if (parsedBucketSpan !== null) {
+ fullSeriesConfig.bucketSpanSeconds = parsedBucketSpan.asSeconds();
+ }
+
+ fullSeriesConfig.detectorLabel = record.function;
+ const jobDetectors = job.analysis_config.detectors;
+ if (jobDetectors) {
+ fullSeriesConfig.detectorLabel = jobDetectors[detectorIndex].detector_description;
+ } else {
+ if (record.field_name !== undefined) {
+ fullSeriesConfig.detectorLabel += ` ${fullSeriesConfig.fieldName}`;
+ }
+ }
+
+ if (record.field_name !== undefined) {
+ fullSeriesConfig.fieldName = record.field_name;
+ fullSeriesConfig.metricFieldName = record.field_name;
+ }
+
+ // Add the 'entity_fields' i.e. the partition, by, over fields which
+ // define the metric series to be plotted.
+ fullSeriesConfig.entityFields = getEntityFieldList(record);
+
+ if (record.function === ML_JOB_AGGREGATION.METRIC) {
+ fullSeriesConfig.metricFunction = mlFunctionToESAggregation(record.function_description);
+ }
+
+ // Build the tooltip data for the chart info icon, showing further details on what is being plotted.
+ let functionLabel = `${config.metricFunction}`;
+ if (
+ fullSeriesConfig.metricFieldName !== undefined &&
+ fullSeriesConfig.metricFieldName !== null
+ ) {
+ functionLabel += ` ${fullSeriesConfig.metricFieldName}`;
+ }
+
+ fullSeriesConfig.infoTooltip = {
+ jobId: record.job_id,
+ aggregationInterval: fullSeriesConfig.interval,
+ chartFunction: functionLabel,
+ entityFields: fullSeriesConfig.entityFields.map((f) => ({
+ fieldName: f.fieldName,
+ fieldValue: f.fieldValue,
+ })),
+ };
+
+ return fullSeriesConfig;
+ }
+
+ function findChartPointForTime(chartData: ChartPoint[], time: number) {
+ return chartData.find((point) => point.date === time);
+ }
+
+ function calculateChartRange(
+ seriesConfigs: SeriesConfigWithMetadata[],
+ selectedEarliestMs: number,
+ selectedLatestMs: number,
+ recordsToPlot: ChartRecord[],
+ timeFieldName: string,
+ optimumNumPoints: number,
+ timeBounds: { min?: number; max?: number }
+ ) {
+ let tooManyBuckets = false;
+ // Calculate the time range for the charts.
+ // Fit in as many points in the available container width plotted at the job bucket span.
+ // Look for the chart with the shortest bucket span as this determines
+ // the length of the time range that can be plotted.
+ const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2);
+ const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000;
+ const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000;
+
+ const pointsToPlotFullSelection = Math.ceil(
+ (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs
+ );
+
+ // Increase actual number of points if we can't plot the selected range
+ // at optimal point spacing.
+ const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection);
+ const halfPoints = Math.ceil(plotPoints / 2);
+ const boundsMin = timeBounds.min;
+ const boundsMax = timeBounds.max;
+ let chartRange: ChartRange = {
+ min: boundsMin
+ ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin)
+ : midpointMs - halfPoints * minBucketSpanMs,
+ max: boundsMax
+ ? Math.min(midpointMs + halfPoints * minBucketSpanMs, boundsMax)
+ : midpointMs + halfPoints * minBucketSpanMs,
+ };
+
+ if (plotPoints > CHART_MAX_POINTS) {
+ // For each series being plotted, display the record with the highest score if possible.
+ const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS;
+ let minMs = recordsToPlot[0][timeFieldName];
+ let maxMs = recordsToPlot[0][timeFieldName];
+
+ each(recordsToPlot, (record) => {
+ const diffMs = maxMs - minMs;
+ if (diffMs < maxTimeSpan) {
+ const recordTime = record[timeFieldName];
+ if (recordTime < minMs) {
+ if (maxMs - recordTime <= maxTimeSpan) {
+ minMs = recordTime;
+ }
+ }
+
+ if (recordTime > maxMs) {
+ if (recordTime - minMs <= maxTimeSpan) {
+ maxMs = recordTime;
+ }
+ }
+ }
+ });
+
+ if (maxMs - minMs < maxTimeSpan) {
+ // Expand out before and after the span with the highest scoring anomalies,
+ // covering as much as the requested time span as possible.
+ // Work out if the high scoring region is nearer the start or end of the selected time span.
+ const diff = maxTimeSpan - (maxMs - minMs);
+ if (minMs - 0.5 * diff <= selectedEarliestMs) {
+ minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff);
+ maxMs = minMs + maxTimeSpan;
+ } else {
+ maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff);
+ minMs = maxMs - maxTimeSpan;
+ }
+ }
+
+ chartRange = { min: minMs, max: maxMs };
+ }
+
+ // Elasticsearch aggregation returns points at start of bucket,
+ // so align the min to the length of the longest bucket,
+ // and use the start of the latest selected bucket in the check
+ // for too many selected buckets, respecting the max bounds set in the view.
+ chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs;
+ if (boundsMin !== undefined && chartRange.min < boundsMin) {
+ chartRange.min = chartRange.min + maxBucketSpanMs;
+ }
+
+ // When used as an embeddable, selectedEarliestMs is the start date on the time picker,
+ // which may be earlier than the time of the first point plotted in the chart (as we plot
+ // the first full bucket with a start date no earlier than the start).
+ const selectedEarliestBucketCeil = boundsMin
+ ? Math.ceil(Math.max(selectedEarliestMs, boundsMin) / maxBucketSpanMs) * maxBucketSpanMs
+ : Math.ceil(selectedEarliestMs / maxBucketSpanMs) * maxBucketSpanMs;
+
+ const selectedLatestBucketStart = boundsMax
+ ? Math.floor(Math.min(selectedLatestMs, boundsMax) / maxBucketSpanMs) * maxBucketSpanMs
+ : Math.floor(selectedLatestMs / maxBucketSpanMs) * maxBucketSpanMs;
+
+ if (
+ (chartRange.min > selectedEarliestBucketCeil || chartRange.max < selectedLatestBucketStart) &&
+ chartRange.max - chartRange.min < selectedLatestBucketStart - selectedEarliestBucketCeil
+ ) {
+ tooManyBuckets = true;
+ }
+
+ return {
+ chartRange,
+ tooManyBuckets,
+ };
+ }
+
+ function initErrorHandler(errorMessages: Record> | undefined) {
+ handleError = (errorMsg: string, jobId: string) => {
+ // Group the jobIds by the type of error message
+ if (!errorMessages) {
+ errorMessages = {};
+ }
+
+ if (errorMessages[errorMsg]) {
+ errorMessages[errorMsg].add(jobId);
+ } else {
+ errorMessages[errorMsg] = new Set([jobId]);
+ }
+ };
+ }
+
+ async function getAnomalyData(
+ combinedJobRecords: Record,
+ anomalyRecords: ChartRecord[],
+ selectedEarliestMs: number,
+ selectedLatestMs: number,
+ numberOfPoints: number,
+ timeBounds: { min?: number; max?: number },
+ severity = 0,
+ maxSeries = 6
+ ) {
+ const data = getDefaultChartsData();
+
+ const filteredRecords = anomalyRecords.filter((record) => {
+ return Number(record.record_score) >= severity;
+ });
+ const { records: allSeriesRecords, errors: errorMessages } = processRecordsForDisplay(
+ combinedJobRecords,
+ filteredRecords
+ );
+
+ initErrorHandler(errorMessages);
+
+ if (!Array.isArray(allSeriesRecords)) return;
+
+ const recordsToPlot = allSeriesRecords.slice(0, maxSeries);
+ const hasGeoData = recordsToPlot.find(
+ (record) => (record.function_description || record.function) === ML_JOB_AGGREGATION.LAT_LONG
+ );
+
+ const seriesConfigs = recordsToPlot.map((record) =>
+ buildConfig(record, combinedJobRecords[record.job_id])
+ );
+
+ const seriesConfigsNoGeoData = [];
+
+ const mapData: SeriesConfigWithMetadata[] = [];
+
+ if (hasGeoData !== undefined) {
+ for (let i = 0; i < seriesConfigs.length; i++) {
+ const config = seriesConfigs[i];
+ let records;
+ if (
+ (config.detectorLabel !== undefined &&
+ config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) ||
+ config?.metricFunction === ML_JOB_AGGREGATION.LAT_LONG
+ ) {
+ if (config.entityFields.length) {
+ records = [
+ recordsToPlot.find((record) => {
+ const entityFieldName = config.entityFields[0].fieldName;
+ const entityFieldValue = config.entityFields[0].fieldValue;
+ return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue;
+ }),
+ ];
+ } else {
+ records = recordsToPlot;
+ }
+
+ mapData.push({
+ ...config,
+ loading: false,
+ mapData: records,
+ });
+ } else {
+ seriesConfigsNoGeoData.push(config);
+ }
+ }
+ }
+
+ const { chartRange, tooManyBuckets } = calculateChartRange(
+ seriesConfigs as SeriesConfigWithMetadata[],
+ selectedEarliestMs,
+ selectedLatestMs,
+ recordsToPlot,
+ 'timestamp',
+ numberOfPoints,
+ timeBounds
+ );
+
+ data.tooManyBuckets = tooManyBuckets;
+
+ // first load and wait for required data,
+ // only after that trigger data processing and page render.
+ // TODO - if query returns no results e.g. source data has been deleted,
+ // display a message saying 'No data between earliest/latest'.
+ const seriesPromises: Array<
+ Promise<[MetricData, RecordsForCriteria, ScheduledEventsByBucket, any]>
+ > = [];
+ // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved
+ // and we map through the responses
+ const seriesConfigsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs;
+ seriesConfigsForPromises.forEach((seriesConfig) => {
+ const job = combinedJobRecords[seriesConfig.jobId];
+ seriesPromises.push(
+ Promise.all([
+ getMetricData(seriesConfig, chartRange, job),
+ getRecordsForCriteriaChart(seriesConfig, chartRange),
+ getScheduledEvents(seriesConfig, chartRange),
+ getEventDistribution(seriesConfig, chartRange),
+ ])
+ );
+ });
+
+ const response = await Promise.all(seriesPromises);
+
+ function processChartData(
+ responses: [MetricData, RecordsForCriteria, ScheduledEventsByBucket, any],
+ seriesIndex: number
+ ) {
+ const metricData = responses[0].results;
+ const records = responses[1].records;
+ const jobId = seriesConfigsForPromises[seriesIndex].jobId;
+ const scheduledEvents = responses[2].events[jobId];
+ const eventDistribution = responses[3];
+ const chartType = getChartType(seriesConfigsForPromises[seriesIndex]);
+
+ // Sort records in ascending time order matching up with chart data
+ records.sort((recordA, recordB) => {
+ return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME];
+ });
+
+ // Return dataset in format used by the chart.
+ // i.e. array of Objects with keys date (timestamp), value,
+ // plus anomalyScore for points with anomaly markers.
+ let chartData: ChartPoint[] = [];
+ if (metricData !== undefined) {
+ if (records.length > 0) {
+ const filterField = records[0].by_field_value || records[0].over_field_value;
+ if (eventDistribution && eventDistribution.length > 0) {
+ chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField);
+ }
+ map(metricData, (value, time) => {
+ // The filtering for rare/event_distribution charts needs to be handled
+ // differently because of how the source data is structured.
+ // For rare chart values we are only interested wether a value is either `0` or not,
+ // `0` acts like a flag in the chart whether to display the dot/marker.
+ // All other charts (single metric, population) are metric based and with
+ // those a value of `null` acts as the flag to hide a data point.
+ if (
+ (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) ||
+ (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null)
+ ) {
+ chartData.push({
+ date: +time,
+ value,
+ entity: filterField,
+ });
+ }
+ });
+ } else {
+ chartData = map(metricData, (value, time) => ({
+ date: +time,
+ value,
+ }));
+ }
+ }
+
+ // Iterate through the anomaly records, adding anomalyScore properties
+ // to the chartData entries for anomalous buckets.
+ const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType);
+ each(records, (record) => {
+ // Look for a chart point with the same time as the record.
+ // If none found, insert a point for anomalies due to a gap in the data.
+ const recordTime = record[ML_TIME_FIELD_NAME];
+ let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime);
+ if (chartPoint === undefined) {
+ chartPoint = { date: recordTime, value: null };
+ chartData.push(chartPoint);
+ }
+ if (chartPoint !== undefined) {
+ chartPoint.anomalyScore = record.record_score;
+
+ if (record.actual !== undefined) {
+ chartPoint.actual = record.actual;
+ chartPoint.typical = record.typical;
+ } else {
+ const causes = get(record, 'causes', []);
+ if (causes.length > 0) {
+ chartPoint.byFieldName = record.by_field_name;
+ chartPoint.numberOfCauses = causes.length;
+ if (causes.length === 1) {
+ // If only a single cause, copy actual and typical values to the top level.
+ const cause = record.causes[0];
+ chartPoint.actual = cause.actual;
+ chartPoint.typical = cause.typical;
+ }
+ }
+ }
+
+ if (record.multi_bucket_impact !== undefined) {
+ chartPoint.multiBucketImpact = record.multi_bucket_impact;
+ }
+ }
+ });
+
+ // Add a scheduledEvents property to any points in the chart data set
+ // which correspond to times of scheduled events for the job.
+ if (scheduledEvents !== undefined) {
+ each(scheduledEvents, (events, time) => {
+ const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time));
+ if (chartPoint !== undefined) {
+ // Note if the scheduled event coincides with an absence of the underlying metric data,
+ // we don't worry about plotting the event.
+ chartPoint.scheduledEvents = events;
+ }
+ });
+ }
+
+ return chartData;
+ }
+
+ function getChartDataForPointSearch(
+ chartData: ChartPoint[],
+ record: AnomalyRecordDoc,
+ chartType: ChartType
+ ) {
+ if (
+ chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
+ chartType === CHART_TYPE.POPULATION_DISTRIBUTION
+ ) {
+ return chartData.filter((d) => {
+ return d.entity === (record && (record.by_field_value || record.over_field_value));
+ });
+ }
+
+ return chartData;
+ }
+
+ // calculate an overall min/max for all series
+ const processedData = response.map(processChartData);
+ const allDataPoints = reduce(
+ processedData,
+ (datapoints, series) => {
+ each(series, (d) => datapoints.push(d));
+ return datapoints;
+ },
+ [] as ChartPoint[]
+ );
+ const overallChartLimits = chartLimits(allDataPoints);
+
+ const seriesToPlot = response
+ // Don't show the charts if there was an issue retrieving metric or anomaly data
+ .filter((r) => r[0]?.success === true && r[1]?.success === true)
+ .map((d, i) => {
+ return {
+ ...seriesConfigsForPromises[i],
+ loading: false,
+ chartData: processedData[i],
+ plotEarliest: chartRange.min,
+ plotLatest: chartRange.max,
+ selectedEarliest: selectedEarliestMs,
+ selectedLatest: selectedLatestMs,
+ // FIXME can we remove this?
+ chartLimits: USE_OVERALL_CHART_LIMITS
+ ? overallChartLimits
+ : chartLimits(processedData[i]),
+ };
+ });
+
+ if (mapData.length) {
+ // push map data in if it's available
+ // @ts-ignore
+ seriesToPlot.push(...mapData);
+ }
+
+ data.seriesToPlot = seriesToPlot;
+
+ data.errorMessages = errorMessages
+ ? Object.entries(errorMessages!).reduce((acc, [errorMessage, jobs]) => {
+ acc[errorMessage] = Array.from(jobs);
+ return acc;
+ }, {} as Record)
+ : undefined;
+
+ return data;
+ }
+
+ async function getMetricData(
+ config: SeriesConfigWithMetadata,
+ range: ChartRange,
+ job: MlJob
+ ): Promise {
+ const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config;
+
+ // If the job uses aggregation or scripted fields, and if it's a config we don't support
+ // use model plot data if model plot is enabled
+ // else if source data can be plotted, use that, otherwise model plot will be available.
+ // @ts-ignore
+ const useSourceData = isSourceDataChartableForDetector(job, detectorIndex);
+
+ if (useSourceData) {
+ const datafeedQuery = get(config, 'datafeedConfig.query', null);
+
+ try {
+ return await fetchMetricData(
+ Array.isArray(config.datafeedConfig.indices)
+ ? config.datafeedConfig.indices.join()
+ : config.datafeedConfig.indices,
+ entityFields,
+ datafeedQuery,
+ config.metricFunction,
+ config.metricFieldName,
+ config.summaryCountFieldName,
+ config.timeField,
+ range.min,
+ range.max,
+ bucketSpanSeconds * 1000,
+ config.datafeedConfig
+ );
+ } catch (error) {
+ handleError(
+ i18n.translate('xpack.ml.timeSeriesJob.metricDataErrorMessage', {
+ defaultMessage: 'an error occurred while retrieving metric data',
+ }),
+ job.job_id
+ );
+ return { success: false, results: {}, error };
+ }
+ } else {
+ // Extract the partition, by, over fields on which to filter.
+ const criteriaFields: CriteriaField[] = [];
+ const detector = job.analysis_config.detectors[detectorIndex];
+ if (detector.partition_field_name !== undefined) {
+ const partitionEntity = find(entityFields, {
+ fieldName: detector.partition_field_name,
+ });
+ if (partitionEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
+ { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
+ );
+ }
+ }
+
+ if (detector.over_field_name !== undefined) {
+ const overEntity = find(entityFields, { fieldName: detector.over_field_name });
+ if (overEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
+ { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
+ );
+ }
+ }
+
+ if (detector.by_field_name !== undefined) {
+ const byEntity = find(entityFields, { fieldName: detector.by_field_name });
+ if (byEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
+ { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
+ );
+ }
+ }
+
+ const obj = {
+ success: true,
+ results: {} as Record,
+ };
+
+ try {
+ const resp = await getModelPlotOutput(
+ jobId,
+ detectorIndex,
+ criteriaFields,
+ range.min,
+ range.max,
+ bucketSpanSeconds * 1000
+ );
+ // Return data in format required by the explorer charts.
+ const results = resp.results;
+ Object.keys(results).forEach((time) => {
+ obj.results[time] = results[time].actual;
+ });
+
+ return obj;
+ } catch (error) {
+ handleError(
+ i18n.translate('xpack.ml.timeSeriesJob.modelPlotDataErrorMessage', {
+ defaultMessage: 'an error occurred while retrieving model plot data',
+ }),
+ job.job_id
+ );
+
+ return { success: false, results: {}, error };
+ }
+ }
+ }
+
+ /**
+ * TODO make an endpoint
+ */
+ async function getScheduledEventsByBucket(
+ jobIds: string[],
+ earliestMs: number,
+ latestMs: number,
+ intervalMs: number,
+ maxJobs: number,
+ maxEvents: number
+ ): Promise {
+ const obj: ScheduledEventsByBucket = {
+ success: true,
+ events: {},
+ };
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Adds criteria for the time range plus any specified job IDs.
+ const boolCriteria: any[] = [
+ {
+ range: {
+ timestamp: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ {
+ exists: { field: 'scheduled_events' },
+ },
+ ];
+
+ if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
+ let jobIdFilterStr = '';
+ each(jobIds, (jobId, i) => {
+ jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`;
+ });
+ boolCriteria.push({
+ query_string: {
+ analyze_wildcard: false,
+ query: jobIdFilterStr,
+ },
+ });
+ }
+
+ const searchRequest: estypes.SearchRequest = {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ result_type: 'bucket',
+ },
+ },
+ {
+ bool: {
+ must: boolCriteria,
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ jobs: {
+ terms: {
+ field: 'job_id',
+ min_doc_count: 1,
+ size: maxJobs,
+ },
+ aggs: {
+ times: {
+ date_histogram: {
+ field: 'timestamp',
+ fixed_interval: `${intervalMs}ms`,
+ min_doc_count: 1,
+ },
+ aggs: {
+ events: {
+ terms: {
+ field: 'scheduled_events',
+ size: maxEvents,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const resp = await mlClient.anomalySearch(searchRequest, jobIds);
+
+ const dataByJobId = get(resp, ['aggregations', 'jobs', 'buckets'], []);
+ each(dataByJobId, (dataForJob: any) => {
+ const jobId: string = dataForJob.key;
+ const resultsForTime: Record = {};
+ const dataByTime = get(dataForJob, ['times', 'buckets'], []);
+ each(dataByTime, (dataForTime: any) => {
+ const time: string = dataForTime.key;
+ const events: any[] = get(dataForTime, ['events', 'buckets']);
+ resultsForTime[time] = events.map((e) => e.key);
+ });
+ obj.events[jobId] = resultsForTime;
+ });
+
+ return obj;
+ }
+
+ async function getScheduledEvents(config: SeriesConfigWithMetadata, range: ChartRange) {
+ try {
+ return await getScheduledEventsByBucket(
+ [config.jobId],
+ range.min,
+ range.max,
+ config.bucketSpanSeconds * 1000,
+ 1,
+ MAX_SCHEDULED_EVENTS
+ );
+ } catch (error) {
+ handleError(
+ i18n.translate('xpack.ml.timeSeriesJob.scheduledEventsByBucketErrorMessage', {
+ defaultMessage: 'an error occurred while retrieving scheduled events',
+ }),
+ config.jobId
+ );
+ return { success: false, events: {}, error };
+ }
+ }
+
+ async function getEventDistributionData(
+ index: string,
+ splitField: EntityField | undefined | null,
+ filterField: EntityField | undefined | null,
+ query: any,
+ metricFunction: string | undefined | null, // ES aggregation name
+ metricFieldName: string | undefined,
+ timeFieldName: string,
+ earliestMs: number,
+ latestMs: number,
+ intervalMs: number
+ ): Promise {
+ if (splitField === undefined) {
+ return [];
+ }
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the time range, entity fields,
+ // plus any additional supplied query.
+ const mustCriteria = [];
+
+ mustCriteria.push({
+ range: {
+ [timeFieldName]: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ });
+
+ if (query) {
+ mustCriteria.push(query);
+ }
+
+ if (!!filterField) {
+ mustCriteria.push({
+ term: {
+ [filterField.fieldName]: filterField.fieldValue,
+ },
+ });
+ }
+
+ const body: estypes.SearchRequest = {
+ index,
+ track_total_hits: true,
+ query: {
+ // using function_score and random_score to get a random sample of documents.
+ // otherwise all documents would have the same score and the sampler aggregation
+ // would pick the first N documents instead of a random set.
+ function_score: {
+ query: {
+ bool: {
+ must: mustCriteria,
+ },
+ },
+ functions: [
+ {
+ random_score: {
+ // static seed to get same randomized results on every request
+ seed: 10,
+ field: '_seq_no',
+ },
+ },
+ ],
+ },
+ },
+ size: 0,
+ aggs: {
+ sample: {
+ sampler: {
+ shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE,
+ },
+ aggs: {
+ byTime: {
+ date_histogram: {
+ field: timeFieldName,
+ fixed_interval: `${intervalMs}ms`,
+ min_doc_count: AGGREGATION_MIN_DOC_COUNT,
+ },
+ aggs: {
+ entities: {
+ terms: {
+ field: splitField?.fieldName,
+ size: ENTITY_AGGREGATION_SIZE,
+ min_doc_count: AGGREGATION_MIN_DOC_COUNT,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ if (
+ metricFieldName !== undefined &&
+ metricFieldName !== '' &&
+ typeof metricFunction === 'string'
+ ) {
+ // @ts-ignore
+ body.aggs.sample.aggs.byTime.aggs.entities.aggs = {};
+
+ const metricAgg = {
+ [metricFunction]: {
+ field: metricFieldName,
+ },
+ };
+
+ if (metricFunction === 'percentiles') {
+ // @ts-ignore
+ metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS];
+ }
+
+ if (metricFunction === 'cardinality') {
+ // @ts-ignore
+ metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD;
+ }
+ // @ts-ignore
+ body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg;
+ }
+
+ const resp = await client!.asCurrentUser.search(body);
+
+ // Because of the sampling, results of metricFunctions which use sum or count
+ // can be significantly skewed. Taking into account totalHits we calculate a
+ // a factor to normalize results for these metricFunctions.
+ // @ts-ignore
+ const totalHits = resp.hits.total.value;
+ const successfulShards = get(resp, ['_shards', 'successful'], 0);
+
+ let normalizeFactor = 1;
+ if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) {
+ normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE);
+ }
+
+ const dataByTime = get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []);
+ // @ts-ignore
+ const data = dataByTime.reduce((d, dataForTime) => {
+ const date = +dataForTime.key;
+ const entities = get(dataForTime, ['entities', 'buckets'], []);
+ // @ts-ignore
+ entities.forEach((entity) => {
+ let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value;
+
+ if (
+ metricFunction === 'count' ||
+ metricFunction === 'cardinality' ||
+ metricFunction === 'sum'
+ ) {
+ value = value * normalizeFactor;
+ }
+
+ d.push({
+ date,
+ entity: entity.key,
+ value,
+ });
+ });
+ return d;
+ }, [] as any[]);
+
+ return data;
+ }
+
+ async function getEventDistribution(config: SeriesConfigWithMetadata, range: ChartRange) {
+ const chartType = getChartType(config);
+
+ let splitField;
+ let filterField = null;
+
+ // Define splitField and filterField based on chartType
+ if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
+ splitField = config.entityFields.find((f) => f.fieldType === 'by');
+ filterField = config.entityFields.find((f) => f.fieldType === 'partition');
+ } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ splitField = config.entityFields.find((f) => f.fieldType === 'over');
+ filterField = config.entityFields.find((f) => f.fieldType === 'partition');
+ }
+
+ const datafeedQuery = get(config, 'datafeedConfig.query', null);
+
+ try {
+ return await getEventDistributionData(
+ Array.isArray(config.datafeedConfig.indices)
+ ? config.datafeedConfig.indices.join()
+ : config.datafeedConfig.indices,
+ splitField,
+ filterField,
+ datafeedQuery,
+ config.metricFunction,
+ config.metricFieldName,
+ config.timeField,
+ range.min,
+ range.max,
+ config.bucketSpanSeconds * 1000
+ );
+ } catch (e) {
+ handleError(
+ i18n.translate('xpack.ml.timeSeriesJob.eventDistributionDataErrorMessage', {
+ defaultMessage: 'an error occurred while retrieving data',
+ }),
+ config.jobId
+ );
+ }
+ }
+
+ async function getRecordsForCriteriaChart(config: SeriesConfigWithMetadata, range: ChartRange) {
+ let criteria: EntityField[] = [];
+ criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex });
+ criteria = criteria.concat(config.entityFields);
+
+ try {
+ return await getRecordsForCriteria([config.jobId], criteria, 0, range.min, range.max, 500);
+ } catch (error) {
+ handleError(
+ i18n.translate('xpack.ml.timeSeriesJob.recordsForCriteriaErrorMessage', {
+ defaultMessage: 'an error occurred while retrieving anomaly records',
+ }),
+ config.jobId
+ );
+ return { success: false, records: [], error };
+ }
+ }
+
+ /**
+ * TODO make an endpoint
+ * @param jobIds
+ * @param criteriaFields
+ * @param threshold
+ * @param earliestMs
+ * @param latestMs
+ * @param maxResults
+ * @param functionDescription
+ */
+ async function getRecordsForCriteria(
+ jobIds: string[],
+ criteriaFields: CriteriaField[],
+ threshold: any,
+ earliestMs: number | null,
+ latestMs: number | null,
+ maxResults: number | undefined,
+ functionDescription?: string
+ ): Promise {
+ const obj: RecordsForCriteria = { success: true, records: [] };
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the time range, record score, plus any specified job IDs.
+ const boolCriteria: any[] = [
+ {
+ range: {
+ timestamp: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ {
+ range: {
+ record_score: {
+ gte: threshold,
+ },
+ },
+ },
+ ];
+
+ if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
+ let jobIdFilterStr = '';
+ each(jobIds, (jobId, i) => {
+ if (i > 0) {
+ jobIdFilterStr += ' OR ';
+ }
+ jobIdFilterStr += 'job_id:';
+ jobIdFilterStr += jobId;
+ });
+ boolCriteria.push({
+ query_string: {
+ analyze_wildcard: false,
+ query: jobIdFilterStr,
+ },
+ });
+ }
+
+ // Add in term queries for each of the specified criteria.
+ each(criteriaFields, (criteria) => {
+ boolCriteria.push({
+ term: {
+ [criteria.fieldName]: criteria.fieldValue,
+ },
+ });
+ });
+
+ if (functionDescription !== undefined) {
+ const mlFunctionToPlotIfMetric =
+ functionDescription !== undefined
+ ? aggregationTypeTransform.toML(functionDescription)
+ : functionDescription;
+
+ boolCriteria.push({
+ term: {
+ function_description: mlFunctionToPlotIfMetric,
+ },
+ });
+ }
+
+ const searchRequest: estypes.SearchRequest = {
+ size: maxResults !== undefined ? maxResults : 100,
+ query: {
+ bool: {
+ filter: [
+ {
+ query_string: {
+ query: 'result_type:record',
+ analyze_wildcard: false,
+ },
+ },
+ {
+ bool: {
+ must: boolCriteria,
+ },
+ },
+ ],
+ },
+ },
+ // @ts-ignore check score request
+ sort: [{ record_score: { order: 'desc' } }],
+ };
+
+ const resp = await mlClient.anomalySearch(searchRequest, jobIds);
+
+ // @ts-ignore
+ if (resp.hits.total.value > 0) {
+ each(resp.hits.hits, (hit: any) => {
+ obj.records.push(hit._source);
+ });
+ }
+ return obj;
+ }
+
+ async function getRecordsForInfluencer(
+ jobIds: string[],
+ influencers: EntityField[],
+ threshold: number,
+ earliestMs: number,
+ latestMs: number,
+ maxResults: number,
+ influencersFilterQuery?: InfluencersFilterQuery
+ ): Promise {
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the time range, record score, plus any specified job IDs.
+ const boolCriteria: estypes.QueryDslBoolQuery['must'] = [
+ {
+ range: {
+ timestamp: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ {
+ range: {
+ record_score: {
+ gte: threshold,
+ },
+ },
+ },
+ ];
+
+ // TODO optimize query
+ if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
+ let jobIdFilterStr = '';
+ each(jobIds, (jobId, i) => {
+ if (i > 0) {
+ jobIdFilterStr += ' OR ';
+ }
+ jobIdFilterStr += 'job_id:';
+ jobIdFilterStr += jobId;
+ });
+ boolCriteria.push({
+ query_string: {
+ analyze_wildcard: false,
+ query: jobIdFilterStr,
+ },
+ });
+ }
+
+ if (influencersFilterQuery !== undefined) {
+ boolCriteria.push(influencersFilterQuery);
+ }
+
+ // Add a nested query to filter for each of the specified influencers.
+ if (influencers.length > 0) {
+ boolCriteria.push({
+ bool: {
+ should: influencers.map((influencer) => {
+ return {
+ nested: {
+ path: 'influencers',
+ query: {
+ bool: {
+ must: [
+ {
+ match: {
+ 'influencers.influencer_field_name': influencer.fieldName,
+ },
+ },
+ {
+ match: {
+ 'influencers.influencer_field_values': influencer.fieldValue,
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+ }),
+ minimum_should_match: 1,
+ },
+ });
+ }
+
+ const response = await mlClient.anomalySearch>(
+ {
+ size: maxResults !== undefined ? maxResults : 100,
+ body: {
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ result_type: 'record',
+ },
+ },
+ {
+ bool: {
+ must: boolCriteria,
+ },
+ },
+ ],
+ },
+ },
+ sort: [{ record_score: { order: 'desc' } }],
+ },
+ },
+ jobIds
+ );
+
+ // @ts-ignore
+ return response.hits.hits
+ .map((hit) => {
+ return hit._source;
+ })
+ .filter(isDefined);
+ }
+
+ /**
+ * Provides anomaly charts data
+ */
+ async function getAnomalyChartsData(options: {
+ jobIds: string[];
+ influencers: EntityField[];
+ threshold: number;
+ earliestMs: number;
+ latestMs: number;
+ maxResults: number;
+ influencersFilterQuery?: InfluencersFilterQuery;
+ numberOfPoints: number;
+ timeBounds: { min?: number; max?: number };
+ }) {
+ const {
+ jobIds,
+ earliestMs,
+ latestMs,
+ maxResults,
+ influencersFilterQuery,
+ influencers,
+ numberOfPoints,
+ threshold,
+ timeBounds,
+ } = options;
+
+ // First fetch records that satisfy influencers query criteria
+ const recordsForInfluencers = await getRecordsForInfluencer(
+ jobIds,
+ influencers,
+ threshold,
+ earliestMs,
+ latestMs,
+ 500,
+ influencersFilterQuery
+ );
+
+ const selectedJobs = (await mlClient.getJobs({ job_id: jobIds })).jobs;
+
+ const combinedJobRecords: Record = keyBy(selectedJobs, 'job_id');
+
+ const chartData = await getAnomalyData(
+ combinedJobRecords,
+ recordsForInfluencers,
+ earliestMs,
+ latestMs,
+ numberOfPoints,
+ timeBounds,
+ threshold,
+ maxResults
+ );
+
+ return chartData;
+ }
+
+ return getAnomalyChartsData;
+}
diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts
index aa92ada043c29..9caf41ce97ee3 100644
--- a/x-pack/plugins/ml/server/models/results_service/results_service.ts
+++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts
@@ -28,6 +28,7 @@ import type { MlClient } from '../../lib/ml_client';
import { datafeedsProvider } from '../job_service/datafeeds';
import { annotationServiceProvider } from '../annotation_service';
import { showActualForFunction, showTypicalForFunction } from '../../../common/util/anomaly_utils';
+import { anomalyChartsDataProvider } from './anomaly_charts';
// Service for carrying out Elasticsearch queries to obtain data for the
// ML Results dashboards.
@@ -806,5 +807,6 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust
getCategorizerStats,
getCategoryStoppedPartitions,
getDatafeedResultsChartData,
+ getAnomalyChartsData: anomalyChartsDataProvider(mlClient, client!),
};
}
diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json
index 5b518641548b6..59ed08664da3b 100644
--- a/x-pack/plugins/ml/server/routes/apidoc.json
+++ b/x-pack/plugins/ml/server/routes/apidoc.json
@@ -55,6 +55,7 @@
"AnomalySearch",
"GetCategorizerStats",
"GetCategoryStoppedPartitions",
+ "GetAnomalyChartsData",
"Modules",
"DataRecognizer",
diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts
index 78f05f0d731aa..a04eee11cbf10 100644
--- a/x-pack/plugins/ml/server/routes/results_service.ts
+++ b/x-pack/plugins/ml/server/routes/results_service.ts
@@ -15,6 +15,7 @@ import {
maxAnomalyScoreSchema,
partitionFieldValuesSchema,
anomalySearchSchema,
+ getAnomalyChartsSchema,
} from './schemas/results_service_schema';
import { resultsServiceProvider } from '../models/results_service';
import { jobIdSchema } from './schemas/anomaly_detectors_schema';
@@ -388,4 +389,37 @@ export function resultsServiceRoutes({ router, routeGuard }: RouteInitialization
}
})
);
+
+ /**
+ * @apiGroup ResultsService
+ *
+ * @api {post} /api/ml/results/anomaly_charts Get data for anomaly charts
+ * @apiName GetAnomalyChartsData
+ * @apiDescription Returns anomaly charts data
+ *
+ * @apiSchema (body) getAnomalyChartsSchema
+ */
+ router.post(
+ {
+ path: '/api/ml/results/anomaly_charts',
+ validate: {
+ body: getAnomalyChartsSchema,
+ },
+ options: {
+ tags: ['access:ml:canGetJobs'],
+ },
+ },
+ routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => {
+ try {
+ const { getAnomalyChartsData } = resultsServiceProvider(mlClient, client);
+ const resp = await getAnomalyChartsData(request.body);
+
+ return response.ok({
+ body: resp,
+ });
+ } catch (e) {
+ return response.customError(wrapError(e));
+ }
+ })
+ );
}
diff --git a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts
index df4a56b06410b..e3b2bc84eb861 100644
--- a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts
+++ b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts
@@ -112,3 +112,27 @@ export const getDatafeedResultsChartDataSchema = schema.object({
start: schema.number(),
end: schema.number(),
});
+
+export const getAnomalyChartsSchema = schema.object({
+ jobIds: schema.arrayOf(schema.string()),
+ influencers: schema.arrayOf(schema.any()),
+ /**
+ * Severity threshold
+ */
+ threshold: schema.number({ defaultValue: 0, min: 0, max: 99 }),
+ earliestMs: schema.number(),
+ latestMs: schema.number(),
+ /**
+ * Maximum amount of series data.
+ */
+ maxResults: schema.number({ defaultValue: 6, min: 1, max: 10 }),
+ influencersFilterQuery: schema.maybe(schema.any()),
+ /**
+ * Optimal number of data points per chart
+ */
+ numberOfPoints: schema.number(),
+ timeBounds: schema.object({
+ min: schema.maybe(schema.number()),
+ max: schema.maybe(schema.number()),
+ }),
+});
diff --git a/x-pack/plugins/monitoring_collection/server/constants.ts b/x-pack/plugins/monitoring_collection/server/constants.ts
index 90f3ce67ffaeb..86231dec6c6c2 100644
--- a/x-pack/plugins/monitoring_collection/server/constants.ts
+++ b/x-pack/plugins/monitoring_collection/server/constants.ts
@@ -4,4 +4,4 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-export const TYPE_ALLOWLIST = ['rules', 'actions'];
+export const TYPE_ALLOWLIST = ['node_rules', 'cluster_rules', 'node_actions', 'cluster_actions'];
diff --git a/x-pack/plugins/monitoring_collection/server/index.ts b/x-pack/plugins/monitoring_collection/server/index.ts
index 51264a4d8781f..1f2d68d6336bf 100644
--- a/x-pack/plugins/monitoring_collection/server/index.ts
+++ b/x-pack/plugins/monitoring_collection/server/index.ts
@@ -12,7 +12,7 @@ import { configSchema } from './config';
export type { MonitoringCollectionConfig } from './config';
-export type { MonitoringCollectionSetup, MetricResult } from './plugin';
+export type { MonitoringCollectionSetup, MetricResult, Metric } from './plugin';
export const plugin = (initContext: PluginInitializerContext) =>
new MonitoringCollectionPlugin(initContext);
diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts
index 7697cfda6d22d..ce0b99afd14eb 100644
--- a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts
+++ b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.test.ts
@@ -57,4 +57,22 @@ describe('getKibanaStats', () => {
status: 'red',
});
});
+
+ it('should handle no status', async () => {
+ const getStatus = () => {
+ return undefined;
+ };
+ const stats = await getKibanaStats({ config, getStatus });
+ expect(stats).toStrictEqual({
+ uuid: config.uuid,
+ name: config.server.name,
+ index: config.kibanaIndex,
+ host: config.server.hostname,
+ locale: 'en',
+ transport_address: `${config.server.hostname}:${config.server.port}`,
+ version: '8.0.0',
+ snapshot: false,
+ status: 'unknown',
+ });
+ });
});
diff --git a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts
index 7d3011deb447d..2b2d72305c2c7 100644
--- a/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts
+++ b/x-pack/plugins/monitoring_collection/server/lib/get_kibana_stats.ts
@@ -30,7 +30,7 @@ export function getKibanaStats({
port: number;
};
};
- getStatus: () => ServiceStatus;
+ getStatus: () => ServiceStatus | undefined;
}) {
const status = getStatus();
return {
@@ -42,6 +42,6 @@ export function getKibanaStats({
transport_address: `${config.server.hostname}:${config.server.port}`,
version: config.kibanaVersion.replace(SNAPSHOT_REGEX, ''),
snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion),
- status: ServiceStatusToLegacyState[status.level.toString()],
+ status: status ? ServiceStatusToLegacyState[status.level.toString()] : 'unknown', // If not status, not available yet
};
}
diff --git a/x-pack/plugins/monitoring_collection/server/mocks.ts b/x-pack/plugins/monitoring_collection/server/mocks.ts
new file mode 100644
index 0000000000000..9858653df648f
--- /dev/null
+++ b/x-pack/plugins/monitoring_collection/server/mocks.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { MonitoringCollectionSetup } from '.';
+
+const createSetupMock = (): jest.Mocked => {
+ const mock = {
+ registerMetric: jest.fn(),
+ getMetrics: jest.fn(),
+ };
+ return mock;
+};
+
+export const monitoringCollectionMock = {
+ createSetup: createSetupMock,
+};
diff --git a/x-pack/plugins/monitoring_collection/server/plugin.test.ts b/x-pack/plugins/monitoring_collection/server/plugin.test.ts
index ebdb33c78322f..b9553b68daf24 100644
--- a/x-pack/plugins/monitoring_collection/server/plugin.test.ts
+++ b/x-pack/plugins/monitoring_collection/server/plugin.test.ts
@@ -28,7 +28,7 @@ describe('monitoring_collection plugin', () => {
it('should allow registering a collector and getting data from it', async () => {
const { registerMetric } = plugin.setup(coreSetup);
registerMetric<{ name: string }>({
- type: 'actions',
+ type: 'cluster_actions',
schema: {
name: {
type: 'text',
@@ -43,14 +43,14 @@ describe('monitoring_collection plugin', () => {
},
});
- const metrics = await plugin.getMetric('actions');
+ const metrics = await plugin.getMetric('cluster_actions');
expect(metrics).toStrictEqual([{ name: 'foo' }]);
});
it('should allow registering multiple ollectors and getting data from it', async () => {
const { registerMetric } = plugin.setup(coreSetup);
registerMetric<{ name: string }>({
- type: 'actions',
+ type: 'cluster_actions',
schema: {
name: {
type: 'text',
@@ -65,7 +65,7 @@ describe('monitoring_collection plugin', () => {
},
});
registerMetric<{ name: string }>({
- type: 'rules',
+ type: 'cluster_rules',
schema: {
name: {
type: 'text',
@@ -86,7 +86,10 @@ describe('monitoring_collection plugin', () => {
},
});
- const metrics = await Promise.all([plugin.getMetric('actions'), plugin.getMetric('rules')]);
+ const metrics = await Promise.all([
+ plugin.getMetric('cluster_actions'),
+ plugin.getMetric('cluster_rules'),
+ ]);
expect(metrics).toStrictEqual([
[{ name: 'foo' }],
[{ name: 'foo' }, { name: 'bar' }, { name: 'foobar' }],
diff --git a/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts
index d884d8efc15ad..817b65fa95d8f 100644
--- a/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts
+++ b/x-pack/plugins/monitoring_collection/server/routes/dynamic_route.ts
@@ -27,7 +27,7 @@ export function registerDynamicRoute({
port: number;
};
};
- getStatus: () => ServiceStatus;
+ getStatus: () => ServiceStatus | undefined;
getMetric: (
type: string
) => Promise> | MetricResult | undefined>;
diff --git a/x-pack/plugins/monitoring_collection/tsconfig.json b/x-pack/plugins/monitoring_collection/tsconfig.json
index 41f781cb8cb9f..c382b243b3fec 100644
--- a/x-pack/plugins/monitoring_collection/tsconfig.json
+++ b/x-pack/plugins/monitoring_collection/tsconfig.json
@@ -12,6 +12,5 @@
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
- { "path": "../monitoring/tsconfig.json" },
]
}
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
index 48779569131d6..2fdf0a07f4647 100644
--- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
@@ -27,39 +27,47 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) {
return (
-
-
-
-
-
-
-
- {noHasDataBoxes.map((box) => (
-
-
-
- ))}
+ {noHasDataBoxes.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ {noHasDataBoxes.map((box) => (
+
+
+
+ ))}
+ >
+ )}
- {noHasDataBoxes.length > 0 && }
+ {noHasDataBoxes.length > 0 && hasDataBoxes.length > 0 && }
-
-
-
-
-
-
-
- {hasDataBoxes.map((box) => (
-
-
-
- ))}
+ {hasDataBoxes.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ {hasDataBoxes.map((box) => (
+
+
+
+ ))}
+ >
+ )}
);
}
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx
new file mode 100644
index 0000000000000..6e79c3691402a
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { HasDataContextValue } from '../../../context/has_data_context';
+import * as hasDataHook from '../../../hooks/use_has_data';
+import { ObservabilityStatusProgress } from './observability_status_progress';
+import { I18nProvider } from '@kbn/i18n-react';
+
+describe('ObservabilityStatusProgress', () => {
+ const onViewDetailsClickFn = jest.fn();
+
+ beforeEach(() => {
+ jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({
+ hasDataMap: {
+ apm: { hasData: true, status: 'success' },
+ synthetics: { hasData: true, status: 'success' },
+ infra_logs: { hasData: undefined, status: 'success' },
+ infra_metrics: { hasData: true, status: 'success' },
+ ux: { hasData: undefined, status: 'success' },
+ alert: { hasData: false, status: 'success' },
+ },
+ hasAnyData: true,
+ isAllRequestsComplete: true,
+ onRefreshTimeRange: () => {},
+ forceUpdate: '',
+ } as HasDataContextValue);
+ });
+ it('should render the progress', () => {
+ render(
+
+
+
+ );
+ const progressBar = screen.getByRole('progressbar') as HTMLProgressElement;
+ expect(progressBar).toBeInTheDocument();
+ expect(progressBar.value).toBe(50);
+ });
+
+ it('should call the onViewDetailsCallback when view details button is clicked', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByText('View details'));
+ expect(onViewDetailsClickFn).toHaveBeenCalled();
+ });
+
+ it('should hide the component when dismiss button is clicked', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByText('Dismiss'));
+ expect(screen.queryByTestId('status-progress')).toBe(null);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx
new file mode 100644
index 0000000000000..81f08537c775f
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React, { useState, useEffect } from 'react';
+import {
+ EuiPanel,
+ EuiProgress,
+ EuiTitle,
+ EuiButtonEmpty,
+ EuiButton,
+ EuiText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+} from '@elastic/eui';
+import { reduce } from 'lodash';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { useHasData } from '../../../hooks/use_has_data';
+import { useUiTracker } from '../../../hooks/use_track_metric';
+
+const LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY = 'HIDE_GUIDED_SETUP';
+
+interface ObservabilityStatusProgressProps {
+ onViewDetailsClick: () => void;
+}
+export function ObservabilityStatusProgress({
+ onViewDetailsClick,
+}: ObservabilityStatusProgressProps) {
+ const { hasDataMap, isAllRequestsComplete } = useHasData();
+ const trackMetric = useUiTracker({ app: 'observability-overview' });
+ const hideGuidedSetupLocalStorageKey = window.localStorage.getItem(
+ LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY
+ );
+ const [isGuidedSetupHidden, setIsGuidedSetupHidden] = useState(
+ JSON.parse(hideGuidedSetupLocalStorageKey || 'false')
+ );
+ const [progress, setProgress] = useState(0);
+
+ useEffect(() => {
+ const totalCounts = Object.keys(hasDataMap);
+ if (isAllRequestsComplete) {
+ const hasDataCount = reduce(
+ hasDataMap,
+ (result, value) => {
+ return value?.hasData ? result + 1 : result;
+ },
+ 0
+ );
+
+ const percentage = (hasDataCount / totalCounts.length) * 100;
+ setProgress(isFinite(percentage) ? percentage : 0);
+ }
+ }, [isAllRequestsComplete, hasDataMap]);
+
+ const hideGuidedSetup = () => {
+ window.localStorage.setItem(LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY, 'true');
+ setIsGuidedSetupHidden(true);
+ trackMetric({ metric: 'guided_setup_progress_dismiss' });
+ };
+
+ const showDetails = () => {
+ onViewDetailsClick();
+ trackMetric({ metric: 'guided_setup_progress_view_details' });
+ };
+
+ return !isGuidedSetupHidden ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null;
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts
index 286ad7005c2cb..faddc5ab9596c 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts
@@ -17,6 +17,7 @@ import {
} from './elasticsearch_fieldnames';
import {
AGENT_HOST_LABEL,
+ AGENT_TYPE_LABEL,
BROWSER_FAMILY_LABEL,
BROWSER_VERSION_LABEL,
CLS_LABEL,
@@ -43,6 +44,7 @@ import {
PORT_LABEL,
REQUEST_METHOD,
SERVICE_NAME_LABEL,
+ SERVICE_TYPE_LABEL,
TAGS_LABEL,
TBT_LABEL,
URL_LABEL,
@@ -52,6 +54,8 @@ import {
LABELS_FIELD,
STEP_NAME_LABEL,
STEP_DURATION_LABEL,
+ EVENT_DATASET_LABEL,
+ MESSAGE_LABEL,
} from './labels';
import {
MONITOR_DURATION_US,
@@ -79,6 +83,9 @@ export const FieldLabels: Record = {
'observer.geo.name': OBSERVER_LOCATION_LABEL,
'service.name': SERVICE_NAME_LABEL,
'service.environment': ENVIRONMENT_LABEL,
+ 'service.type': SERVICE_TYPE_LABEL,
+ 'event.dataset': EVENT_DATASET_LABEL,
+ message: MESSAGE_LABEL,
[LCP_FIELD]: LCP_LABEL,
[FCP_FIELD]: FCP_LABEL,
@@ -101,6 +108,7 @@ export const FieldLabels: Record = {
[SYNTHETICS_STEP_NAME]: STEP_NAME_LABEL,
'agent.hostname': AGENT_HOST_LABEL,
+ 'agent.type': AGENT_TYPE_LABEL,
'host.hostname': HOST_NAME_LABEL,
'monitor.name': MONITOR_NAME_LABEL,
'monitor.type': MONITOR_TYPE_LABEL,
@@ -137,6 +145,7 @@ export enum DataTypes {
UX = 'ux',
MOBILE = 'mobile',
METRICS = 'infra_metrics',
+ LOGS = 'infra_logs',
}
export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN';
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts
new file mode 100644
index 0000000000000..b35c6ac2e42dd
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { RECORDS_FIELD } from '../constants';
+
+export const LOG_RATE = RECORDS_FIELD;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts
index 13375004acb22..912424cc7eb2d 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/labels.ts
@@ -45,6 +45,13 @@ export const SERVICE_NAME_LABEL = i18n.translate(
}
);
+export const SERVICE_TYPE_LABEL = i18n.translate(
+ 'xpack.observability.expView.fieldLabels.serviceType',
+ {
+ defaultMessage: 'Service type',
+ }
+);
+
export const ENVIRONMENT_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.environment',
{
@@ -52,6 +59,13 @@ export const ENVIRONMENT_LABEL = i18n.translate(
}
);
+export const EVENT_DATASET_LABEL = i18n.translate(
+ 'xpack.observability.expView.fieldLabels.eventDataset',
+ {
+ defaultMessage: 'Dataset',
+ }
+);
+
export const LCP_LABEL = i18n.translate('xpack.observability.expView.fieldLabels.lcp', {
defaultMessage: 'Largest contentful paint',
});
@@ -136,6 +150,17 @@ export const AGENT_HOST_LABEL = i18n.translate(
}
);
+export const AGENT_TYPE_LABEL = i18n.translate(
+ 'xpack.observability.expView.fieldLabels.agentType',
+ {
+ defaultMessage: 'Agent type',
+ }
+);
+
+export const MESSAGE_LABEL = i18n.translate('xpack.observability.expView.fieldLabels.message', {
+ defaultMessage: 'Message',
+});
+
export const HOST_NAME_LABEL = i18n.translate('xpack.observability.expView.fieldLabels.hostName', {
defaultMessage: 'Host name',
});
@@ -359,3 +384,7 @@ export const NUMBER_OF_DEVICES = i18n.translate(
defaultMessage: 'Number of Devices',
}
);
+
+export const LOG_RATE = i18n.translate('xpack.observability.expView.fieldLabels.logRate', {
+ defaultMessage: 'Log rate',
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts
new file mode 100644
index 0000000000000..a5f8faeed1062
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { ConfigProps, SeriesConfig } from '../../types';
+import { FieldLabels, RECORDS_FIELD, ReportTypes } from '../constants';
+import { LOG_RATE as LOG_RATE_FIELD } from '../constants/field_names/infra_logs';
+import { LOG_RATE as LOG_RATE_LABEL } from '../constants/labels';
+
+export function getLogsKPIConfig(configProps: ConfigProps): SeriesConfig {
+ return {
+ reportType: ReportTypes.KPI,
+ defaultSeriesType: 'bar',
+ seriesTypes: [],
+ xAxisColumn: {
+ label: i18n.translate('xpack.observability.exploratoryView.logs.logRateXAxisLabel', {
+ defaultMessage: 'Timestamp',
+ }),
+ dataType: 'date',
+ operationType: 'date_histogram',
+ sourceField: '@timestamp',
+ isBucketed: true,
+ scale: 'interval',
+ },
+ yAxisColumns: [
+ {
+ label: i18n.translate('xpack.observability.exploratoryView.logs.logRateYAxisLabel', {
+ defaultMessage: 'Log rate per minute',
+ }),
+ dataType: 'number',
+ operationType: 'count',
+ isBucketed: false,
+ scale: 'ratio',
+ sourceField: RECORDS_FIELD,
+ timeScale: 'm',
+ },
+ ],
+ hasOperationType: false,
+ filterFields: ['agent.type', 'service.type', 'event.dataset'],
+ breakdownFields: ['agent.hostname', 'service.type', 'event.dataset'],
+ baseFilters: [],
+ definitionFields: ['agent.hostname', 'service.type', 'event.dataset'],
+ textDefinitionFields: ['message'],
+ metricOptions: [
+ {
+ label: LOG_RATE_LABEL,
+ field: RECORDS_FIELD,
+ id: LOG_RATE_FIELD,
+ columnType: 'unique_count',
+ },
+ ],
+ labels: { ...FieldLabels },
+ };
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts
index cf11536c7a846..9a41fec6fc391 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts
@@ -501,14 +501,15 @@ export class LensAttributes {
getMainYAxis(layerConfig: LayerConfig, layerId: string, columnFilter: string) {
const { breakdown } = layerConfig;
- const { sourceField, operationType, label } = layerConfig.seriesConfig.yAxisColumns[0];
+ const { sourceField, operationType, label, timeScale } =
+ layerConfig.seriesConfig.yAxisColumns[0];
if (sourceField === RECORDS_PERCENTAGE_FIELD) {
return getDistributionInPercentageColumn({ label, layerId, columnFilter }).main;
}
if (sourceField === RECORDS_FIELD || !sourceField) {
- return this.getRecordsColumn(label);
+ return this.getRecordsColumn(label, undefined, timeScale);
}
return this.getColumnBasedOnType({
@@ -628,6 +629,7 @@ export class LensAttributes {
});
const urlFilters = urlFiltersToKueryString(filters ?? []);
+
if (!baseFilters) {
return urlFilters;
}
@@ -682,7 +684,6 @@ export class LensAttributes {
const columnFilter = this.getLayerFilters(layerConfig, layerConfigs.length);
const timeShift = this.getTimeShift(this.layerConfigs[0], layerConfig, index);
const mainYAxis = this.getMainYAxis(layerConfig, layerId, columnFilter);
-
const { sourceField } = seriesConfig.xAxisColumn;
const label = timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts
index 4ece4ff056a59..46f9beba99e41 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts
@@ -13,6 +13,7 @@ import {
HOST_OS,
OS_PLATFORM,
SERVICE_VERSION,
+ URL_LABEL,
} from '../constants/labels';
export const MobileFields: Record = {
@@ -23,4 +24,5 @@ export const MobileFields: Record = {
'network.carrier.name': CARRIER_NAME,
'network.connection_type': CONNECTION_TYPE,
'labels.device_model': DEVICE_MODEL,
+ 'url.full': URL_LABEL,
};
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts
index ead75d79582cc..412bf2ef87f6b 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts
@@ -46,7 +46,7 @@ export function getSyntheticsDistributionConfig({ series, dataView }: ConfigProp
},
],
hasOperationType: false,
- filterFields: ['monitor.type', 'observer.geo.name', 'tags'],
+ filterFields: ['monitor.type', 'observer.geo.name', 'tags', 'url.full'],
breakdownFields: [
'observer.geo.name',
'monitor.name',
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
index 217d34facbf0f..c626ba5d522c2 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts
@@ -66,7 +66,7 @@ export function getSyntheticsKPIConfig({ dataView }: ConfigProps): SeriesConfig
},
],
hasOperationType: false,
- filterFields: ['observer.geo.name', 'monitor.type', 'tags'],
+ filterFields: ['observer.geo.name', 'monitor.type', 'tags', 'url.full'],
breakdownFields: [
'observer.geo.name',
'monitor.type',
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx
index e92b0878ba3e9..ecc6ac3c63eda 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx
@@ -72,6 +72,11 @@ export function DataViewContextProvider({ children }: ProviderProps) {
hasDataT = Boolean(resultMetrics?.hasData);
indices = resultMetrics?.indices;
break;
+ case 'infra_logs':
+ const resultLogs = await getDataHandler(dataType)?.hasData();
+ hasDataT = Boolean(resultLogs?.hasData);
+ indices = resultLogs?.indices;
+ break;
case 'apm':
case 'mobile':
const resultApm = await getDataHandler('apm')!.hasData();
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts
index a430c4e79862e..748d2e8fd6d0c 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts
@@ -26,12 +26,14 @@ import { EuiTheme } from '../../../../../../../../src/plugins/kibana_react/commo
import { LABEL_FIELDS_BREAKDOWN } from '../configurations/constants';
import { ReportConfigMap, useExploratoryView } from '../contexts/exploratory_view_config';
-export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitions']) => {
+export const getFiltersFromDefs = (
+ reportDefinitions: SeriesUrl['reportDefinitions'] | SeriesUrl['textReportDefinitions']
+) => {
return Object.entries(reportDefinitions ?? {})
.map(([field, value]) => {
return {
field,
- values: value,
+ values: Array.isArray(value) ? value : [value],
};
})
.filter(({ values }) => !values.includes(ALL_VALUES_SELECTED)) as UrlFilter[];
@@ -63,7 +65,8 @@ export function getLayerConfigs(
});
const filters: UrlFilter[] = (series.filters ?? []).concat(
- getFiltersFromDefs(series.reportDefinitions)
+ getFiltersFromDefs(series.reportDefinitions),
+ getFiltersFromDefs(series.textReportDefinitions)
);
const color = `euiColorVis${seriesIndex}`;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx
index a8deb76432672..c6760aec6814a 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx
@@ -29,7 +29,7 @@ import { getMobileKPIDistributionConfig } from './configurations/mobile/distribu
import { getMobileKPIConfig } from './configurations/mobile/kpi_over_time_config';
import { getMobileDeviceDistributionConfig } from './configurations/mobile/device_distribution_config';
import { usePluginContext } from '../../../hooks/use_plugin_context';
-import { getMetricsKPIConfig } from './configurations/infra_metrics/kpi_over_time_config';
+import { getLogsKPIConfig } from './configurations/infra_logs/kpi_over_time_config';
export const DataTypesLabels = {
[DataTypes.UX]: i18n.translate('xpack.observability.overview.exploratoryView.uxLabel', {
@@ -44,7 +44,11 @@ export const DataTypesLabels = {
),
[DataTypes.METRICS]: i18n.translate('xpack.observability.overview.exploratoryView.metricsLabel', {
- defaultMessage: 'Infra metrics',
+ defaultMessage: 'Metrics',
+ }),
+
+ [DataTypes.LOGS]: i18n.translate('xpack.observability.overview.exploratoryView.logsLabel', {
+ defaultMessage: 'Logs',
}),
[DataTypes.MOBILE]: i18n.translate(
@@ -64,8 +68,8 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [
label: DataTypesLabels[DataTypes.UX],
},
{
- id: DataTypes.METRICS,
- label: DataTypesLabels[DataTypes.METRICS],
+ id: DataTypes.LOGS,
+ label: DataTypesLabels[DataTypes.LOGS],
},
{
id: DataTypes.MOBILE,
@@ -91,7 +95,7 @@ export const obsvReportConfigMap = {
getMobileKPIDistributionConfig,
getMobileDeviceDistributionConfig,
],
- [DataTypes.METRICS]: [getMetricsKPIConfig],
+ [DataTypes.LOGS]: [getLogsKPIConfig],
};
export function ObservabilityExploratoryView() {
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx
index 778a4737e81b4..22bcbb186fcb3 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx
@@ -57,6 +57,10 @@ export function DataTypesSelect({ seriesId, series }: Props) {
if (reportType === ReportTypes.CORE_WEB_VITAL) {
return id === DataTypes.UX;
}
+ // NOTE: Logs only provides a config for KPI over time
+ if (id === DataTypes.LOGS) {
+ return reportType === ReportTypes.KPI;
+ }
return true;
})
.map(({ id, label }) => ({
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx
index a665ec1999133..ccb439549c619 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx
@@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesConfig, SeriesUrl } from '../../types';
import { ReportDefinitionField } from './report_definition_field';
+import { TextReportDefinitionField } from './text_report_definition_field';
import { isStepLevelMetric } from '../../configurations/synthetics/kpi_over_time_config';
import { SYNTHETICS_STEP_NAME } from '../../configurations/constants/field_names/synthetics';
@@ -25,9 +26,12 @@ export function ReportDefinitionCol({
}) {
const { setSeries } = useSeriesStorage();
- const { reportDefinitions: selectedReportDefinitions = {} } = series;
+ const {
+ reportDefinitions: selectedReportDefinitions = {},
+ textReportDefinitions: selectedTextReportDefinitions = {},
+ } = series;
- const { definitionFields } = seriesConfig;
+ const { definitionFields, textDefinitionFields } = seriesConfig;
const onChange = (field: string, value?: string[]) => {
if (!value?.[0]) {
@@ -44,6 +48,21 @@ export function ReportDefinitionCol({
}
};
+ const onChangeTextDefinitionField = (field: string, value: string) => {
+ if (isEmpty(value)) {
+ delete selectedTextReportDefinitions[field];
+ setSeries(seriesId, {
+ ...series,
+ textReportDefinitions: { ...selectedTextReportDefinitions },
+ });
+ } else {
+ setSeries(seriesId, {
+ ...series,
+ textReportDefinitions: { ...selectedTextReportDefinitions, [field]: value },
+ });
+ }
+ };
+
const hasFieldDataSelected = (field: string) => {
return !isEmpty(series.reportDefinitions?.[field]);
};
@@ -102,6 +121,20 @@ export function ReportDefinitionCol({
>
);
})}
+
+ {textDefinitionFields?.map((field) => {
+ return (
+
+
+
+ );
+ })}
);
}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx
index b0ee4651bdc31..cc84f64c2c7f0 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { useMemo } from 'react';
import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { FilterExpanded } from './filter_expanded';
import { SeriesConfig, SeriesUrl } from '../../types';
@@ -44,12 +44,26 @@ export function SeriesFilter({ series, seriesConfig, seriesId }: Props) {
};
});
+ const hasUrlFilter = useMemo(() => {
+ return seriesConfig.filterFields.some((field) => {
+ if (typeof field === 'string') {
+ return field === TRANSACTION_URL;
+ } else if (field.field !== undefined) {
+ return field.field === TRANSACTION_URL;
+ } else {
+ return false;
+ }
+ });
+ }, [seriesConfig]);
+
return (
<>
-
-
-
+ {hasUrlFilter ? (
+
+
+
+ ) : null}
{options.map((opt) =>
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx
new file mode 100644
index 0000000000000..844de3201489e
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { EuiFieldText, EuiFormRow } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { SeriesConfig, SeriesUrl } from '../../types';
+
+interface Props {
+ seriesId: number;
+ series: SeriesUrl;
+ field: string;
+ seriesConfig: SeriesConfig;
+ onChange: (field: string, value: string) => void;
+}
+
+export function TextReportDefinitionField({ series, field, seriesConfig, onChange }: Props) {
+ const { textReportDefinitions: selectedTextReportDefinitions = {} } = series;
+ const { labels } = seriesConfig;
+ const label = labels[field] ?? field;
+
+ return (
+
+ onChange(field, e.target.value)}
+ compressed={false}
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts
index 9fa565e4eae34..775c989df2aec 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts
@@ -65,6 +65,7 @@ export interface SeriesConfig {
filters?: Array;
}
>;
+ textDefinitionFields?: string[];
metricOptions?: MetricOption[];
labels: Record;
hasOperationType: boolean;
@@ -75,6 +76,7 @@ export interface SeriesConfig {
}
export type URLReportDefinition = Record;
+export type URLTextReportDefinition = Record;
export interface SeriesUrl {
name: string;
@@ -88,6 +90,7 @@ export interface SeriesUrl {
operationType?: OperationType;
dataType: AppDataType;
reportDefinitions?: URLReportDefinition;
+ textReportDefinitions?: URLTextReportDefinition;
selectedMetricField?: string;
hidden?: boolean;
color?: string;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts
index 29b0ac417f50f..0e044bc1e2a27 100644
--- a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts
@@ -26,6 +26,7 @@ function addSlashes(str: string) {
export const urlFiltersToKueryString = (urlFilters: UrlFilter[]): string => {
let kueryString = '';
+
urlFilters.forEach(({ field, values, notValues, wildcards, notWildcards }) => {
const valuesT = values?.map((val) => `"${addSlashes(val)}"`);
const notValuesT = notValues?.map((val) => `"${addSlashes(val)}"`);
diff --git a/x-pack/plugins/observability/public/context/has_data_context.test.tsx b/x-pack/plugins/observability/public/context/has_data_context.test.tsx
index e933364b7015a..5c03c802c5e7e 100644
--- a/x-pack/plugins/observability/public/context/has_data_context.test.tsx
+++ b/x-pack/plugins/observability/public/context/has_data_context.test.tsx
@@ -92,7 +92,10 @@ describe('HasDataContextProvider', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => ({ hasData: false }) },
- { appName: 'infra_logs', hasData: async () => false },
+ {
+ appName: 'infra_logs',
+ hasData: async () => ({ hasData: false, indices: 'test-index' }),
+ },
{ appName: 'infra_metrics', hasData: async () => ({ hasData: false }) },
{
appName: 'synthetics',
@@ -129,7 +132,7 @@ describe('HasDataContextProvider', () => {
hasData: false,
status: 'success',
},
- infra_logs: { hasData: false, status: 'success' },
+ infra_logs: { hasData: false, indices: 'test-index', status: 'success' },
infra_metrics: { hasData: false, status: 'success' },
ux: {
hasData: false,
@@ -149,7 +152,10 @@ describe('HasDataContextProvider', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => ({ hasData: true }) },
- { appName: 'infra_logs', hasData: async () => false },
+ {
+ appName: 'infra_logs',
+ hasData: async () => ({ hasData: false, indices: 'test-index' }),
+ },
{
appName: 'infra_metrics',
hasData: async () => ({ hasData: false, indices: 'metric-*' }),
@@ -189,7 +195,7 @@ describe('HasDataContextProvider', () => {
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
- infra_logs: { hasData: false, status: 'success' },
+ infra_logs: { hasData: false, indices: 'test-index', status: 'success' },
infra_metrics: { hasData: false, indices: 'metric-*', status: 'success' },
ux: {
hasData: false,
@@ -210,7 +216,10 @@ describe('HasDataContextProvider', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => ({ hasData: true }) },
- { appName: 'infra_logs', hasData: async () => true },
+ {
+ appName: 'infra_logs',
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
+ },
{
appName: 'infra_metrics',
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
@@ -253,7 +262,7 @@ describe('HasDataContextProvider', () => {
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
- infra_logs: { hasData: true, status: 'success' },
+ infra_logs: { hasData: true, indices: 'test-index', status: 'success' },
infra_metrics: { hasData: true, indices: 'metric-*', status: 'success' },
ux: {
hasData: true,
@@ -373,7 +382,10 @@ describe('HasDataContextProvider', () => {
throw new Error('BOOMMMMM');
},
},
- { appName: 'infra_logs', hasData: async () => true },
+ {
+ appName: 'infra_logs',
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
+ },
{
appName: 'infra_metrics',
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
@@ -413,7 +425,7 @@ describe('HasDataContextProvider', () => {
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
- infra_logs: { hasData: true, status: 'success' },
+ infra_logs: { hasData: true, indices: 'test-index', status: 'success' },
infra_metrics: { hasData: true, indices: 'metric-*', status: 'success' },
ux: {
hasData: true,
diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx
index 0123b137036b1..cbdbe0c679156 100644
--- a/x-pack/plugins/observability/public/context/has_data_context.tsx
+++ b/x-pack/plugins/observability/public/context/has_data_context.tsx
@@ -96,8 +96,11 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
break;
case 'infra_logs':
- const resultInfra = await getDataHandler(app)?.hasData();
- updateState({ hasData: resultInfra });
+ const resultInfraLogs = await getDataHandler(app)?.hasData();
+ updateState({
+ hasData: resultInfraLogs?.hasData,
+ indices: resultInfraLogs?.indices,
+ });
break;
case 'infra_metrics':
const resultInfraMetrics = await getDataHandler(app)?.hasData();
diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts
index e6b194b9fa046..d0cca3b5272e7 100644
--- a/x-pack/plugins/observability/public/data_handler.test.ts
+++ b/x-pack/plugins/observability/public/data_handler.test.ts
@@ -135,7 +135,12 @@ describe('registerDataHandler', () => {
},
};
},
- hasData: async () => true,
+ hasData: async () => {
+ return {
+ hasData: true,
+ indices: 'test-index',
+ };
+ },
});
it('registered data handler', () => {
@@ -176,9 +181,9 @@ describe('registerDataHandler', () => {
});
it('returns true when hasData is called', async () => {
- const dataHandler = getDataHandler('apm');
+ const dataHandler = getDataHandler('infra_logs');
const hasData = await dataHandler?.hasData();
- expect(hasData).toBeTruthy();
+ expect(hasData?.hasData).toBeTruthy();
});
});
describe('Uptime', () => {
diff --git a/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts b/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts
index f7995a938c61b..fc9fa73f234b6 100644
--- a/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts
+++ b/x-pack/plugins/observability/public/hooks/create_use_rules_link.ts
@@ -11,7 +11,7 @@ export function createUseRulesLink(isNewRuleManagementEnabled = false) {
const linkProps = isNewRuleManagementEnabled
? {
app: 'observability',
- pathname: '/rules',
+ pathname: '/alerts/rules',
}
: {
app: 'management',
diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
index cf6ae92d1b9c8..dbe095c311c0f 100644
--- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
+++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
@@ -44,6 +44,7 @@ interface RuleStatsState {
disabled: number;
muted: number;
error: number;
+ snoozed: number;
}
export interface TopAlert {
@@ -90,6 +91,7 @@ function AlertsPage() {
disabled: 0,
muted: 0,
error: 0,
+ snoozed: 0,
});
useEffect(() => {
@@ -111,18 +113,21 @@ function AlertsPage() {
const response = await loadRuleAggregations({
http,
});
- const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response;
- if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) {
+ const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } =
+ response;
+ if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) {
const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0);
const { disabled } = ruleEnabledStatus;
const { muted } = ruleMutedStatus;
const { error } = ruleExecutionStatus;
+ const { snoozed } = ruleSnoozedStatus;
setRuleStats({
...ruleStats,
total,
disabled,
muted,
error,
+ snoozed,
});
}
setRuleStatsLoading(false);
@@ -142,7 +147,7 @@ function AlertsPage() {
}, []);
const manageRulesHref = config.unsafe.rules.enabled
- ? http.basePath.prepend('/app/observability/rules')
+ ? http.basePath.prepend('/app/observability/alerts/rules')
: http.basePath.prepend('/app/management/insightsAndAlerting/triggersActions/rules');
const dynamicIndexPatternsAsyncState = useAsync(async (): Promise => {
@@ -263,9 +268,9 @@ function AlertsPage() {
data-test-subj="statDisabled"
/>,
;
@@ -145,6 +146,7 @@ export function OverviewPage({ routeParams }: Props) {
{hasData && (
<>
+ setIsFlyoutVisible(true)} />
false,
+ hasData: async () => ({ hasData: false, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -244,7 +244,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
return (
@@ -259,7 +259,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -281,7 +281,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -305,7 +305,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -337,7 +337,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -369,7 +369,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -403,7 +403,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -434,7 +434,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'infra_logs',
fetchData: async () => emptyLogsResponse,
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
@@ -470,7 +470,7 @@ storiesOf('app/Overview', module)
fetchData: async () => {
throw new Error('Error fetching Logs data');
},
- hasData: async () => true,
+ hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx
index 21664ca63507d..a0b95441f4857 100644
--- a/x-pack/plugins/observability/public/pages/rules/index.tsx
+++ b/x-pack/plugins/observability/public/pages/rules/index.tsx
@@ -19,6 +19,7 @@ import {
EuiFieldSearch,
OnRefreshChangeProps,
} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
@@ -140,6 +141,12 @@ export function RulesPage() {
}, [refreshInterval, reload, isPaused]);
useBreadcrumbs([
+ {
+ text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', {
+ defaultMessage: 'Alerts',
+ }),
+ href: http.basePath.prepend('/app/observability/alerts'),
+ },
{
text: RULES_BREADCRUMB_TEXT,
},
diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts
index 9d483b63ac0a9..ed591d45a9820 100644
--- a/x-pack/plugins/observability/public/plugin.ts
+++ b/x-pack/plugins/observability/public/plugin.ts
@@ -93,21 +93,22 @@ export class Plugin
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.hidden,
- },
- {
- id: 'rules',
- title: i18n.translate('xpack.observability.rulesLinkTitle', {
- defaultMessage: 'Rules',
- }),
- order: 8002,
- path: '/rules',
- navLinkStatus: AppNavLinkStatus.hidden,
+ deepLinks: [
+ {
+ id: 'rules',
+ title: i18n.translate('xpack.observability.rulesLinkTitle', {
+ defaultMessage: 'Rules',
+ }),
+ path: '/alerts/rules',
+ navLinkStatus: AppNavLinkStatus.hidden,
+ },
+ ],
},
getCasesDeepLinks({
basePath: casesPath,
extend: {
[CasesDeepLinkId.cases]: {
- order: 8003,
+ order: 8002,
navLinkStatus: AppNavLinkStatus.hidden,
},
[CasesDeepLinkId.casesCreate]: {
diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx
index d895f55152ef8..528dbfee06f9d 100644
--- a/x-pack/plugins/observability/public/routes/index.tsx
+++ b/x-pack/plugins/observability/public/routes/index.tsx
@@ -88,7 +88,7 @@ export const routes = {
},
exact: true,
},
- '/rules': {
+ '/alerts/rules': {
handler: () => {
return ;
},
diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
index 97302a0ada7d0..99e86632968cf 100644
--- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
+++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
@@ -60,6 +60,11 @@ export interface InfraMetricsHasDataResponse {
indices: string;
}
+export interface InfraLogsHasDataResponse {
+ hasData: boolean;
+ indices: string;
+}
+
export type FetchData = (
fetchDataParams: FetchDataParams
) => Promise;
@@ -155,7 +160,7 @@ export interface ObservabilityFetchDataResponse {
export interface ObservabilityHasDataResponse {
apm: APMHasDataResponse;
infra_metrics: InfraMetricsHasDataResponse;
- infra_logs: boolean;
+ infra_logs: InfraLogsHasDataResponse;
synthetics: SyntheticsHasDataResponse;
ux: UXHasDataResponse;
}
diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
index 86ce6cd587213..4d4a4fc2cc353 100644
--- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
+++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
@@ -49,7 +49,7 @@ const appToPatternMap: Record = {
synthetics: '(synthetics-data-view)*',
apm: 'apm-*',
ux: '(rum-data-view)*',
- infra_logs: '',
+ infra_logs: '(infra-logs-data-view)*',
infra_metrics: '(infra-metrics-data-view)*',
mobile: '(mobile-data-view)*',
};
diff --git a/x-pack/plugins/osquery/common/types.ts b/x-pack/plugins/osquery/common/types.ts
index f543057d773fe..2d3fc6e972c7c 100644
--- a/x-pack/plugins/osquery/common/types.ts
+++ b/x-pack/plugins/osquery/common/types.ts
@@ -9,6 +9,7 @@ import { PackagePolicy, PackagePolicyInput, PackagePolicyInputStream } from '../
export const savedQuerySavedObjectType = 'osquery-saved-query';
export const packSavedObjectType = 'osquery-pack';
+export const packAssetSavedObjectType = 'osquery-pack-asset';
export const usageMetricSavedObjectType = 'osquery-manager-usage-metric';
export type SavedObjectType =
| 'osquery-saved-query'
@@ -68,4 +69,5 @@ export interface OsqueryManagerPackagePolicyInput extends Omit {
inputs: OsqueryManagerPackagePolicyInput[];
+ read_only?: boolean;
}
diff --git a/x-pack/plugins/osquery/public/assets/constants.ts b/x-pack/plugins/osquery/public/assets/constants.ts
new file mode 100644
index 0000000000000..00b9067d83089
--- /dev/null
+++ b/x-pack/plugins/osquery/public/assets/constants.ts
@@ -0,0 +1,8 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export const INTEGRATION_ASSETS_STATUS_ID = 'integrationAssetsStatus';
diff --git a/x-pack/plugins/osquery/public/assets/use_assets_status.ts b/x-pack/plugins/osquery/public/assets/use_assets_status.ts
new file mode 100644
index 0000000000000..41307952b222c
--- /dev/null
+++ b/x-pack/plugins/osquery/public/assets/use_assets_status.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { SavedObject } from 'kibana/public';
+import { useQuery } from 'react-query';
+import { useKibana } from '../common/lib/kibana';
+import { INTEGRATION_ASSETS_STATUS_ID } from './constants';
+
+export const useAssetsStatus = () => {
+ const { http } = useKibana().services;
+
+ return useQuery<{ install: SavedObject[]; update: SavedObject[]; upToDate: SavedObject[] }>(
+ [INTEGRATION_ASSETS_STATUS_ID],
+ () => http.get('/internal/osquery/assets'),
+ {
+ keepPreviousData: true,
+ }
+ );
+};
diff --git a/x-pack/plugins/osquery/public/assets/use_import_assets.ts b/x-pack/plugins/osquery/public/assets/use_import_assets.ts
new file mode 100644
index 0000000000000..f63f3e7096f03
--- /dev/null
+++ b/x-pack/plugins/osquery/public/assets/use_import_assets.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMutation, useQueryClient } from 'react-query';
+import { useKibana } from '../common/lib/kibana';
+import { useErrorToast } from '../common/hooks/use_error_toast';
+import { PACKS_ID } from '../packs/constants';
+import { INTEGRATION_ASSETS_STATUS_ID } from './constants';
+
+interface UseImportAssetsProps {
+ successToastText: string;
+}
+
+export const useImportAssets = ({ successToastText }: UseImportAssetsProps) => {
+ const queryClient = useQueryClient();
+ const {
+ http,
+ notifications: { toasts },
+ } = useKibana().services;
+ const setErrorToast = useErrorToast();
+
+ return useMutation(
+ () =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ http.post('/internal/osquery/assets/update'),
+ {
+ onSuccess: () => {
+ setErrorToast();
+ queryClient.invalidateQueries(PACKS_ID);
+ queryClient.invalidateQueries(INTEGRATION_ASSETS_STATUS_ID);
+ toasts.addSuccess(successToastText);
+ },
+ onError: (error) => {
+ setErrorToast(error);
+ },
+ }
+ );
+};
diff --git a/x-pack/plugins/osquery/public/packs/active_state_switch.tsx b/x-pack/plugins/osquery/public/packs/active_state_switch.tsx
index da1581f5f7bfe..1dbb145430a3e 100644
--- a/x-pack/plugins/osquery/public/packs/active_state_switch.tsx
+++ b/x-pack/plugins/osquery/public/packs/active_state_switch.tsx
@@ -17,6 +17,7 @@ import { useAgentPolicies } from '../agent_policies/use_agent_policies';
import { ConfirmDeployAgentPolicyModal } from './form/confirmation_modal';
import { useErrorToast } from '../common/hooks/use_error_toast';
import { useUpdatePack } from './use_update_pack';
+import { PACKS_ID } from './constants';
const StyledEuiLoadingSpinner = styled(EuiLoadingSpinner)`
margin-right: ${({ theme }) => theme.eui.paddingSizes.s};
@@ -55,7 +56,7 @@ const ActiveStateSwitchComponent: React.FC = ({ item })
options: {
// @ts-expect-error update types
onSuccess: (response) => {
- queryClient.invalidateQueries('packList');
+ queryClient.invalidateQueries(PACKS_ID);
setErrorToast();
toasts.addSuccess(
response.attributes.enabled
diff --git a/x-pack/plugins/osquery/public/packs/add_pack_button.tsx b/x-pack/plugins/osquery/public/packs/add_pack_button.tsx
new file mode 100644
index 0000000000000..1473cee6e7aa2
--- /dev/null
+++ b/x-pack/plugins/osquery/public/packs/add_pack_button.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { EuiButton, EuiButtonProps } from '@elastic/eui';
+import { useKibana, useRouterNavigate } from '../common/lib/kibana';
+
+interface AddPackButtonComponentProps {
+ fill?: EuiButtonProps['fill'];
+}
+
+const AddPackButtonComponent: React.FC = ({ fill = true }) => {
+ const permissions = useKibana().services.application.capabilities.osquery;
+ const newQueryLinkProps = useRouterNavigate('packs/add');
+
+ return (
+
+
+
+ );
+};
+
+export const AddPackButton = React.memo(AddPackButtonComponent);
diff --git a/x-pack/plugins/osquery/public/packs/form/index.tsx b/x-pack/plugins/osquery/public/packs/form/index.tsx
index b68336e6705be..f327239560345 100644
--- a/x-pack/plugins/osquery/public/packs/form/index.tsx
+++ b/x-pack/plugins/osquery/public/packs/form/index.tsx
@@ -51,6 +51,7 @@ interface PackFormProps {
}
const PackFormComponent: React.FC = ({ defaultValue, editMode = false }) => {
+ const isReadOnly = !!defaultValue?.read_only;
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const handleHideConfirmationModal = useCallback(() => setShowConfirmationModal(false), []);
@@ -183,18 +184,20 @@ const PackFormComponent: React.FC = ({ defaultValue, editMode = f
setShowConfirmationModal(false);
}, [submit]);
+ const euiFieldProps = useMemo(() => ({ isDisabled: isReadOnly }), [isReadOnly]);
+
return (
<>
diff --git a/x-pack/plugins/searchprofiler/public/application/contexts/app_context.tsx b/x-pack/plugins/searchprofiler/public/application/contexts/app_context.tsx
index 6ae8a20eea3ec..90756b93cf1d7 100644
--- a/x-pack/plugins/searchprofiler/public/application/contexts/app_context.tsx
+++ b/x-pack/plugins/searchprofiler/public/application/contexts/app_context.tsx
@@ -6,6 +6,7 @@
*/
import React, { useContext, createContext, useCallback } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
import { HttpSetup, ToastsSetup } from 'kibana/public';
import { LicenseStatus } from '../../../common';
@@ -14,19 +15,21 @@ export interface ContextArgs {
http: HttpSetup;
notifications: ToastsSetup;
initialLicenseStatus: LicenseStatus;
+ location: RouteComponentProps['location'];
}
export interface ContextValue {
http: HttpSetup;
notifications: ToastsSetup;
getLicenseStatus: () => LicenseStatus;
+ location: RouteComponentProps['location'];
}
const AppContext = createContext(null as any);
export const AppContextProvider = ({
children,
- args: { http, notifications, initialLicenseStatus },
+ args: { http, notifications, initialLicenseStatus, location },
}: {
children: React.ReactNode;
args: ContextArgs;
@@ -39,6 +42,7 @@ export const AppContextProvider = ({
http,
notifications,
getLicenseStatus,
+ location,
}}
>
{children}
diff --git a/x-pack/plugins/searchprofiler/public/application/index.tsx b/x-pack/plugins/searchprofiler/public/application/index.tsx
index 6c1f88c45c1c0..419455c119fc2 100644
--- a/x-pack/plugins/searchprofiler/public/application/index.tsx
+++ b/x-pack/plugins/searchprofiler/public/application/index.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Observable } from 'rxjs';
import { HttpStart as Http, ToastsSetup, CoreTheme } from 'kibana/public';
+import { RouteComponentProps } from 'react-router-dom';
import { LicenseStatus } from '../../common';
import { KibanaThemeProvider } from '../shared_imports';
@@ -23,6 +24,7 @@ interface AppDependencies {
notifications: ToastsSetup;
initialLicenseStatus: LicenseStatus;
theme$: Observable;
+ location: RouteComponentProps['location'];
}
export const renderApp = ({
@@ -32,11 +34,12 @@ export const renderApp = ({
notifications,
initialLicenseStatus,
theme$,
+ location,
}: AppDependencies) => {
render(
-
+
diff --git a/x-pack/plugins/searchprofiler/public/locator.ts b/x-pack/plugins/searchprofiler/public/locator.ts
new file mode 100644
index 0000000000000..40d79d0e5fc27
--- /dev/null
+++ b/x-pack/plugins/searchprofiler/public/locator.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { SerializableRecord } from '@kbn/utility-types';
+import { LocatorDefinition } from '../../../../src/plugins/share/public/';
+
+export const SEARCH_PROFILER_LOCATOR_ID = 'SEARCH_PROFILER_LOCATOR';
+
+export interface SearchProfilerLocatorParams extends SerializableRecord {
+ loadFrom: string;
+ index: string;
+}
+
+export class SearchProfilerLocatorDefinition
+ implements LocatorDefinition
+{
+ public readonly id = SEARCH_PROFILER_LOCATOR_ID;
+
+ public readonly getLocation = async ({ loadFrom, index }: SearchProfilerLocatorParams) => {
+ const indexQueryParam = index ? `?index=${index}` : '';
+ const loadFromQueryParam = index && loadFrom ? `&load_from=${loadFrom}` : '';
+
+ return {
+ app: 'dev_tools',
+ path: `#/searchprofiler${indexQueryParam}${loadFromQueryParam}`,
+ state: { loadFrom, index },
+ };
+ };
+}
diff --git a/x-pack/plugins/searchprofiler/public/plugin.ts b/x-pack/plugins/searchprofiler/public/plugin.ts
index c903712577b5d..6b0b0bd831c13 100644
--- a/x-pack/plugins/searchprofiler/public/plugin.ts
+++ b/x-pack/plugins/searchprofiler/public/plugin.ts
@@ -14,6 +14,7 @@ import { ILicense } from '../../licensing/common/types';
import { PLUGIN } from '../common';
import { AppPublicPluginDependencies } from './types';
+import { SearchProfilerLocatorDefinition } from './locator';
const checkLicenseStatus = (license: ILicense) => {
const { state, message } = license.check(PLUGIN.id, PLUGIN.minimumLicenseType);
@@ -23,7 +24,7 @@ const checkLicenseStatus = (license: ILicense) => {
export class SearchProfilerUIPlugin implements Plugin {
public setup(
{ http, getStartServices }: CoreSetup,
- { devTools, home, licensing }: AppPublicPluginDependencies
+ { devTools, home, licensing, share }: AppPublicPluginDependencies
) {
home.featureCatalogue.register({
id: PLUGIN.id,
@@ -61,6 +62,7 @@ export class SearchProfilerUIPlugin implements Plugin {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
+ "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
]
`);
});
@@ -321,6 +322,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
+ "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/get",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/find",
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/update",
@@ -376,6 +378,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
+ "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary",
@@ -478,6 +481,7 @@ describe(`feature_privilege_builder`, () => {
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
+ "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary",
diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts
index 13aa45d54f66e..800e8297d7fb1 100644
--- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts
+++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts
@@ -33,6 +33,7 @@ const writeOperations: Record = {
'muteAlert',
'unmuteAlert',
'snooze',
+ 'unsnooze',
],
alert: ['update'],
};
diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts
index cc64b7e640f1f..91545e25057d7 100644
--- a/x-pack/plugins/security_solution/common/constants.ts
+++ b/x-pack/plugins/security_solution/common/constants.ts
@@ -432,5 +432,11 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrenc
export const RULES_TABLE_MAX_PAGE_SIZE = 100;
export const RULES_TABLE_PAGE_SIZE_OPTIONS = [5, 10, 20, 50, RULES_TABLE_MAX_PAGE_SIZE];
+/**
+ * A local storage key we use to store the state of the feature tour UI for the Rule Management page.
+ *
+ * NOTE: As soon as we want to show a new tour for features in the current Kibana version,
+ * we will need to update this constant with the corresponding version.
+ */
export const RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY =
'securitySolution.rulesManagementPage.newFeaturesTour.v8.1';
diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
index e6f2669c95c34..737d81cc9d1ed 100644
--- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
@@ -256,19 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator {
* It prevents tour to appear during tests and cover UI elements
* @param window - browser's window object
*/
-const disableRulesFeatureTour = (window: Window) => {
+const disableFeatureTourForRuleManagementPage = (window: Window) => {
const tourConfig = {
isTourActive: false,
};
@@ -317,7 +317,7 @@ export const loginAndWaitForPage = (
if (onBeforeLoadCallback) {
onBeforeLoadCallback(win);
}
- disableRulesFeatureTour(win);
+ disableFeatureTourForRuleManagementPage(win);
},
}
);
@@ -333,7 +333,7 @@ export const waitForPage = (url: string) => {
export const loginAndWaitForPageWithoutDateRange = (url: string, role?: ROLES) => {
login(role);
cy.visit(role ? getUrlWithRoute(role, url) : url, {
- onBeforeLoad: disableRulesFeatureTour,
+ onBeforeLoad: disableFeatureTourForRuleManagementPage,
});
cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 });
};
@@ -341,7 +341,7 @@ export const loginAndWaitForPageWithoutDateRange = (url: string, role?: ROLES) =
export const loginWithUserAndWaitForPageWithoutDateRange = (url: string, user: User) => {
loginWithUser(user);
cy.visit(constructUrlWithUser(user, url), {
- onBeforeLoad: disableRulesFeatureTour,
+ onBeforeLoad: disableFeatureTourForRuleManagementPage,
});
cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 });
};
@@ -351,7 +351,7 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => {
login(role);
cy.visit(role ? getUrlWithRoute(role, route) : route, {
- onBeforeLoad: disableRulesFeatureTour,
+ onBeforeLoad: disableFeatureTourForRuleManagementPage,
});
cy.get('[data-test-subj="headerGlobalNav"]');
cy.get(TIMELINE_FLYOUT_BODY).should('be.visible');
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts
index a6e1a9875bd62..4183f12eec72a 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts
@@ -477,6 +477,12 @@ export const mockAlertDetailsData = [
values: ['2020-11-25T15:36:40.924914552Z'],
originalValue: '2020-11-25T15:36:40.924914552Z',
},
+ {
+ category: 'kibana',
+ field: 'kibana.alert.original_event.id',
+ values: ['f7bc2422-cb1e-4427-ba33-6f496ee8360c'],
+ originalValue: 'f7bc2422-cb1e-4427-ba33-6f496ee8360c',
+ },
{
category: 'kibana',
field: 'kibana.alert.original_event.code',
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
index 53c0d143600fb..650b915f50214 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
@@ -68,7 +68,7 @@ describe('AlertSummaryView', () => {
);
- ['host.name', 'user.name', 'Rule type', 'query'].forEach((fieldId) => {
+ ['host.name', 'user.name', 'Rule type', 'query', 'Source event id'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx
index 0527acfef1f9a..8d2de0439967c 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx
@@ -37,6 +37,7 @@ const alwaysDisplayedFields: EventSummaryField[] = [
{ id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{ id: 'user.name' },
{ id: ALERT_RULE_TYPE, label: i18n.RULE_TYPE },
+ { id: 'kibana.alert.original_event.id', label: i18n.SOURCE_EVENT_ID },
];
/**
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx
index 451ffd64584a7..78cb55166555d 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx
@@ -47,7 +47,7 @@ const summaryColumns: Array> = [
{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP}}
/>
>
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx
index 83b4c63484dd3..6a0e6bf4e6b9d 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx
@@ -15,6 +15,7 @@ import { EventFieldsData } from '../types';
import { TimelineId } from '../../../../../common/types';
import { AlertSummaryRow } from '../helpers';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
+import { getEmptyValue } from '../../../components/empty_value';
jest.mock('../../../lib/kibana');
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
@@ -75,10 +76,10 @@ describe('PrevalenceCellRenderer', () => {
});
describe('When an error was returned', () => {
- test('it should return null', async () => {
+ test('it should return empty value placeholder', async () => {
mockUseAlertPrevalence.mockImplementation(() => ({
loading: false,
- count: 123,
+ count: undefined,
error: true,
}));
const { container } = render(
@@ -88,6 +89,7 @@ describe('PrevalenceCellRenderer', () => {
);
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(0);
expect(screen.queryByText('123')).toBeNull();
+ expect(screen.queryByText(getEmptyValue())).toBeTruthy();
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.tsx
index ed8b610b39d1f..46de86d4bff1b 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.tsx
@@ -9,11 +9,12 @@ import React from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { AlertSummaryRow } from '../helpers';
+import { defaultToEmptyTag } from '../../../components/empty_value';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
const PrevalenceCell = React.memo(
({ data, values, timelineId }) => {
- const { loading, count, error } = useAlertPrevalence({
+ const { loading, count } = useAlertPrevalence({
field: data.field,
timelineId,
value: values,
@@ -22,11 +23,9 @@ const PrevalenceCell = React.memo(
if (loading) {
return ;
- } else if (error) {
- return null;
+ } else {
+ return defaultToEmptyTag(count);
}
-
- return <>{count}>;
}
);
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts
index 52f73e9de481a..6e32beb7da02a 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts
@@ -46,7 +46,7 @@ export const HIGHLIGHTED_FIELDS_VALUE = i18n.translate(
export const HIGHLIGHTED_FIELDS_ALERT_PREVALENCE = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields.alertPrevalence',
{
- defaultMessage: 'Alert Prevalence',
+ defaultMessage: 'Alert prevalence',
}
);
@@ -117,6 +117,13 @@ export const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alert
defaultMessage: 'Rule type',
});
+export const SOURCE_EVENT_ID = i18n.translate(
+ 'xpack.securitySolution.detections.alerts.sourceEventId',
+ {
+ defaultMessage: 'Source event id',
+ }
+);
+
export const MULTI_FIELD_TOOLTIP = i18n.translate(
'xpack.securitySolution.eventDetails.multiFieldTooltipContent',
{
diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx
index 58a84d8dc8548..60d895e417ce7 100644
--- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx
@@ -57,8 +57,8 @@ const Popover = React.memo(
iconSide={iconSide}
iconSize={iconSize}
iconType={iconType}
- onClick={handleLinkIconClick}
disabled={disabled}
+ onClick={handleLinkIconClick}
>
{children}
@@ -119,7 +119,6 @@ export const UtilityBarAction = React.memo(
{popoverContent ? (
(
ownFocus={ownFocus}
popoverPanelPaddingSize={popoverPanelPaddingSize}
popoverContent={popoverContent}
+ onClick={onClick}
>
{children}
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/README.md b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/README.md
new file mode 100644
index 0000000000000..282ee8c46cd9e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/README.md
@@ -0,0 +1,56 @@
+# Feature Tour on the Rule Management Page
+
+This folder contains an implementation of a feature tour UI for new features introduced in `8.1.0`.
+This implementaion is currently unused - all usages have been removed from React components.
+We might revisit this implementation in the next releases when we have something new for the user
+to demonstrate on the Rule Management Page.
+
+## A new way of building tours
+
+The EUI Tour has evolved and continues to do so.
+
+EUI folks have implemented a new programming model for defining tour steps and binding them to
+UI elements on a page ([ticket][1], [PR][2]). When we revisit the Tour UI, we should build it
+differently - using the new `anchor` property and consolidating all the tour steps and logic
+in a single component. We shouldn't need to wrap the page with the provider anymore. And there's
+[a chance][3] that this implementation based on query selectors will have fewer UI glitches.
+
+New features and fixes to track:
+
+- Support for previous, next and go to step [#4831][4]
+- Built-in 'Next' button [#5715][5]
+- Popover on the EuiTour component doesn't respect the anchorPosition prop [#5731][6]
+
+## How to revive this tour for the next release (if needed)
+
+1. Update Kibana version in `RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY`.
+ Set it to a version you're going to implement a feature tour for.
+
+1. Define steps for your tour. See `RulesFeatureTourContextProvider` and `stepsConfig`.
+
+1. Rewrite the implementation using the new `anchor` property and targeting UI elements
+ from steps using query selectors. Consolidate all the steps and their ``
+ components in a single `RuleManagementPageFeatureTour` component. Render this component
+ in the Rule Management page. Get rid of `RulesFeatureTourContextProvider` - we shouldn't
+ need to wrap the page and pass anything down the tree anymore.
+
+1. Consider abstracting away persistence in Local Storage and other functionality that
+ may be common to tours on different pages.
+
+## Useful links
+
+Docs: [`EuiTour`](https://elastic.github.io/eui/#/display/tour).
+
+For reference, PRs where this Tour has been introduced or changed:
+
+- added in `8.1.0` ([PR](https://github.com/elastic/kibana/pull/124343))
+- removed in `8.2.0` ([PR](https://github.com/elastic/kibana/pull/128398))
+
+
+
+[1]: https://github.com/elastic/kibana/issues/124052
+[2]: https://github.com/elastic/eui/pull/5696
+[3]: https://github.com/elastic/eui/issues/5731#issuecomment-1075202910
+[4]: https://github.com/elastic/eui/issues/4831
+[5]: https://github.com/elastic/eui/issues/5715
+[6]: https://github.com/elastic/eui/issues/5731
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/rules_feature_tour_context.tsx
similarity index 52%
rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx
rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/rules_feature_tour_context.tsx
index 6c1d5a0de7a54..aaa483e49fca7 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/rules_feature_tour_context.tsx
@@ -6,38 +6,45 @@
*/
import React, { createContext, useContext, useEffect, useMemo, FC } from 'react';
-
-import { noop } from 'lodash';
import {
- useEuiTour,
+ EuiButtonIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
EuiTourState,
EuiStatelessTourStep,
- EuiSpacer,
- EuiButton,
EuiTourStepProps,
+ EuiTourActions,
+ useEuiTour,
} from '@elastic/eui';
-import { invariant } from '../../../../../../common/utils/invariant';
-import { RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY } from '../../../../../../common/constants';
-import { useKibana } from '../../../../../common/lib/kibana';
-import * as i18n from '../translations';
+import { noop } from 'lodash';
+import { invariant } from '../../../../../../../common/utils/invariant';
+import { useKibana } from '../../../../../../common/lib/kibana';
+import { RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY } from '../../../../../../../common/constants';
+
+import * as i18n from './translations';
export interface RulesFeatureTourContextType {
- steps: {
- inMemoryTableStepProps: EuiTourStepProps;
- bulkActionsStepProps: EuiTourStepProps;
- };
- goToNextStep: () => void;
- finishTour: () => void;
+ steps: EuiTourStepProps[];
+ actions: EuiTourActions;
}
const TOUR_POPOVER_WIDTH = 360;
-const featuresTourSteps: EuiStatelessTourStep[] = [
+const tourConfig: EuiTourState = {
+ currentTourStep: 1,
+ isTourActive: true,
+ tourPopoverWidth: TOUR_POPOVER_WIDTH,
+ tourSubtitle: i18n.TOUR_TITLE,
+};
+
+// This is an example. Replace with the steps for your particular version. Don't forget to use i18n.
+const stepsConfig: EuiStatelessTourStep[] = [
{
step: 1,
- title: i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP_TITLE,
- content: <>>,
+ title: 'A new feature',
+ content: {'This feature allows for...'}
,
stepsTotal: 2,
children: <>>,
onFinish: noop,
@@ -45,8 +52,8 @@ const featuresTourSteps: EuiStatelessTourStep[] = [
},
{
step: 2,
- title: i18n.FEATURE_TOUR_BULK_ACTIONS_STEP_TITLE,
- content: {i18n.FEATURE_TOUR_BULK_ACTIONS_STEP}
,
+ title: 'Another feature',
+ content: {'This another feature allows for...'}
,
stepsTotal: 2,
children: <>>,
onFinish: noop,
@@ -55,13 +62,6 @@ const featuresTourSteps: EuiStatelessTourStep[] = [
},
];
-const tourConfig: EuiTourState = {
- currentTourStep: 1,
- isTourActive: true,
- tourPopoverWidth: TOUR_POPOVER_WIDTH,
- tourSubtitle: i18n.FEATURE_TOUR_TITLE,
-};
-
const RulesFeatureTourContext = createContext(null);
/**
@@ -71,7 +71,8 @@ const RulesFeatureTourContext = createContext {
const { storage } = useKibana().services;
- const initialStore = useMemo(
+
+ const restoredState = useMemo(
() => ({
...tourConfig,
...(storage.get(RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY) ?? tourConfig),
@@ -79,43 +80,51 @@ export const RulesFeatureTourContextProvider: FC = ({ children }) => {
[storage]
);
- const [stepProps, actions, reducerState] = useEuiTour(featuresTourSteps, initialStore);
-
- const finishTour = actions.finishTour;
- const goToNextStep = actions.incrementStep;
-
- const inMemoryTableStepProps = useMemo(
- () => ({
- ...stepProps[0],
- content: (
- <>
- {i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP}
-
-
- {i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP_NEXT}
-
- >
- ),
- }),
- [stepProps, goToNextStep]
+ const [tourSteps, tourActions, tourState] = useEuiTour(stepsConfig, restoredState);
+
+ const enhancedSteps = useMemo(() => {
+ return tourSteps.map((item, index, array) => {
+ return {
+ ...item,
+ content: (
+ <>
+ {item.content}
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ };
+ });
+ }, [tourSteps, tourActions]);
+
+ const providerValue = useMemo(
+ () => ({ steps: enhancedSteps, actions: tourActions }),
+ [enhancedSteps, tourActions]
);
useEffect(() => {
- const { isTourActive, currentTourStep } = reducerState;
+ const { isTourActive, currentTourStep } = tourState;
storage.set(RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY, { isTourActive, currentTourStep });
- }, [reducerState, storage]);
-
- const providerValue = useMemo(
- () => ({
- steps: {
- inMemoryTableStepProps,
- bulkActionsStepProps: stepProps[1],
- },
- finishTour,
- goToNextStep,
- }),
- [finishTour, goToNextStep, inMemoryTableStepProps, stepProps]
- );
+ }, [tourState, storage]);
return (
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/translations.ts
new file mode 100644
index 0000000000000..bfcda64bb13dd
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/feature_tour/translations.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const TOUR_TITLE = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.tourTitle',
+ {
+ defaultMessage: "What's new",
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx
index 6092ec2a134d1..3b24dda539174 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx
@@ -13,7 +13,6 @@ import { TestProviders } from '../../../../../common/mock';
import '../../../../../common/mock/formatted_relative';
import '../../../../../common/mock/match_media';
import { AllRules } from './index';
-import { RulesFeatureTourContextProvider } from './rules_feature_tour_context';
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../../common/lib/kibana');
@@ -68,8 +67,7 @@ describe('AllRules', () => {
rulesNotInstalled={0}
rulesNotUpdated={0}
/>
- ,
- { wrappingComponent: RulesFeatureTourContextProvider }
+
);
await waitFor(() => {
@@ -92,8 +90,7 @@ describe('AllRules', () => {
rulesNotInstalled={0}
rulesNotUpdated={0}
/>
- ,
- { wrappingComponent: RulesFeatureTourContextProvider }
+
);
await waitFor(() => {
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx
index 6bb9927c8ab82..e8c7742125c74 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx
@@ -45,7 +45,7 @@ export const AllRules = React.memo(
return (
<>
-
+
= ({
- children,
- stepProps,
-}) => {
- if (!stepProps) {
- return <>{children}>;
- }
-
- return (
-
- <>{children}>
-
- );
-};
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx
index 966cb726c8711..261e14fd1411b 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx
@@ -10,8 +10,6 @@ import React from 'react';
import styled from 'styled-components';
import { useRulesTableContext } from './rules_table/rules_table_context';
import * as i18n from '../translations';
-import { useRulesFeatureTourContext } from './rules_feature_tour_context';
-import { OptionalEuiTourStep } from './optional_eui_tour_step';
const ToolbarLayout = styled.div`
display: grid;
@@ -24,7 +22,6 @@ const ToolbarLayout = styled.div`
interface RulesTableToolbarProps {
activeTab: AllRulesTabs;
onTabChange: (tab: AllRulesTabs) => void;
- loading: boolean;
}
export enum AllRulesTabs {
@@ -46,17 +43,12 @@ const allRulesTabs = [
];
export const RulesTableToolbar = React.memo(
- ({ onTabChange, activeTab, loading }) => {
+ ({ onTabChange, activeTab }) => {
const {
state: { isInMemorySorting },
actions: { setIsInMemorySorting },
} = useRulesTableContext();
- const {
- steps: { inMemoryTableStepProps },
- goToNextStep,
- } = useRulesFeatureTourContext();
-
return (
@@ -72,22 +64,13 @@ export const RulesTableToolbar = React.memo(
))}
- {/* delaying render of tour due to EuiPopover can't react to layout changes
- https://github.com/elastic/kibana/pull/124343#issuecomment-1032467614 */}
-
-
- {
- if (inMemoryTableStepProps.isStepOpen) {
- goToNextStep();
- }
- setIsInMemorySorting(e.target.checked);
- }}
- />
-
-
+
+ setIsInMemorySorting(e.target.checked)}
+ />
+
);
}
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx
index a936e84cee00a..6d9c2f92b214e 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx
@@ -22,9 +22,6 @@ import {
UtilityBarText,
} from '../../../../../common/components/utility_bar';
import * as i18n from '../translations';
-import { useRulesFeatureTourContextOptional } from './rules_feature_tour_context';
-
-import { OptionalEuiTourStep } from './optional_eui_tour_step';
interface AllRulesUtilityBarProps {
canBulkEdit: boolean;
@@ -58,9 +55,6 @@ export const AllRulesUtilityBar = React.memo(
isBulkActionInProgress,
hasDisabledActions,
}) => {
- // use optional rulesFeatureTourContext as AllRulesUtilityBar can be used outside the context
- const featureTour = useRulesFeatureTourContextOptional();
-
const handleGetBulkItemsPopoverContent = useCallback(
(closePopover: () => void): JSX.Element | null => {
if (onGetBulkItemsPopoverContent != null) {
@@ -140,24 +134,17 @@ export const AllRulesUtilityBar = React.memo(
)}
{canBulkEdit && (
-
- {
- if (featureTour?.steps?.bulkActionsStepProps?.isStepOpen) {
- featureTour?.finishTour();
- }
- }}
- >
- {i18n.BATCH_ACTIONS}
-
-
+
+ {i18n.BATCH_ACTIONS}
+
)}
{
showExceptionsCheckBox
showCheckBox
/>
-
-
-
-
-
- {loadPrebuiltRulesAndTemplatesButton && (
- {loadPrebuiltRulesAndTemplatesButton}
- )}
- {reloadPrebuiltRulesAndTemplatesButton && (
- {reloadPrebuiltRulesAndTemplatesButton}
- )}
-
-
-
- {i18n.UPLOAD_VALUE_LISTS}
-
-
-
-
+
+
+
+
+
+ {loadPrebuiltRulesAndTemplatesButton && (
+ {loadPrebuiltRulesAndTemplatesButton}
+ )}
+ {reloadPrebuiltRulesAndTemplatesButton && (
+ {reloadPrebuiltRulesAndTemplatesButton}
+ )}
+
+
- {i18n.IMPORT_RULE}
+ {i18n.UPLOAD_VALUE_LISTS}
-
-
-
- {i18n.ADD_NEW_RULE}
-
-
-
-
- {(prePackagedRuleStatus === 'ruleNeedUpdate' ||
- prePackagedTimelineStatus === 'timelineNeedUpdate') && (
-
- )}
-
+
+
+
+ {i18n.IMPORT_RULE}
+
+
+
+
+ {i18n.ADD_NEW_RULE}
+
+
+
+
+ {(prePackagedRuleStatus === 'ruleNeedUpdate' ||
+ prePackagedTimelineStatus === 'timelineNeedUpdate') && (
+
-
-
-
+ )}
+
+
+
+
>
);
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
index 3a9f233d9bffb..f2f3ef2828e9b 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
@@ -88,50 +88,6 @@ export const EDIT_PAGE_TITLE = i18n.translate(
}
);
-export const FEATURE_TOUR_TITLE = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.tourTitle',
- {
- defaultMessage: "What's new",
- }
-);
-
-export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepDescription',
- {
- defaultMessage:
- 'The experimental rules table view allows for advanced sorting and filtering capabilities.',
- }
-);
-
-export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP_TITLE = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepTitle',
- {
- defaultMessage: 'Step 1',
- }
-);
-
-export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP_NEXT = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepNextButtonTitle',
- {
- defaultMessage: 'Ok, got it',
- }
-);
-
-export const FEATURE_TOUR_BULK_ACTIONS_STEP_TITLE = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.bulkActionsStepTitle',
- {
- defaultMessage: 'Step 2',
- }
-);
-
-export const FEATURE_TOUR_BULK_ACTIONS_STEP = i18n.translate(
- 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.bulkActionsStepDescription',
- {
- defaultMessage:
- 'You can now bulk update index patterns and tags for multiple custom rules at once.',
- }
-);
-
export const REFRESH = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle',
{
diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx
index ab893b57f16e8..4b126d6e747db 100644
--- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx
@@ -257,10 +257,10 @@ export const ArtifactFlyout = memo(
}
// `undefined` will cause params to be dropped from url
- setUrlParams({ itemId: undefined, show: undefined }, true);
+ setUrlParams({ ...urlParams, itemId: undefined, show: undefined }, true);
onClose();
- }, [isSubmittingData, onClose, setUrlParams]);
+ }, [isSubmittingData, onClose, setUrlParams, urlParams]);
const handleFormComponentOnChange: ArtifactFormComponentProps['onChange'] = useCallback(
({ item: updatedItem, isValid }) => {
@@ -285,12 +285,12 @@ export const ArtifactFlyout = memo(
if (isMounted) {
// Close the flyout
// `undefined` will cause params to be dropped from url
- setUrlParams({ itemId: undefined, show: undefined }, true);
+ setUrlParams({ ...urlParams, itemId: undefined, show: undefined }, true);
onSuccess();
}
},
- [isEditFlow, isMounted, labels, onSuccess, setUrlParams, toasts]
+ [isEditFlow, isMounted, labels, onSuccess, setUrlParams, toasts, urlParams]
);
const handleSubmitClick = useCallback(() => {
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx
new file mode 100644
index 0000000000000..8ff4b71668fd4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, PropsWithChildren } from 'react';
+import { EuiCallOut, EuiText } from '@elastic/eui';
+import { UserCommandInput } from './user_command_input';
+import { ParsedCommandInput } from '../service/parsed_command_input';
+import { CommandDefinition } from '../types';
+import { CommandInputUsage } from './command_usage';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+
+export type BadArgumentProps = PropsWithChildren<{
+ parsedInput: ParsedCommandInput;
+ commandDefinition: CommandDefinition;
+}>;
+
+export const BadArgument = memo(
+ ({ parsedInput, commandDefinition, children = null }) => {
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ return (
+ <>
+
+
+
+
+ {children}
+
+
+ >
+ );
+ }
+);
+BadArgument.displayName = 'BadArgument';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx
new file mode 100644
index 0000000000000..2205bb38d0aea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo } from 'react';
+import { EuiCallOut } from '@elastic/eui';
+
+export interface CommandExecutionFailureProps {
+ error: Error;
+}
+export const CommandExecutionFailure = memo(({ error }) => {
+ return {error} ;
+});
+CommandExecutionFailure.displayName = 'CommandExecutionOutput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx
new file mode 100644
index 0000000000000..8bb9769980914
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx
@@ -0,0 +1,107 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, ReactNode, useCallback, useEffect, useState } from 'react';
+import { EuiButton, EuiLoadingChart } from '@elastic/eui';
+import styled from 'styled-components';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { CommandExecutionFailure } from './command_execution_failure';
+import { UserCommandInput } from './user_command_input';
+import { Command } from '../types';
+import { useCommandService } from '../hooks/state_selectors/use_command_service';
+import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch';
+
+const CommandOutputContainer = styled.div`
+ position: relative;
+
+ .run-in-background {
+ position: absolute;
+ right: 0;
+ top: 1em;
+ }
+`;
+
+export interface CommandExecutionOutputProps {
+ command: Command;
+}
+export const CommandExecutionOutput = memo(({ command }) => {
+ const commandService = useCommandService();
+ const [isRunning, setIsRunning] = useState(true);
+ const [output, setOutput] = useState(null);
+ const dispatch = useConsoleStateDispatch();
+
+ // FIXME:PT implement the `run in the background` functionality
+ const [showRunInBackground, setShowRunInTheBackground] = useState(false);
+ const handleRunInBackgroundClick = useCallback(() => {
+ setShowRunInTheBackground(false);
+ }, []);
+
+ useEffect(() => {
+ (async () => {
+ const timeoutId = setTimeout(() => {
+ setShowRunInTheBackground(true);
+ }, 15000);
+
+ try {
+ const commandOutput = await commandService.executeCommand(command);
+ setOutput(commandOutput.result);
+
+ // FIXME: PT the console should scroll the bottom as well
+ } catch (error) {
+ setOutput( );
+ }
+
+ clearTimeout(timeoutId);
+ setIsRunning(false);
+ setShowRunInTheBackground(false);
+ })();
+ }, [command, commandService]);
+
+ useEffect(() => {
+ if (!isRunning) {
+ dispatch({ type: 'scrollDown' });
+ }
+ }, [isRunning, dispatch]);
+
+ return (
+
+ {showRunInBackground && (
+
+
+
+
+
+ )}
+
+
+ {isRunning && (
+ <>
+
+ >
+ )}
+
+ {output}
+
+ );
+});
+CommandExecutionOutput.displayName = 'CommandExecutionOutput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx
new file mode 100644
index 0000000000000..e61318227cb1f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ConsoleProps } from '../../console';
+import { AppContextTestRender } from '../../../../../common/mock/endpoint';
+import { ConsoleTestSetup, getConsoleTestSetup } from '../../mocks';
+
+describe('When entering data into the Console input', () => {
+ let render: (props?: Partial) => ReturnType;
+ let renderResult: ReturnType;
+ let enterCommand: ConsoleTestSetup['enterCommand'];
+
+ beforeEach(() => {
+ const testSetup = getConsoleTestSetup();
+
+ ({ enterCommand } = testSetup);
+ render = (props = {}) => (renderResult = testSetup.renderConsole(props));
+ });
+
+ it('should display what the user is typing', () => {
+ render();
+
+ enterCommand('c', { inputOnly: true });
+ expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c');
+
+ enterCommand('m', { inputOnly: true });
+ expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm');
+ });
+
+ it('should delete last character when BACKSPACE is pressed', () => {
+ render();
+
+ enterCommand('cm', { inputOnly: true });
+ expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm');
+
+ enterCommand('{backspace}', { inputOnly: true, useKeyboard: true });
+ expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c');
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx
new file mode 100644
index 0000000000000..f9b12391e6f6b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx
@@ -0,0 +1,143 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, MouseEventHandler, useCallback, useRef, useState } from 'react';
+import { CommonProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import styled from 'styled-components';
+import classNames from 'classnames';
+import { KeyCapture, KeyCaptureProps } from './key_capture';
+import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch';
+import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
+
+const CommandInputContainer = styled.div`
+ .prompt {
+ padding-right: 1ch;
+ }
+
+ .textEntered {
+ white-space: break-spaces;
+ }
+
+ .cursor {
+ display: inline-block;
+ width: 0.5em;
+ height: 1em;
+ background-color: ${({ theme }) => theme.eui.euiTextColors.default};
+
+ animation: cursor-blink-animation 1s steps(5, start) infinite;
+ -webkit-animation: cursor-blink-animation 1s steps(5, start) infinite;
+ @keyframes cursor-blink-animation {
+ to {
+ visibility: hidden;
+ }
+ }
+ @-webkit-keyframes cursor-blink-animation {
+ to {
+ visibility: hidden;
+ }
+ }
+
+ &.inactive {
+ background-color: transparent !important;
+ }
+ }
+`;
+
+export interface CommandInputProps extends CommonProps {
+ prompt?: string;
+ isWaiting?: boolean;
+ focusRef?: KeyCaptureProps['focusRef'];
+}
+
+export const CommandInput = memo(
+ ({ prompt = '>', focusRef, ...commonProps }) => {
+ const dispatch = useConsoleStateDispatch();
+ const [textEntered, setTextEntered] = useState('');
+ const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false);
+ const _focusRef: KeyCaptureProps['focusRef'] = useRef(null);
+ const textDisplayRef = useRef(null);
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ const keyCaptureFocusRef = focusRef || _focusRef;
+
+ const handleKeyCaptureOnStateChange = useCallback<
+ NonNullable
+ >((isCapturing) => {
+ setIsKeyInputBeingCaptured(isCapturing);
+ }, []);
+
+ const handleTypingAreaClick = useCallback(
+ (ev) => {
+ if (keyCaptureFocusRef.current) {
+ keyCaptureFocusRef.current();
+ }
+ },
+ [keyCaptureFocusRef]
+ );
+
+ const handleKeyCapture = useCallback(
+ ({ value, eventDetails }) => {
+ setTextEntered((prevState) => {
+ let updatedState = prevState + value;
+
+ switch (eventDetails.keyCode) {
+ // BACKSPACE
+ // remove the last character from the text entered
+ case 8:
+ if (updatedState.length) {
+ updatedState = updatedState.replace(/.$/, '');
+ }
+ break;
+
+ // ENTER
+ // Execute command and blank out the input area
+ case 13:
+ dispatch({ type: 'executeCommand', payload: { input: updatedState } });
+ return '';
+ }
+
+ return updatedState;
+ });
+ },
+ [dispatch]
+ );
+
+ return (
+
+
+
+ {prompt}
+
+
+ {textEntered}
+
+
+
+
+
+
+
+ );
+ }
+);
+CommandInput.displayName = 'CommandInput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts
new file mode 100644
index 0000000000000..4db81ade86011
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { CommandInput } from './command_input';
+export type { CommandInputProps } from './command_input';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx
new file mode 100644
index 0000000000000..03bb133f88d79
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx
@@ -0,0 +1,156 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, {
+ FormEventHandler,
+ KeyboardEventHandler,
+ memo,
+ MutableRefObject,
+ useCallback,
+ useRef,
+ useState,
+} from 'react';
+import { pick } from 'lodash';
+import styled from 'styled-components';
+import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
+
+const NOOP = () => undefined;
+
+const KeyCaptureContainer = styled.span`
+ display: inline-block;
+ position: relative;
+ width: 1px;
+ height: 1em;
+ overflow: hidden;
+
+ .invisible-input {
+ &,
+ &:focus {
+ border: none;
+ background-image: none;
+ background-color: transparent;
+ -webkit-box-shadow: none;
+ -moz-box-shadow: none;
+ box-shadow: none;
+ animation: none !important;
+ width: 1ch !important;
+ position: absolute;
+ left: -100px;
+ top: -100px;
+ }
+ }
+`;
+
+export interface KeyCaptureProps {
+ onCapture: (params: {
+ value: string | undefined;
+ eventDetails: Pick<
+ KeyboardEvent,
+ 'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey'
+ >;
+ }) => void;
+ onStateChange?: (isCapturing: boolean) => void;
+ focusRef?: MutableRefObject<((force?: boolean) => void) | null>;
+}
+
+/**
+ * Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of
+ * the console. It's sole purpose is to capture what the user types, which is then pass along to be
+ * displayed in a more UX friendly way
+ */
+export const KeyCapture = memo(({ onCapture, focusRef, onStateChange }) => {
+ // We don't need the actual value that was last input in this component, because
+ // `setLastInput()` is used with a function that returns the typed character.
+ // This state is used like this:
+ // 1. user presses a keyboard key
+ // 2. `input` event is triggered - we store the letter typed
+ // 3. the next event to be triggered (after `input`) that we listen for is `keyup`,
+ // and when that is triggered, we take the input letter (already stored) and
+ // call `onCapture()` with it and then set the lastInput state back to an empty string
+ const [, setLastInput] = useState('');
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ const handleBlurAndFocus = useCallback(
+ (ev) => {
+ if (!onStateChange) {
+ return;
+ }
+
+ onStateChange(ev.type === 'focus');
+ },
+ [onStateChange]
+ );
+
+ const handleOnKeyUp = useCallback>(
+ (ev) => {
+ ev.stopPropagation();
+
+ const eventDetails = pick(ev, [
+ 'key',
+ 'altKey',
+ 'ctrlKey',
+ 'keyCode',
+ 'metaKey',
+ 'repeat',
+ 'shiftKey',
+ ]);
+
+ setLastInput((value) => {
+ onCapture({
+ value,
+ eventDetails,
+ });
+
+ return '';
+ });
+ },
+ [onCapture]
+ );
+
+ const handleOnInput = useCallback>((ev) => {
+ const newValue = ev.currentTarget.value;
+
+ setLastInput((prevState) => {
+ return `${prevState || ''}${newValue}`;
+ });
+ }, []);
+
+ const inputRef = useRef(null);
+
+ const setFocus = useCallback((force: boolean = false) => {
+ // If user selected text and `force` is not true, then don't focus (else they lose selection)
+ if (!force && (window.getSelection()?.toString() ?? '').length > 0) {
+ return;
+ }
+
+ inputRef.current?.focus();
+ }, []);
+
+ if (focusRef) {
+ focusRef.current = setFocus;
+ }
+
+ // FIXME:PT probably need to add `aria-` type properties to the input?
+ return (
+
+
+
+ );
+});
+KeyCapture.displayName = 'KeyCapture';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx
new file mode 100644
index 0000000000000..d7464e2f97391
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useMemo } from 'react';
+import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { CommandDefinition } from '../types';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+
+export interface CommandListProps {
+ commands: CommandDefinition[];
+}
+
+export const CommandList = memo(({ commands }) => {
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ const footerMessage = useMemo(() => {
+ return (
+ {'some-command --help'},
+ }}
+ />
+ );
+ }, []);
+
+ return (
+ <>
+
+ {commands.map(({ name, about }) => {
+ return (
+
+
+
+ );
+ })}
+
+ {footerMessage}
+ >
+ );
+});
+CommandList.displayName = 'CommandList';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx
new file mode 100644
index 0000000000000..9d17d83f0266f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useMemo } from 'react';
+import {
+ EuiDescriptionList,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { usageFromCommandDefinition } from '../service/usage_from_command_definition';
+import { CommandDefinition } from '../types';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+
+export const CommandInputUsage = memo>(({ commandDef }) => {
+ const usageHelp = useMemo(() => {
+ return usageFromCommandDefinition(commandDef);
+ }, [commandDef]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {usageHelp}
+
+
+
+
+ );
+});
+CommandInputUsage.displayName = 'CommandInputUsage';
+
+export interface CommandUsageProps {
+ commandDef: CommandDefinition;
+}
+
+export const CommandUsage = memo(({ commandDef }) => {
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+ const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]);
+ const commandOptions = useMemo(() => {
+ // `command.args` only here to silence TS check
+ if (!hasArgs || !commandDef.args) {
+ return [];
+ }
+
+ return Object.entries(commandDef.args).map(([option, { about: description }]) => ({
+ title: `--${option}`,
+ description,
+ }));
+ }, [commandDef.args, hasArgs]);
+ const additionalProps = useMemo(
+ () => ({
+ className: 'euiTruncateText',
+ }),
+ []
+ );
+
+ return (
+
+ {commandDef.about}
+
+ {hasArgs && (
+ <>
+
+
+
+
+ {commandDef.mustHaveArgs && commandDef.args && hasArgs && (
+
+
+
+ )}
+
+
+ {commandDef.args && (
+
+ )}
+ >
+ )}
+
+ );
+});
+CommandUsage.displayName = 'CommandUsage';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts
new file mode 100644
index 0000000000000..8d7de159bbc5a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+// FIXME:PT implement a React context to manage consoles
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx
new file mode 100644
index 0000000000000..852b2b1ab58fe
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react';
+import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer';
+import { ConsoleStore } from './types';
+
+const ConsoleStateContext = createContext(null);
+
+type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface;
+
+/**
+ * A Console wide data store for internal state management between inner components
+ */
+export const ConsoleStateProvider = memo(
+ ({ commandService, scrollToBottom, dataTestSubj, children }) => {
+ const [state, dispatch] = useReducer(
+ stateDataReducer,
+ { commandService, scrollToBottom, dataTestSubj },
+ initiateState
+ );
+
+ // FIXME:PT should handle cases where props that are in the store change
+ // Probably need to have a `useAffect()` that just does a `dispatch()` to update those.
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+ConsoleStateProvider.displayName = 'ConsoleStateProvider';
+
+export const useConsoleStore = (): ConsoleStore => {
+ const store = useContext(ConsoleStateContext);
+
+ if (!store) {
+ throw new Error(`ConsoleStateContext not defined`);
+ }
+
+ return store;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts
new file mode 100644
index 0000000000000..dc59ac1c2acef
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { ConsoleStateProvider } from './console_state';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts
new file mode 100644
index 0000000000000..94175d9821ae7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ConsoleDataState, ConsoleStoreReducer } from './types';
+import { handleExecuteCommand } from './state_update_handlers/handle_execute_command';
+import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service';
+
+export type InitialStateInterface = Pick<
+ ConsoleDataState,
+ 'commandService' | 'scrollToBottom' | 'dataTestSubj'
+>;
+
+export const initiateState = ({
+ commandService,
+ scrollToBottom,
+ dataTestSubj,
+}: InitialStateInterface): ConsoleDataState => {
+ return {
+ commandService,
+ scrollToBottom,
+ dataTestSubj,
+ commandHistory: [],
+ builtinCommandService: new ConsoleBuiltinCommandsService(),
+ };
+};
+
+export const stateDataReducer: ConsoleStoreReducer = (state, action) => {
+ switch (action.type) {
+ case 'scrollDown':
+ state.scrollToBottom();
+ return state;
+
+ case 'executeCommand':
+ return handleExecuteCommand(state, action);
+ }
+
+ return state;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx
new file mode 100644
index 0000000000000..b6a8e4db52340
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx
@@ -0,0 +1,192 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { ConsoleProps } from '../../../console';
+import { AppContextTestRender } from '../../../../../../common/mock/endpoint';
+import { getConsoleTestSetup } from '../../../mocks';
+import type { ConsoleTestSetup } from '../../../mocks';
+import { waitFor } from '@testing-library/react';
+
+describe('When a Console command is entered by the user', () => {
+ let render: (props?: Partial) => ReturnType;
+ let renderResult: ReturnType;
+ let commandServiceMock: ConsoleTestSetup['commandServiceMock'];
+ let enterCommand: ConsoleTestSetup['enterCommand'];
+
+ beforeEach(() => {
+ const testSetup = getConsoleTestSetup();
+
+ ({ commandServiceMock, enterCommand } = testSetup);
+ render = (props = {}) => (renderResult = testSetup.renderConsole(props));
+ });
+
+ it('should display all available commands when `help` command is entered', async () => {
+ render();
+ enterCommand('help');
+
+ expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy();
+
+ await waitFor(() => {
+ expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength(
+ // `+2` to account for builtin commands
+ commandServiceMock.getCommandList().length + 2
+ );
+ });
+ });
+
+ it('should display custom help output when Command service has `getHelp()` defined', async () => {
+ commandServiceMock.getHelp = async () => {
+ return {
+ result: {'help output'}
,
+ };
+ };
+ render();
+ enterCommand('help');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('custom-help')).toBeTruthy();
+ });
+ });
+
+ it('should clear the command output history when `clear` is entered', async () => {
+ render();
+ enterCommand('help');
+ enterCommand('help');
+
+ expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(2);
+
+ enterCommand('clear');
+
+ expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(0);
+ });
+
+ it('should show individual command help when `--help` option is used', async () => {
+ render();
+ enterCommand('cmd2 --help');
+
+ await waitFor(() => expect(renderResult.getByTestId('test-commandUsage')).toBeTruthy());
+ });
+
+ it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => {
+ commandServiceMock.getCommandUsage = async () => {
+ return {
+ result: {'command help here'}
,
+ };
+ };
+ render();
+ enterCommand('cmd2 --help');
+
+ await waitFor(() => expect(renderResult.getByTestId('cmd-help')).toBeTruthy());
+ });
+
+ it('should execute a command entered', async () => {
+ render();
+ enterCommand('cmd1');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('exec-output')).toBeTruthy();
+ });
+ });
+
+ it('should allow multiple of the same options if `allowMultiples` is `true`', async () => {
+ render();
+ enterCommand('cmd3 --foo one --foo two');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('exec-output')).toBeTruthy();
+ });
+ });
+
+ it('should show error if unknown command', async () => {
+ render();
+ enterCommand('foo-foo');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual(
+ 'Unknown commandFor a list of available command, enter: help'
+ );
+ });
+ });
+
+ it('should show error if options are used but command supports none', async () => {
+ render();
+ enterCommand('cmd1 --foo');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'command does not support any argumentsUsage:cmd1'
+ );
+ });
+ });
+
+ it('should show error if unknown option is used', async () => {
+ render();
+ enterCommand('cmd2 --file test --foo');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'unsupported argument: --fooUsage:cmd2 --file [--ext --bad]'
+ );
+ });
+ });
+
+ it('should show error if any required option is not set', async () => {
+ render();
+ enterCommand('cmd2 --ext one');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'missing required argument: --fileUsage:cmd2 --file [--ext --bad]'
+ );
+ });
+ });
+
+ it('should show error if argument is used more than one', async () => {
+ render();
+ enterCommand('cmd2 --file one --file two');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'argument can only be used once: --fileUsage:cmd2 --file [--ext --bad]'
+ );
+ });
+ });
+
+ it("should show error returned by the option's `validate()` callback", async () => {
+ render();
+ enterCommand('cmd2 --file one --bad foo');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'invalid argument value: --bad. This is a bad valueUsage:cmd2 --file [--ext --bad]'
+ );
+ });
+ });
+
+ it('should show error no options were provided, bug command requires some', async () => {
+ render();
+ enterCommand('cmd2');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'missing required arguments: --fileUsage:cmd2 --file [--ext --bad]'
+ );
+ });
+ });
+
+ it('should show error if all arguments are optional, but at least 1 must be defined', async () => {
+ render();
+ enterCommand('cmd4');
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('test-badArgument').textContent).toEqual(
+ 'at least one argument must be usedUsage:cmd4 [--foo --bar]'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx
new file mode 100644
index 0000000000000..2815ec4605917
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx
@@ -0,0 +1,269 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint complexity: ["error", 40]*/
+// FIXME:PT remove the complexity
+
+import React from 'react';
+import { i18n } from '@kbn/i18n';
+import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types';
+import { parseCommandInput } from '../../../service/parsed_command_input';
+import { HistoryItem } from '../../history_item';
+import { UnknownCommand } from '../../unknow_comand';
+import { HelpOutput } from '../../help_output';
+import { BadArgument } from '../../bad_argument';
+import { CommandExecutionOutput } from '../../command_execution_output';
+import { CommandDefinition } from '../../../types';
+
+const toCliArgumentOption = (argName: string) => `--${argName}`;
+
+const getRequiredArguments = (argDefinitions: CommandDefinition['args']): string[] => {
+ if (!argDefinitions) {
+ return [];
+ }
+
+ return Object.entries(argDefinitions)
+ .filter(([_, argDef]) => argDef.required)
+ .map(([argName]) => argName);
+};
+
+const updateStateWithNewCommandHistoryItem = (
+ state: ConsoleDataState,
+ newHistoryItem: ConsoleDataState['commandHistory'][number]
+): ConsoleDataState => {
+ return {
+ ...state,
+ commandHistory: [...state.commandHistory, newHistoryItem],
+ };
+};
+
+export const handleExecuteCommand: ConsoleStoreReducer<
+ ConsoleDataAction & { type: 'executeCommand' }
+> = (state, action) => {
+ const parsedInput = parseCommandInput(action.payload.input);
+
+ if (parsedInput.name === '') {
+ return state;
+ }
+
+ const { commandService, builtinCommandService } = state;
+
+ // Is it an internal command?
+ if (builtinCommandService.isBuiltin(parsedInput.name)) {
+ const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService);
+
+ if (commandOutput.clearBuffer) {
+ return {
+ ...state,
+ commandHistory: [],
+ };
+ }
+
+ return updateStateWithNewCommandHistoryItem(state, commandOutput.result);
+ }
+
+ // ----------------------------------------------------
+ // Validate and execute the user defined command
+ // ----------------------------------------------------
+ const commandDefinition = commandService
+ .getCommandList()
+ .find((definition) => definition.name === parsedInput.name);
+
+ // Unknown command
+ if (!commandDefinition) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+
+ );
+ }
+
+ const requiredArgs = getRequiredArguments(commandDefinition.args);
+
+ // If args were entered, then validate them
+ if (parsedInput.hasArgs()) {
+ // Show command help
+ if (parsedInput.hasArg('help')) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)(
+ commandDefinition
+ )}
+
+
+ );
+ }
+
+ // Command supports no arguments
+ if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate(
+ 'xpack.securitySolution.console.commandValidation.noArgumentsSupported',
+ {
+ defaultMessage: 'command does not support any arguments',
+ }
+ )}
+
+
+ );
+ }
+
+ // no unknown arguments allowed?
+ if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', {
+ defaultMessage: 'unknown argument(s): {unknownArgs}',
+ values: {
+ unknownArgs: parsedInput.unknownArgs.join(', '),
+ },
+ })}
+
+
+ );
+ }
+
+ // Missing required Arguments
+ for (const requiredArg of requiredArgs) {
+ if (!parsedInput.args[requiredArg]) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate(
+ 'xpack.securitySolution.console.commandValidation.missingRequiredArg',
+ {
+ defaultMessage: 'missing required argument: {argName}',
+ values: {
+ argName: toCliArgumentOption(requiredArg),
+ },
+ }
+ )}
+
+
+ );
+ }
+ }
+
+ // Validate each argument given to the command
+ for (const argName of Object.keys(parsedInput.args)) {
+ const argDefinition = commandDefinition.args[argName];
+ const argInput = parsedInput.args[argName];
+
+ // Unknown argument
+ if (!argDefinition) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', {
+ defaultMessage: 'unsupported argument: {argName}',
+ values: { argName: toCliArgumentOption(argName) },
+ })}
+
+
+ );
+ }
+
+ // does not allow multiple values
+ if (
+ !argDefinition.allowMultiples &&
+ Array.isArray(argInput.values) &&
+ argInput.values.length > 0
+ ) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate(
+ 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce',
+ {
+ defaultMessage: 'argument can only be used once: {argName}',
+ values: { argName: toCliArgumentOption(argName) },
+ }
+ )}
+
+
+ );
+ }
+
+ if (argDefinition.validate) {
+ const validationResult = argDefinition.validate(argInput);
+
+ if (validationResult !== true) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate(
+ 'xpack.securitySolution.console.commandValidation.invalidArgValue',
+ {
+ defaultMessage: 'invalid argument value: {argName}. {error}',
+ values: { argName: toCliArgumentOption(argName), error: validationResult },
+ }
+ )}
+
+
+ );
+ }
+ }
+ }
+ } else if (requiredArgs.length > 0) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', {
+ defaultMessage: 'missing required arguments: {requiredArgs}',
+ values: {
+ requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '),
+ },
+ })}
+
+
+ );
+ } else if (commandDefinition.mustHaveArgs) {
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+ {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', {
+ defaultMessage: 'at least one argument must be used',
+ })}
+
+
+ );
+ }
+
+ // All is good. Execute the command
+ return updateStateWithNewCommandHistoryItem(
+ state,
+
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts
new file mode 100644
index 0000000000000..72810d31e3248
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Dispatch, Reducer } from 'react';
+import { CommandServiceInterface } from '../../types';
+import { HistoryItemComponent } from '../history_item';
+import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service';
+
+export interface ConsoleDataState {
+ /** Command service defined on input to the `Console` component by consumers of the component */
+ commandService: CommandServiceInterface;
+ /** Command service for builtin console commands */
+ builtinCommandService: BuiltinCommandServiceInterface;
+ /** UI function that scrolls the console down to the bottom */
+ scrollToBottom: () => void;
+ /**
+ * List of commands entered by the user and being shown in the UI
+ */
+ commandHistory: Array>;
+ dataTestSubj?: string;
+}
+
+export type ConsoleDataAction =
+ | { type: 'scrollDown' }
+ | { type: 'executeCommand'; payload: { input: string } };
+
+export interface ConsoleStore {
+ state: ConsoleDataState;
+ dispatch: Dispatch;
+}
+
+export type ConsoleStoreReducer = Reducer<
+ ConsoleDataState,
+ A
+>;
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx
new file mode 100644
index 0000000000000..b0a2217e169c4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, ReactNode, useEffect, useState } from 'react';
+import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui';
+import { UserCommandInput } from './user_command_input';
+import { CommandExecutionFailure } from './command_execution_failure';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+
+export interface HelpOutputProps extends Pick {
+ input: string;
+ children: ReactNode | Promise<{ result: ReactNode }>;
+}
+export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => {
+ const [content, setContent] = useState( );
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ useEffect(() => {
+ if (children instanceof Promise) {
+ (async () => {
+ try {
+ const response = await (children as Promise<{
+ result: ReactNode;
+ }>);
+ setContent(response.result);
+ } catch (error) {
+ setContent( );
+ }
+ })();
+
+ return;
+ }
+
+ setContent(children);
+ }, [children]);
+
+ return (
+
+ );
+});
+HelpOutput.displayName = 'HelpOutput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx
new file mode 100644
index 0000000000000..0143d36f0e766
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, PropsWithChildren } from 'react';
+import { EuiFlexItem } from '@elastic/eui';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+
+export type HistoryItemProps = PropsWithChildren<{}>;
+
+export const HistoryItem = memo(({ children }) => {
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ return (
+
+ {children}
+
+ );
+});
+
+HistoryItem.displayName = 'HistoryItem';
+
+export type HistoryItemComponent = typeof HistoryItem;
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx
new file mode 100644
index 0000000000000..088a6fac57ae4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useEffect } from 'react';
+import { CommonProps, EuiFlexGroup } from '@elastic/eui';
+import { useCommandHistory } from '../hooks/state_selectors/use_command_history';
+import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+
+export type OutputHistoryProps = CommonProps;
+
+export const HistoryOutput = memo((commonProps) => {
+ const historyItems = useCommandHistory();
+ const dispatch = useConsoleStateDispatch();
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ // Anytime we add a new item to the history
+ // scroll down so that command input remains visible
+ useEffect(() => {
+ dispatch({ type: 'scrollDown' });
+ }, [dispatch, historyItems.length]);
+
+ return (
+
+ {historyItems}
+
+ );
+});
+
+HistoryOutput.displayName = 'HistoryOutput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx
new file mode 100644
index 0000000000000..5529457cbb05a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo } from 'react';
+import { EuiCallOut, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { UserCommandInput } from './user_command_input';
+import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
+import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
+
+export interface UnknownCommand {
+ input: string;
+}
+export const UnknownCommand = memo(({ input }) => {
+ const getTestId = useTestIdGenerator(useDataTestSubj());
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {'help'},
+ }}
+ />
+
+
+ >
+ );
+});
+UnknownCommand.displayName = 'UnknownCommand';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx
new file mode 100644
index 0000000000000..84afff3f28209
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo } from 'react';
+
+export interface UserCommandInputProps {
+ input: string;
+}
+
+export const UserCommandInput = memo(({ input }) => {
+ return (
+ <>
+ {'$ '}
+ {input}
+ >
+ );
+});
+UserCommandInput.displayName = 'UserCommandInput';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx
new file mode 100644
index 0000000000000..9adeaa72d683e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { AppContextTestRender } from '../../../common/mock/endpoint';
+import { ConsoleProps } from './console';
+import { getConsoleTestSetup } from './mocks';
+import userEvent from '@testing-library/user-event';
+
+describe('When using Console component', () => {
+ let render: (props?: Partial) => ReturnType;
+ let renderResult: ReturnType;
+
+ beforeEach(() => {
+ const testSetup = getConsoleTestSetup();
+
+ render = (props = {}) => (renderResult = testSetup.renderConsole(props));
+ });
+
+ it('should render console', () => {
+ render();
+
+ expect(renderResult.getByTestId('test')).toBeTruthy();
+ });
+
+ it('should display prompt given on input', () => {
+ render({ prompt: 'MY PROMPT>>' });
+
+ expect(renderResult.getByTestId('test-cmdInput-prompt').textContent).toEqual('MY PROMPT>>');
+ });
+
+ it('should focus on input area when it gains focus', () => {
+ render();
+ userEvent.click(renderResult.getByTestId('test-mainPanel'));
+
+ expect(document.activeElement!.classList.contains('invisible-input')).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx
new file mode 100644
index 0000000000000..6c64a045c86fe
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useCallback, useRef } from 'react';
+import { CommonProps, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
+import styled from 'styled-components';
+import { HistoryOutput } from './components/history_output';
+import { CommandInput, CommandInputProps } from './components/command_input';
+import { CommandServiceInterface } from './types';
+import { ConsoleStateProvider } from './components/console_state';
+import { useTestIdGenerator } from '../hooks/use_test_id_generator';
+
+// FIXME:PT implement dark mode for the console or light mode switch
+
+const ConsoleWindow = styled.div`
+ height: 100%;
+
+ // FIXME: IMPORTANT - this should NOT be used in production
+ // dark mode on light theme / light mode on dark theme
+ filter: invert(100%);
+
+ .ui-panel {
+ min-width: ${({ theme }) => theme.eui.euiBreakpoints.s};
+ height: 100%;
+ min-height: 300px;
+ overflow-y: auto;
+ }
+
+ .descriptionList-20_80 {
+ &.euiDescriptionList {
+ > .euiDescriptionList__title {
+ width: 20%;
+ }
+
+ > .euiDescriptionList__description {
+ width: 80%;
+ }
+ }
+ }
+`;
+
+export interface ConsoleProps extends CommonProps, Pick {
+ commandService: CommandServiceInterface;
+}
+
+export const Console = memo(({ prompt, commandService, ...commonProps }) => {
+ const consoleWindowRef = useRef(null);
+ const inputFocusRef: CommandInputProps['focusRef'] = useRef(null);
+ const getTestId = useTestIdGenerator(commonProps['data-test-subj']);
+
+ const scrollToBottom = useCallback(() => {
+ // We need the `setTimeout` here because in some cases, the command output
+ // will take a bit of time to populate its content due to the use of Promises
+ setTimeout(() => {
+ if (consoleWindowRef.current) {
+ consoleWindowRef.current.scrollTop = consoleWindowRef.current.scrollHeight;
+ }
+ }, 1);
+
+ // NOTE: its IMPORTANT that this callback does NOT have any dependencies, because
+ // it is stored in State and currently not updated if it changes
+ }, []);
+
+ const handleConsoleClick = useCallback(() => {
+ if (inputFocusRef.current) {
+ inputFocusRef.current();
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+Console.displayName = 'Console';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts
new file mode 100644
index 0000000000000..22167d5066743
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useConsoleStore } from '../../components/console_state/console_state';
+import { CommandServiceInterface } from '../../types';
+
+export const useCommandService = (): CommandServiceInterface => {
+ return useConsoleStore().state.builtinCommandService;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts
new file mode 100644
index 0000000000000..ded51471a1c3e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useConsoleStore } from '../../components/console_state/console_state';
+
+export const useCommandHistory = () => {
+ return useConsoleStore().state.commandHistory;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts
new file mode 100644
index 0000000000000..66ce0c2b5eb43
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useConsoleStore } from '../../components/console_state/console_state';
+import { CommandServiceInterface } from '../../types';
+
+export const useCommandService = (): CommandServiceInterface => {
+ return useConsoleStore().state.commandService;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts
new file mode 100644
index 0000000000000..90e5fe094f9c7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useConsoleStore } from '../../components/console_state/console_state';
+import { ConsoleStore } from '../../components/console_state/types';
+
+export const useConsoleStateDispatch = (): ConsoleStore['dispatch'] => {
+ return useConsoleStore().dispatch;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts
new file mode 100644
index 0000000000000..144a5a63cd71b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useConsoleStore } from '../../components/console_state/console_state';
+
+export const useDataTestSubj = (): string | undefined => {
+ return useConsoleStore().state.dataTestSubj;
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts
new file mode 100644
index 0000000000000..81244b3013b36
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { Console } from './console';
+export type { ConsoleProps } from './console';
+export type { CommandServiceInterface, CommandDefinition, Command } from './types';
diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx
new file mode 100644
index 0000000000000..693daf83ed6ea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx
@@ -0,0 +1,176 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import React from 'react';
+import { EuiCode } from '@elastic/eui';
+import userEvent from '@testing-library/user-event';
+import { act } from '@testing-library/react';
+import { Console } from './console';
+import type { ConsoleProps } from './console';
+import type { Command, CommandServiceInterface } from './types';
+import type { AppContextTestRender } from '../../../common/mock/endpoint';
+import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
+import { CommandDefinition } from './types';
+
+export interface ConsoleTestSetup {
+ renderConsole(props?: Partial): ReturnType;
+
+ commandServiceMock: jest.Mocked;
+
+ enterCommand(
+ cmd: string,
+ options?: Partial<{
+ /** If true, the ENTER key will not be pressed */
+ inputOnly: boolean;
+ /**
+ * if true, then the keyboard keys will be used to send the command.
+ * Use this if wanting ot press keyboard keys other than letter/punctuation
+ */
+ useKeyboard: boolean;
+ }>
+ ): void;
+}
+
+export const getConsoleTestSetup = (): ConsoleTestSetup => {
+ const mockedContext = createAppRootMockRenderer();
+
+ let renderResult: ReturnType;
+
+ const commandServiceMock = getCommandServiceMock();
+
+ const renderConsole: ConsoleTestSetup['renderConsole'] = ({
+ prompt = '$$>',
+ commandService = commandServiceMock,
+ 'data-test-subj': dataTestSubj = 'test',
+ ...others
+ } = {}) => {
+ if (commandService !== commandServiceMock) {
+ throw new Error('Must use CommandService provided by test setup');
+ }
+
+ return (renderResult = mockedContext.render(
+
+ ));
+ };
+
+ const enterCommand: ConsoleTestSetup['enterCommand'] = (
+ cmd,
+ { inputOnly = false, useKeyboard = false } = {}
+ ) => {
+ const keyCaptureInput = renderResult.getByTestId('test-keyCapture-input');
+
+ act(() => {
+ if (useKeyboard) {
+ userEvent.click(keyCaptureInput);
+ userEvent.keyboard(cmd);
+ } else {
+ userEvent.type(keyCaptureInput, cmd);
+ }
+
+ if (!inputOnly) {
+ userEvent.keyboard('{enter}');
+ }
+ });
+ };
+
+ return {
+ renderConsole,
+ commandServiceMock,
+ enterCommand,
+ };
+};
+
+export const getCommandServiceMock = (): jest.Mocked => {
+ return {
+ getCommandList: jest.fn(() => {
+ const commands: CommandDefinition[] = [
+ {
+ name: 'cmd1',
+ about: 'a command with no options',
+ },
+ {
+ name: 'cmd2',
+ about: 'runs cmd 2',
+ args: {
+ file: {
+ about: 'Includes file in the run',
+ required: true,
+ allowMultiples: false,
+ validate: () => {
+ return true;
+ },
+ },
+ ext: {
+ about: 'optional argument',
+ required: false,
+ allowMultiples: false,
+ },
+ bad: {
+ about: 'will fail validation',
+ required: false,
+ allowMultiples: false,
+ validate: () => 'This is a bad value',
+ },
+ },
+ },
+ {
+ name: 'cmd3',
+ about: 'allows argument to be used multiple times',
+ args: {
+ foo: {
+ about: 'foo stuff',
+ required: true,
+ allowMultiples: true,
+ },
+ },
+ },
+ {
+ name: 'cmd4',
+ about: 'all options optinal, but at least one is required',
+ mustHaveArgs: true,
+ args: {
+ foo: {
+ about: 'foo stuff',
+ required: false,
+ allowMultiples: true,
+ },
+ bar: {
+ about: 'bar stuff',
+ required: false,
+ allowMultiples: true,
+ },
+ },
+ },
+ ];
+
+ return commands;
+ }),
+
+ executeCommand: jest.fn(async (command: Command) => {
+ await new Promise((r) => setTimeout(r, 1));
+
+ return {
+ result: (
+
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+
+ {JSON.stringify(command.args, null, 2)}
+
+
+ ),
+ };
+ }),
+ };
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx
new file mode 100644
index 0000000000000..6cd8af0dc6eff
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx
@@ -0,0 +1,102 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { ReactNode } from 'react';
+import { i18n } from '@kbn/i18n';
+import { HistoryItem, HistoryItemComponent } from '../components/history_item';
+import { HelpOutput } from '../components/help_output';
+import { ParsedCommandInput } from './parsed_command_input';
+import { CommandList } from '../components/command_list';
+import { CommandUsage } from '../components/command_usage';
+import { Command, CommandDefinition, CommandServiceInterface } from '../types';
+import { BuiltinCommandServiceInterface } from './types.builtin_command_service';
+
+const builtInCommands = (): CommandDefinition[] => {
+ return [
+ {
+ name: 'help',
+ about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', {
+ defaultMessage: 'View list of available commands',
+ }),
+ },
+ {
+ name: 'clear',
+ about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', {
+ defaultMessage: 'Clear the console buffer',
+ }),
+ },
+ ];
+};
+
+export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface {
+ constructor(private commandList = builtInCommands()) {}
+
+ getCommandList(): CommandDefinition[] {
+ return this.commandList;
+ }
+
+ async executeCommand(command: Command): Promise<{ result: ReactNode }> {
+ return {
+ result: null,
+ };
+ }
+
+ executeBuiltinCommand(
+ parsedInput: ParsedCommandInput,
+ contextConsoleService: CommandServiceInterface
+ ): { result: ReturnType | null; clearBuffer?: boolean } {
+ switch (parsedInput.name) {
+ case 'help':
+ return {
+ result: (
+
+
+ {this.getHelpContent(parsedInput, contextConsoleService)}
+
+
+ ),
+ };
+
+ case 'clear':
+ return {
+ result: null,
+ clearBuffer: true,
+ };
+ }
+
+ return { result: null };
+ }
+
+ async getHelpContent(
+ parsedInput: ParsedCommandInput,
+ commandService: CommandServiceInterface
+ ): Promise<{ result: ReactNode }> {
+ let helpOutput: ReactNode;
+
+ if (commandService.getHelp) {
+ helpOutput = (await commandService.getHelp()).result;
+ } else {
+ helpOutput = (
+
+ );
+ }
+
+ return {
+ result: helpOutput,
+ };
+ }
+
+ isBuiltin(name: string): boolean {
+ return !!this.commandList.find((command) => command.name === name);
+ }
+
+ async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> {
+ return {
+ result: ,
+ };
+ }
+}
diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts
new file mode 100644
index 0000000000000..55e0b3dc6267b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts
@@ -0,0 +1,95 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+// @ts-ignore
+// eslint-disable-next-line import/no-extraneous-dependencies
+import argsplit from 'argsplit';
+
+// FIXME:PT use a 3rd party lib for arguments parsing
+// For now, just using what I found in kibana package.json devDependencies, so this will NOT work for production
+
+// FIXME:PT Type `ParsedCommandInput` should be a generic that allows for the args's keys to be defined
+
+export interface ParsedArgData {
+ /** For arguments that were used only once. Will be `undefined` if multiples were used */
+ value: undefined | string;
+ /** For arguments that were used multiple times */
+ values: undefined | string[];
+}
+
+export interface ParsedCommandInput {
+ input: string;
+ name: string;
+ args: {
+ [argName: string]: ParsedArgData;
+ };
+ unknownArgs: undefined | string[];
+ hasArgs(): boolean;
+ hasArg(argName: string): boolean;
+}
+
+const PARSED_COMMAND_INPUT_PROTOTYPE: Pick = Object.freeze({
+ hasArgs(this: ParsedCommandInput) {
+ return Object.keys(this.args).length > 0 || Array.isArray(this.unknownArgs);
+ },
+
+ hasArg(argName: string): boolean {
+ // @ts-ignore
+ return Object.prototype.hasOwnProperty.call(this.args, argName);
+ },
+});
+
+export const parseCommandInput = (input: string): ParsedCommandInput => {
+ const inputTokens: string[] = argsplit(input) || [];
+ const name: string = inputTokens.shift() || '';
+ const args: ParsedCommandInput['args'] = {};
+ let unknownArgs: ParsedCommandInput['unknownArgs'];
+
+ // All options start with `--`
+ let argName = '';
+
+ for (const inputToken of inputTokens) {
+ if (inputToken.startsWith('--')) {
+ argName = inputToken.substr(2);
+
+ if (!args[argName]) {
+ args[argName] = {
+ value: undefined,
+ values: undefined,
+ };
+ }
+
+ // eslint-disable-next-line no-continue
+ continue;
+ } else if (!argName) {
+ (unknownArgs = unknownArgs || []).push(inputToken);
+
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+
+ if (Array.isArray(args[argName].values)) {
+ // @ts-ignore
+ args[argName].values.push(inputToken);
+ } else {
+ // Do we have multiple values for this argumentName, then create array for values
+ if (args[argName].value !== undefined) {
+ args[argName].values = [args[argName].value ?? '', inputToken];
+ args[argName].value = undefined;
+ } else {
+ args[argName].value = inputToken;
+ }
+ }
+ }
+
+ return Object.assign(Object.create(PARSED_COMMAND_INPUT_PROTOTYPE), {
+ input,
+ name,
+ args,
+ unknownArgs,
+ });
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts
new file mode 100644
index 0000000000000..dbd5347ea99c2
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ReactNode } from 'react';
+import { CommandDefinition, CommandServiceInterface } from '../types';
+import { ParsedCommandInput } from './parsed_command_input';
+import { HistoryItemComponent } from '../components/history_item';
+
+export interface BuiltinCommandServiceInterface extends CommandServiceInterface {
+ executeBuiltinCommand(
+ parsedInput: ParsedCommandInput,
+ contextConsoleService: CommandServiceInterface
+ ): { result: ReturnType | null; clearBuffer?: boolean };
+
+ getHelpContent(
+ parsedInput: ParsedCommandInput,
+ commandService: CommandServiceInterface
+ ): Promise<{ result: ReactNode }>;
+
+ isBuiltin(name: string): boolean;
+
+ getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>;
+}
diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts
new file mode 100644
index 0000000000000..edc7d404fd8dd
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CommandDefinition } from '../types';
+
+export const usageFromCommandDefinition = (command: CommandDefinition): string => {
+ let requiredArgs = '';
+ let optionalArgs = '';
+
+ if (command.args) {
+ for (const [argName, argDefinition] of Object.entries(command.args)) {
+ if (argDefinition.required) {
+ if (requiredArgs.length) {
+ requiredArgs += ' ';
+ }
+ requiredArgs += `--${argName}`;
+ } else {
+ if (optionalArgs.length) {
+ optionalArgs += ' ';
+ }
+ optionalArgs += `--${argName}`;
+ }
+ }
+ }
+
+ return `${command.name} ${requiredArgs} ${
+ optionalArgs.length > 0 ? `[${optionalArgs}]` : ''
+ }`.trim();
+};
diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts
new file mode 100644
index 0000000000000..e2b6d5c2a84aa
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ReactNode } from 'react';
+import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input';
+
+export interface CommandDefinition {
+ name: string;
+ about: string;
+ validator?: () => Promise;
+ /** If all args are optional, but at least one must be defined, set to true */
+ mustHaveArgs?: boolean;
+ args?: {
+ [longName: string]: {
+ required: boolean;
+ allowMultiples: boolean;
+ about: string;
+ /**
+ * Validate the individual values given to this argument.
+ * Should return `true` if valid or a string with the error message
+ */
+ validate?: (argData: ParsedArgData) => true | string;
+ // Selector: Idea is that the schema can plugin in a rich component for the
+ // user to select something (ex. a file)
+ // FIXME: implement selector
+ selector?: () => unknown;
+ };
+ };
+}
+
+/**
+ * A command to be executed (as entered by the user)
+ */
+export interface Command {
+ /** The raw input entered by the user */
+ input: string;
+ // FIXME:PT this should be a generic that allows for the arguments type to be used
+ /** An object with the arguments entered by the user and their value */
+ args: ParsedCommandInput;
+ /** The command defined associated with this user command */
+ commandDefinition: CommandDefinition;
+}
+
+export interface CommandServiceInterface {
+ getCommandList(): CommandDefinition[];
+
+ executeCommand(command: Command): Promise<{ result: ReactNode }>;
+
+ /**
+ * If defined, then the `help` builtin command will display this output instead of the default one
+ * which is generated out of the Command list
+ */
+ getHelp?: () => Promise<{ result: ReactNode }>;
+
+ /**
+ * If defined, then the output of this function will be used to display individual
+ * command help (`--help`)
+ */
+ getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>;
+}
diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx
new file mode 100644
index 0000000000000..28472e123380a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useMemo } from 'react';
+import { Console } from '../console';
+import { EndpointConsoleCommandService } from './endpoint_console_command_service';
+import type { HostMetadata } from '../../../../common/endpoint/types';
+
+export interface EndpointConsoleProps {
+ endpoint: HostMetadata;
+}
+
+export const EndpointConsole = memo((props) => {
+ const consoleService = useMemo(() => {
+ return new EndpointConsoleCommandService();
+ }, []);
+
+ return `} commandService={consoleService} />;
+});
+
+EndpointConsole.displayName = 'EndpointConsole';
diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx
new file mode 100644
index 0000000000000..5028879bc1a49
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { ReactNode } from 'react';
+import { CommandServiceInterface, CommandDefinition, Command } from '../console';
+
+/**
+ * Endpoint specific Response Actions (commands) for use with Console.
+ */
+export class EndpointConsoleCommandService implements CommandServiceInterface {
+ getCommandList(): CommandDefinition[] {
+ return [];
+ }
+
+ async executeCommand(command: Command): Promise<{ result: ReactNode }> {
+ return { result: <>> };
+ }
+}
diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts
new file mode 100644
index 0000000000000..97f7fb61ae607
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { EndpointConsole } from './endpoint_console';
diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx
index 030538598c8ad..9a6be2814a396 100644
--- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx
@@ -65,6 +65,7 @@ import { useLicense } from '../../../../../common/hooks/use_license';
import { isValidHash } from '../../../../../../common/endpoint/service/trusted_apps/validations';
import { isArtifactGlobal } from '../../../../../../common/endpoint/service/artifacts';
import type { PolicyData } from '../../../../../../common/endpoint/types';
+import { isGlobalPolicyEffected } from '../../../../components/effected_policy_select/utils';
interface BlocklistEntry {
field: BlocklistConditionEntryField;
@@ -106,14 +107,34 @@ export const BlockListForm = memo(
const warningsRef = useRef({});
const errorsRef = useRef({});
const [selectedPolicies, setSelectedPolicies] = useState([]);
+ const isPlatinumPlus = useLicense().isPlatinumPlus();
+ const isGlobal = useMemo(() => isArtifactGlobal(item as ExceptionListItemSchema), [item]);
+ const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(item.tags));
+ const [hasFormChanged, setHasFormChanged] = useState(false);
+
+ const showAssignmentSection = useMemo(() => {
+ return (
+ isPlatinumPlus ||
+ (mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged)))
+ );
+ }, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]);
+
+ // set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not
+ useEffect(() => {
+ if (!hasFormChanged && item.tags) {
+ setWasByPolicy(!isGlobalPolicyEffected(item.tags));
+ }
+ }, [item.tags, hasFormChanged]);
// select policies if editing
useEffect(() => {
+ if (hasFormChanged) return;
const policyIds = item.tags?.map((tag) => tag.split(':')[1]) ?? [];
if (!policyIds.length) return;
const policiesData = policies.filter((policy) => policyIds.includes(policy.id));
+
setSelectedPolicies(policiesData);
- }, [item.tags, policies]);
+ }, [hasFormChanged, item.tags, policies]);
const blocklistEntry = useMemo((): BlocklistEntry => {
if (!item.entries.length) {
@@ -248,6 +269,7 @@ export const BlockListForm = memo(
isValid: isValid(errorsRef.current),
item: nextItem,
});
+ setHasFormChanged(true);
},
[validateValues, onChange, item]
);
@@ -261,6 +283,7 @@ export const BlockListForm = memo(
description: event.target.value,
},
});
+ setHasFormChanged(true);
},
[onChange, item]
);
@@ -286,6 +309,7 @@ export const BlockListForm = memo(
isValid: isValid(errorsRef.current),
item: nextItem,
});
+ setHasFormChanged(true);
},
[validateValues, blocklistEntry, onChange, item]
);
@@ -302,6 +326,7 @@ export const BlockListForm = memo(
isValid: isValid(errorsRef.current),
item: nextItem,
});
+ setHasFormChanged(true);
},
[validateValues, onChange, item, blocklistEntry]
);
@@ -320,6 +345,7 @@ export const BlockListForm = memo(
isValid: isValid(errorsRef.current),
item: nextItem,
});
+ setHasFormChanged(true);
},
[validateValues, onChange, item, blocklistEntry]
);
@@ -341,6 +367,7 @@ export const BlockListForm = memo(
isValid: isValid(errorsRef.current),
item: nextItem,
});
+ setHasFormChanged(true);
},
[validateValues, onChange, item, blocklistEntry]
);
@@ -351,16 +378,20 @@ export const BlockListForm = memo(
? [GLOBAL_ARTIFACT_TAG]
: change.selected.map((policy) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policy.id}`);
- setSelectedPolicies(change.selected);
+ const nextItem = { ...item, tags };
+
+ // Preserve old selected policies when switching to global
+ if (!change.isGlobal) {
+ setSelectedPolicies(change.selected);
+ }
+ validateValues(nextItem);
onChange({
isValid: isValid(errorsRef.current),
- item: {
- ...item,
- tags,
- },
+ item: nextItem,
});
+ setHasFormChanged(true);
},
- [onChange, item]
+ [validateValues, onChange, item]
);
return (
@@ -461,20 +492,22 @@ export const BlockListForm = memo(
/>
- <>
-
-
-
-
- >
+ {showAssignmentSection && (
+ <>
+
+
+
+
+ >
+ )}
);
}
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx
new file mode 100644
index 0000000000000..7fb057809919e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx
@@ -0,0 +1,95 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useMemo } from 'react';
+import { EuiCode } from '@elastic/eui';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
+import { useUrlParams } from '../../../components/hooks/use_url_params';
+import {
+ Command,
+ CommandDefinition,
+ CommandServiceInterface,
+ Console,
+} from '../../../components/console';
+
+const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms));
+
+class DevCommandService implements CommandServiceInterface {
+ getCommandList(): CommandDefinition[] {
+ return [
+ {
+ name: 'cmd1',
+ about: 'Runs cmd1',
+ },
+ {
+ name: 'cmd2',
+ about: 'runs cmd 2',
+ args: {
+ file: {
+ required: true,
+ allowMultiples: false,
+ about: 'Includes file in the run',
+ validate: () => {
+ return true;
+ },
+ },
+ bad: {
+ required: false,
+ allowMultiples: false,
+ about: 'will fail validation',
+ validate: () => 'This is a bad value',
+ },
+ },
+ },
+ {
+ name: 'cmd-long-delay',
+ about: 'runs cmd 2',
+ },
+ ];
+ }
+
+ async executeCommand(command: Command): Promise<{ result: React.ReactNode }> {
+ await delay();
+
+ if (command.commandDefinition.name === 'cmd-long-delay') {
+ await delay(20000);
+ }
+
+ return {
+ result: (
+
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+
{JSON.stringify(command.args, null, 2)}
+
+ ),
+ };
+ }
+}
+
+// ------------------------------------------------------------
+// FOR DEV PURPOSES ONLY
+// FIXME:PT Delete once we have support via row actions menu
+// ------------------------------------------------------------
+export const DevConsole = memo(() => {
+ const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled');
+
+ const consoleService = useMemo(() => {
+ return new DevCommandService();
+ }, []);
+
+ const {
+ urlParams: { showConsole = false },
+ } = useUrlParams();
+
+ return isConsoleEnabled && showConsole ? (
+
+
+
+ ) : null;
+});
+DevConsole.displayName = 'DevConsole';
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx
index da6f3b54323c5..3946edb9a0981 100644
--- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx
@@ -68,6 +68,7 @@ import {
BackToExternalAppButton,
BackToExternalAppButtonProps,
} from '../../../components/back_to_external_app_button/back_to_external_app_button';
+import { DevConsole } from './dev_console';
const MAX_PAGINATED_ITEM = 9999;
const TRANSFORM_URL = '/data/transform';
@@ -664,6 +665,9 @@ export const EndpointList = () => {
}
headerBackComponent={routeState.backLink && backToPolicyList}
>
+ {/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */}
+
+
{hasSelectedEndpoint && }
<>
{areEndpointsEnrolling && !hasErrorFindingTotals && (
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts
index bcc87c3c54fae..337b851466f49 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts
@@ -14,6 +14,7 @@ const FEATURES = {
HOST_ISOLATION_EXCEPTION_BY_POLICY: 'Host isolation exception by policy',
TRUSTED_APP_BY_POLICY: 'Trusted app by policy',
EVENT_FILTERS_BY_POLICY: 'Event filters by policy',
+ BLOCKLIST_BY_POLICY: 'Blocklists by policy',
RANSOMWARE_PROTECTION: 'Ransomeware protection',
MEMORY_THREAT_PROTECTION: 'Memory threat protection',
BEHAVIOR_PROTECTION: 'Behavior protection',
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts
index 0c3c3ec7af472..38300dff14558 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts
@@ -50,6 +50,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger });
return persistenceRuleType({
...type,
+ cancelAlertsOnRuleTimeout: false,
useSavedObjectReferences: {
extractReferences: (params) => extractReferences({ logger, params }),
injectReferences: (params, savedObjectReferences) =>
@@ -304,51 +305,52 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
});
}
- if (result.success) {
- const createdSignalsCount = result.createdSignals.length;
-
- if (actions.length) {
- const fromInMs = parseScheduleDates(`now-${interval}`)?.format('x');
- const toInMs = parseScheduleDates('now')?.format('x');
- const resultsLink = getNotificationResultsLink({
- from: fromInMs,
- to: toInMs,
+ const createdSignalsCount = result.createdSignals.length;
+
+ if (actions.length) {
+ const fromInMs = parseScheduleDates(`now-${interval}`)?.format('x');
+ const toInMs = parseScheduleDates('now')?.format('x');
+ const resultsLink = getNotificationResultsLink({
+ from: fromInMs,
+ to: toInMs,
+ id: alertId,
+ kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
+ ?.kibana_siem_app_url,
+ });
+
+ logger.debug(
+ buildRuleMessage(`Found ${createdSignalsCount} signals for notification.`)
+ );
+
+ if (completeRule.ruleConfig.throttle != null) {
+ // NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early
+ await scheduleThrottledNotificationActions({
+ alertInstance: services.alertFactory.create(alertId),
+ throttle: completeRule.ruleConfig.throttle ?? '',
+ startedAt,
id: alertId,
kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
?.kibana_siem_app_url,
+ outputIndex: ruleDataClient.indexNameWithNamespace(spaceId),
+ ruleId,
+ esClient: services.scopedClusterClient.asCurrentUser,
+ notificationRuleParams,
+ signals: result.createdSignals,
+ logger,
+ });
+ } else if (createdSignalsCount) {
+ const alertInstance = services.alertFactory.create(alertId);
+ scheduleNotificationActions({
+ alertInstance,
+ signalsCount: createdSignalsCount,
+ signals: result.createdSignals,
+ resultsLink,
+ ruleParams: notificationRuleParams,
});
-
- logger.debug(
- buildRuleMessage(`Found ${createdSignalsCount} signals for notification.`)
- );
-
- if (completeRule.ruleConfig.throttle != null) {
- await scheduleThrottledNotificationActions({
- alertInstance: services.alertFactory.create(alertId),
- throttle: completeRule.ruleConfig.throttle ?? '',
- startedAt,
- id: alertId,
- kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
- ?.kibana_siem_app_url,
- outputIndex: ruleDataClient.indexNameWithNamespace(spaceId),
- ruleId,
- esClient: services.scopedClusterClient.asCurrentUser,
- notificationRuleParams,
- signals: result.createdSignals,
- logger,
- });
- } else if (createdSignalsCount) {
- const alertInstance = services.alertFactory.create(alertId);
- scheduleNotificationActions({
- alertInstance,
- signalsCount: createdSignalsCount,
- signals: result.createdSignals,
- resultsLink,
- ruleParams: notificationRuleParams,
- });
- }
}
+ }
+ if (result.success) {
logger.debug(buildRuleMessage('[+] Signal Rule execution completed.'));
logger.debug(
buildRuleMessage(
@@ -392,23 +394,6 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
indexingDurations: result.bulkCreateTimes,
},
});
- // NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early
- if (completeRule.ruleConfig.throttle != null) {
- await scheduleThrottledNotificationActions({
- alertInstance: services.alertFactory.create(alertId),
- throttle: completeRule.ruleConfig.throttle ?? '',
- startedAt,
- id: completeRule.alertId,
- kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
- ?.kibana_siem_app_url,
- outputIndex: ruleDataClient.indexNameWithNamespace(spaceId),
- ruleId,
- esClient: services.scopedClusterClient.asCurrentUser,
- notificationRuleParams,
- signals: result.createdSignals,
- logger,
- });
- }
}
} catch (error) {
const errorMessage = error.message ?? '(no error message given)';
@@ -426,8 +411,9 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
indexingDurations: result.bulkCreateTimes,
},
});
+
// NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early
- if (completeRule.ruleConfig.throttle != null) {
+ if (actions.length && completeRule.ruleConfig.throttle != null) {
await scheduleThrottledNotificationActions({
alertInstance: services.alertFactory.create(alertId),
throttle: completeRule.ruleConfig.throttle ?? '',
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts
index 3b55d4a789fc0..15f7b0a2a54c8 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts
@@ -62,6 +62,8 @@ const allowlistBaseEventFields: AllowlistFields = {
directory: true,
hash: true,
Ext: {
+ compressed_bytes: true,
+ compressed_bytes_present: true,
code_signature: true,
header_bytes: true,
header_data: true,
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts
index a40c16a64966f..e28ef55b4881b 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts
@@ -289,6 +289,7 @@ export const prebuiltRuleAllowlistFields: AllowlistFields = {
},
},
// ml signal fields
+ influencers: true,
signal: {
ancestors: true,
depth: true,
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts
index d055f3843d479..dff3676c20c8a 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts
@@ -59,6 +59,8 @@ describe('TelemetryEventsSender', () => {
test: 'me',
another: 'nope',
Ext: {
+ compressed_bytes: 'data up to 4mb',
+ compressed_bytes_present: 'data up to 4mb',
code_signature: {
key1: 'X',
key2: 'Y',
@@ -131,6 +133,8 @@ describe('TelemetryEventsSender', () => {
created: 0,
path: 'X',
Ext: {
+ compressed_bytes: 'data up to 4mb',
+ compressed_bytes_present: 'data up to 4mb',
code_signature: {
key1: 'X',
key2: 'Y',
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts
index b5bc4622423e2..add586c6cb67f 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts
@@ -14,6 +14,7 @@ import {
EventFilterValidator,
TrustedAppValidator,
HostIsolationExceptionsValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreCreateItemServerExtension['callback'];
@@ -56,6 +57,14 @@ export const getExceptionsPreCreateItemHandler = (
return validatedItem;
}
+ // Validate blocklists
+ if (BlocklistValidator.isBlocklist(data)) {
+ const blocklistValidator = new BlocklistValidator(endpointAppContext, request);
+ const validatedItem = await blocklistValidator.validatePreCreateItem(data);
+ blocklistValidator.notifyFeatureUsage(data, 'BLOCKLIST_BY_POLICY');
+ return validatedItem;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts
index 37d2e9e774c6a..095e4b5631540 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts
@@ -12,6 +12,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreDeleteItemServerExtension['callback'];
@@ -57,6 +58,12 @@ export const getExceptionsPreDeleteItemHandler = (
return data;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreDeleteItem();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts
index 5750080d930e4..8067356532a3a 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts
@@ -11,6 +11,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreExportServerExtension['callback'];
@@ -38,6 +39,7 @@ export const getExceptionsPreExportHandler = (
await new TrustedAppValidator(endpointAppContextService, request).validatePreExport();
return data;
}
+
// Host Isolation Exceptions validations
if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) {
await new HostIsolationExceptionsValidator(
@@ -53,6 +55,12 @@ export const getExceptionsPreExportHandler = (
return data;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreExport();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts
index 31aeb330095fe..a21a99eea3a9d 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts
@@ -12,6 +12,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreGetOneItemServerExtension['callback'];
@@ -57,6 +58,12 @@ export const getExceptionsPreGetOneHandler = (
return data;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreGetOneItem();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts
index 323507dfb2b85..5cfe7311eb9e3 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts
@@ -11,6 +11,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreMultiListFindServerExtension['callback'];
@@ -21,6 +22,7 @@ export const getExceptionsPreMultiListFindHandler = (
if (!data.namespaceType.includes('agnostic')) {
return data;
}
+
// validate Trusted application
if (data.listId.some((id) => TrustedAppValidator.isTrustedApp({ listId: id }))) {
await new TrustedAppValidator(endpointAppContextService, request).validatePreMultiListFind();
@@ -46,6 +48,12 @@ export const getExceptionsPreMultiListFindHandler = (
return data;
}
+ // validate Blocklist
+ if (data.listId.some((id) => BlocklistValidator.isBlocklist({ listId: id }))) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreMultiListFind();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts
index c33ae013b2099..917e6c97b1bfd 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts
@@ -11,6 +11,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback'];
@@ -24,7 +25,7 @@ export const getExceptionsPreSingleListFindHandler = (
const { listId } = data;
- // Validate Host Isolation Exceptions
+ // Validate Trusted applications
if (TrustedAppValidator.isTrustedApp({ listId })) {
await new TrustedAppValidator(endpointAppContextService, request).validatePreSingleListFind();
return data;
@@ -48,6 +49,12 @@ export const getExceptionsPreSingleListFindHandler = (
return data;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreSingleListFind();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts
index c250979058962..93c1abdcb7d7a 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts
@@ -11,6 +11,7 @@ import {
TrustedAppValidator,
HostIsolationExceptionsValidator,
EventFilterValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback'];
@@ -38,6 +39,7 @@ export const getExceptionsPreSummaryHandler = (
await new TrustedAppValidator(endpointAppContextService, request).validatePreGetListSummary();
return data;
}
+
// Host Isolation Exceptions
if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) {
await new HostIsolationExceptionsValidator(
@@ -53,6 +55,12 @@ export const getExceptionsPreSummaryHandler = (
return data;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ await new BlocklistValidator(endpointAppContextService, request).validatePreGetListSummary();
+ return data;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts
index 67b2e5cc03efe..acedbf7d1ed25 100644
--- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts
@@ -15,6 +15,7 @@ import {
EventFilterValidator,
TrustedAppValidator,
HostIsolationExceptionsValidator,
+ BlocklistValidator,
} from '../validators';
type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback'];
@@ -86,6 +87,17 @@ export const getExceptionsPreUpdateItemHandler = (
return validatedItem;
}
+ // Validate Blocklists
+ if (BlocklistValidator.isBlocklist({ listId })) {
+ const blocklistValidator = new BlocklistValidator(endpointAppContextService, request);
+ const validatedItem = await blocklistValidator.validatePreUpdateItem(data, currentSavedItem);
+ blocklistValidator.notifyFeatureUsage(
+ data as ExceptionItemLikeOptions,
+ 'BLOCKLIST_BY_POLICY'
+ );
+ return validatedItem;
+ }
+
return data;
};
};
diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts
new file mode 100644
index 0000000000000..e51190467aee4
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts
@@ -0,0 +1,279 @@
+/*
+ * 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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants';
+import { schema, Type, TypeOf } from '@kbn/config-schema';
+import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
+import { OperatingSystem } from '@kbn/securitysolution-utils';
+import { BaseValidator } from './base_validator';
+import { ExceptionItemLikeOptions } from '../types';
+import {
+ CreateExceptionListItemOptions,
+ UpdateExceptionListItemOptions,
+} from '../../../../../lists/server';
+import { isValidHash } from '../../../../common/endpoint/service/trusted_apps/validations';
+import { EndpointArtifactExceptionValidationError } from './errors';
+
+const allowedHashes: Readonly = ['file.hash.md5', 'file.hash.sha1', 'file.hash.sha256'];
+
+const FileHashField = schema.oneOf(
+ allowedHashes.map((hash) => schema.literal(hash)) as [Type]
+);
+
+const FilePath = schema.literal('file.path');
+const FileCodeSigner = schema.literal('file.Ext.code_signature');
+
+const ConditionEntryTypeSchema = schema.literal('match_any');
+const ConditionEntryOperatorSchema = schema.literal('included');
+
+type ConditionEntryFieldAllowedType =
+ | TypeOf
+ | TypeOf
+ | TypeOf;
+
+type BlocklistConditionEntry =
+ | {
+ field: ConditionEntryFieldAllowedType;
+ type: 'match_any';
+ operator: 'included';
+ value: string[];
+ }
+ | TypeOf;
+
+/*
+ * A generic Entry schema to be used for a specific entry schema depending on the OS
+ */
+const CommonEntrySchema = {
+ field: schema.oneOf([FileHashField, FilePath]),
+ type: ConditionEntryTypeSchema,
+ operator: ConditionEntryOperatorSchema,
+ // If field === HASH then validate hash with custom method, else validate string with minLength = 1
+ value: schema.conditional(
+ schema.siblingRef('field'),
+ FileHashField,
+ schema.arrayOf(
+ schema.string({
+ validate: (hash: string) =>
+ isValidHash(hash) ? undefined : `invalid hash value [${hash}]`,
+ }),
+ { minSize: 1 }
+ ),
+ schema.conditional(
+ schema.siblingRef('field'),
+ FilePath,
+ schema.arrayOf(
+ schema.string({
+ validate: (pathValue: string) =>
+ pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`,
+ }),
+ { minSize: 1 }
+ ),
+ schema.arrayOf(
+ schema.string({
+ validate: (signerValue: string) =>
+ signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`,
+ }),
+ { minSize: 1 }
+ )
+ )
+ ),
+};
+
+// Windows Signer entries use a Nested field that checks to ensure
+// that the certificate is trusted
+const WindowsSignerEntrySchema = schema.object({
+ type: schema.literal('nested'),
+ field: FileCodeSigner,
+ entries: schema.arrayOf(
+ schema.object({
+ field: schema.literal('subject_name'),
+ value: schema.arrayOf(schema.string({ minLength: 1 })),
+ type: schema.literal('match_any'),
+ operator: schema.literal('included'),
+ }),
+ { minSize: 1 }
+ ),
+});
+
+const WindowsEntrySchema = schema.oneOf([
+ WindowsSignerEntrySchema,
+ schema.object({
+ ...CommonEntrySchema,
+ field: schema.oneOf([FileHashField, FilePath]),
+ }),
+]);
+
+const LinuxEntrySchema = schema.object({
+ ...CommonEntrySchema,
+});
+
+const MacEntrySchema = schema.object({
+ ...CommonEntrySchema,
+});
+
+// Hash entries validator method.
+const hashEntriesValidation = (entries: BlocklistConditionEntry[]) => {
+ const currentHashes = entries.map((entry) => entry.field);
+ // If there are more hashes than allowed (three) then return an error
+ if (currentHashes.length > allowedHashes.length) {
+ const allowedHashesMessage = allowedHashes
+ .map((hash) => hash.replace('file.hash.', ''))
+ .join(',');
+ return `There are more hash types than allowed [${allowedHashesMessage}]`;
+ }
+
+ const hashesCount: { [key: string]: boolean } = {};
+ const duplicatedHashes: string[] = [];
+ const invalidHash: string[] = [];
+
+ // Check hash entries individually
+ currentHashes.forEach((hash) => {
+ if (!allowedHashes.includes(hash)) invalidHash.push(hash);
+ if (hashesCount[hash]) {
+ duplicatedHashes.push(hash);
+ } else {
+ hashesCount[hash] = true;
+ }
+ });
+
+ // There is more than one entry with the same hash type
+ if (duplicatedHashes.length) {
+ return `There are some duplicated hashes: ${duplicatedHashes.join(',')}`;
+ }
+
+ // There is an entry with an invalid hash type
+ if (invalidHash.length) {
+ return `There are some invalid fields for hash type: ${invalidHash.join(',')}`;
+ }
+};
+
+// Validate there is only one entry when signer or path and the allowed entries for hashes
+const entriesSchemaOptions = {
+ minSize: 1,
+ validate(entries: BlocklistConditionEntry[]) {
+ if (allowedHashes.includes(entries[0].field)) {
+ return hashEntriesValidation(entries);
+ } else {
+ if (entries.length > 1) {
+ return 'Only one entry is allowed when no using hash field type';
+ }
+ }
+ },
+};
+
+/*
+ * Entities array schema depending on Os type using schema.conditional.
+ * If OS === WINDOWS then use Windows schema,
+ * else if OS === LINUX then use Linux schema,
+ * else use Mac schema
+ *
+ * The validate function checks there is only one item for entries excepts for hash
+ */
+const EntriesSchema = schema.conditional(
+ schema.contextRef('os'),
+ OperatingSystem.WINDOWS,
+ schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions),
+ schema.conditional(
+ schema.contextRef('os'),
+ OperatingSystem.LINUX,
+ schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions),
+ schema.arrayOf(MacEntrySchema, entriesSchemaOptions)
+ )
+);
+
+/**
+ * Schema to validate Blocklist data for create and update.
+ * When called, it must be given an `context` with a `os` property set
+ *
+ * @example
+ *
+ * BlocklistDataSchema.validate(item, { os: 'windows' });
+ */
+const BlocklistDataSchema = schema.object(
+ {
+ entries: EntriesSchema,
+ },
+
+ // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here
+ { unknowns: 'ignore' }
+);
+
+export class BlocklistValidator extends BaseValidator {
+ static isBlocklist(item: { listId: string }): boolean {
+ return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID;
+ }
+
+ async validatePreCreateItem(
+ item: CreateExceptionListItemOptions
+ ): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ await this.validateBlocklistData(item);
+ await this.validateCanCreateByPolicyArtifacts(item);
+ await this.validateByPolicyItem(item);
+
+ return item;
+ }
+
+ async validatePreDeleteItem(): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ }
+
+ async validatePreGetOneItem(): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ }
+
+ async validatePreMultiListFind(): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ }
+
+ async validatePreExport(): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ }
+
+ async validatePreSingleListFind(): Promise {
+ await this.validateCanManageEndpointArtifacts();
+ }
+
+ async validatePreGetListSummary(): Promise