diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage
index f2a58e7b6a7ac..c474998e6fd3d 100644
--- a/.ci/Jenkinsfile_coverage
+++ b/.ci/Jenkinsfile_coverage
@@ -3,7 +3,7 @@
library 'kibana-pipeline-library'
kibanaLibrary.load() // load from the Jenkins instance
-kibanaPipeline(timeoutMinutes: 180) {
+kibanaPipeline(timeoutMinutes: 240) {
catchErrors {
withEnv([
'CODE_COVERAGE=1', // Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc.
diff --git a/.eslintignore b/.eslintignore
index 2eaa498f86e5a..2ed9ecf971ff3 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,45 +1,48 @@
-node_modules
-bower_components
-/data
-/optimize
-/build
-/target
+**/*.js.snap
+**/graphql/types.ts
/.es
-/plugins
+/build
/built_assets
+/data
/html_docs
-/src/plugins/data/common/es_query/kuery/ast/_generated_/**
-/src/plugins/vis_type_timelion/public/_generated_/**
-/src/legacy/ui/public/flot-charts
+/optimize
+/plugins
/test/fixtures/scenarios
-/src/legacy/core_plugins/console/public/webpackShims
+/x-pack/build
+node_modules
+target
+
+!/.eslintrc.js
+
+# plugin overrides
+/src/core/lib/kbn_internal_native_observable
/src/legacy/core_plugins/console/public/tests/webpackShims
+/src/legacy/core_plugins/console/public/webpackShims
+/src/legacy/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken
+/src/legacy/ui/public/flot-charts
/src/legacy/ui/public/utils/decode_geo_hash.js
+/src/plugins/data/common/es_query/kuery/ast/_generated_/**
+/src/plugins/vis_type_timelion/public/_generated_/**
/src/plugins/vis_type_timelion/public/webpackShims/jquery.flot.*
-/src/core/lib/kbn_internal_native_observable
-/packages/*/target
-/packages/eslint-config-kibana
-/packages/kbn-pm/dist
-/packages/kbn-plugin-generator/sao_template/template
-/packages/kbn-ui-framework/dist
-/packages/kbn-ui-framework/doc_site/build
-/packages/kbn-ui-framework/generator-kui/*/templates/
-/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/
-/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/
-/x-pack/legacy/plugins/maps/public/vendor/**
-/x-pack/coverage
-/x-pack/build
/x-pack/legacy/plugins/**/__tests__/fixtures/**
-/packages/kbn-interpreter/src/common/lib/grammar.js
+/x-pack/legacy/plugins/apm/e2e/cypress/**/snapshots.js
/x-pack/legacy/plugins/canvas/canvas_plugin
+/x-pack/legacy/plugins/canvas/canvas_plugin_src/lib/flot-charts
/x-pack/legacy/plugins/canvas/shareable_runtime/build
/x-pack/legacy/plugins/canvas/storybook
-/x-pack/legacy/plugins/canvas/canvas_plugin_src/lib/flot-charts
/x-pack/legacy/plugins/infra/common/graphql/types.ts
/x-pack/legacy/plugins/infra/public/graphql/types.ts
/x-pack/legacy/plugins/infra/server/graphql/types.ts
-/x-pack/legacy/plugins/apm/e2e/cypress/**/snapshots.js
-/src/legacy/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken
-**/graphql/types.ts
-**/*.js.snap
-!/.eslintrc.js
+/x-pack/legacy/plugins/maps/public/vendor/**
+
+# package overrides
+/packages/eslint-config-kibana
+/packages/kbn-interpreter/src/common/lib/grammar.js
+/packages/kbn-plugin-generator/sao_template/template
+/packages/kbn-pm/dist
+/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/
+/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/
+/packages/kbn-ui-framework/dist
+/packages/kbn-ui-framework/doc_site/build
+/packages/kbn-ui-framework/generator-kui/*/templates/
+
diff --git a/.eslintrc.js b/.eslintrc.js
index e45a2a96f29d7..c9b41ec711b7f 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -742,6 +742,101 @@ module.exports = {
},
},
+ /**
+ * Lists overrides
+ */
+ {
+ // typescript and javascript for front and back end
+ files: ['x-pack/plugins/lists/**/*.{js,ts,tsx}'],
+ plugins: ['eslint-plugin-node'],
+ env: {
+ mocha: true,
+ jest: true,
+ },
+ rules: {
+ 'accessor-pairs': 'error',
+ 'array-callback-return': 'error',
+ 'no-array-constructor': 'error',
+ complexity: 'error',
+ 'consistent-return': 'error',
+ 'func-style': ['error', 'expression'],
+ 'import/order': [
+ 'error',
+ {
+ groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
+ 'newlines-between': 'always',
+ },
+ ],
+ 'sort-imports': [
+ 'error',
+ {
+ ignoreDeclarationSort: true,
+ },
+ ],
+ 'node/no-deprecated-api': 'error',
+ 'no-bitwise': 'error',
+ 'no-continue': 'error',
+ 'no-dupe-keys': 'error',
+ 'no-duplicate-case': 'error',
+ 'no-duplicate-imports': 'error',
+ 'no-empty-character-class': 'error',
+ 'no-empty-pattern': 'error',
+ 'no-ex-assign': 'error',
+ 'no-extend-native': 'error',
+ 'no-extra-bind': 'error',
+ 'no-extra-boolean-cast': 'error',
+ 'no-extra-label': 'error',
+ 'no-func-assign': 'error',
+ 'no-implicit-globals': 'error',
+ 'no-implied-eval': 'error',
+ 'no-invalid-regexp': 'error',
+ 'no-inner-declarations': 'error',
+ 'no-lone-blocks': 'error',
+ 'no-multi-assign': 'error',
+ 'no-misleading-character-class': 'error',
+ 'no-new-symbol': 'error',
+ 'no-obj-calls': 'error',
+ 'no-param-reassign': ['error', { props: true }],
+ 'no-process-exit': 'error',
+ 'no-prototype-builtins': 'error',
+ 'no-return-await': 'error',
+ 'no-self-compare': 'error',
+ 'no-shadow-restricted-names': 'error',
+ 'no-sparse-arrays': 'error',
+ 'no-this-before-super': 'error',
+ 'no-undef': 'error',
+ 'no-unreachable': 'error',
+ 'no-unsafe-finally': 'error',
+ 'no-useless-call': 'error',
+ 'no-useless-catch': 'error',
+ 'no-useless-concat': 'error',
+ 'no-useless-computed-key': 'error',
+ 'no-useless-escape': 'error',
+ 'no-useless-rename': 'error',
+ 'no-useless-return': 'error',
+ 'no-void': 'error',
+ 'one-var-declaration-per-line': 'error',
+ 'prefer-object-spread': 'error',
+ 'prefer-promise-reject-errors': 'error',
+ 'prefer-rest-params': 'error',
+ 'prefer-spread': 'error',
+ 'prefer-template': 'error',
+ 'require-atomic-updates': 'error',
+ 'symbol-description': 'error',
+ 'vars-on-top': 'error',
+ '@typescript-eslint/explicit-member-accessibility': 'error',
+ '@typescript-eslint/no-this-alias': 'error',
+ '@typescript-eslint/no-explicit-any': 'error',
+ '@typescript-eslint/no-useless-constructor': 'error',
+ '@typescript-eslint/unified-signatures': 'error',
+ '@typescript-eslint/explicit-function-return-type': 'error',
+ '@typescript-eslint/no-non-null-assertion': 'error',
+ '@typescript-eslint/no-unused-vars': 'error',
+ 'no-template-curly-in-string': 'error',
+ 'sort-keys': 'error',
+ 'prefer-destructuring': 'error',
+ },
+ },
/**
* Alerting Services overrides
*/
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 4a16952f82014..a97400ee09c0e 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -84,7 +84,6 @@
/x-pack/legacy/plugins/ingest_manager/ @elastic/ingest-management
/x-pack/plugins/observability/ @elastic/logs-metrics-ui @elastic/apm-ui @elastic/uptime @elastic/ingest-management
/x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui
-/x-pack/legacy/plugins/uptime @elastic/uptime
/x-pack/plugins/uptime @elastic/uptime
# Machine Learning
@@ -167,8 +166,6 @@
/x-pack/plugins/telemetry_collection_xpack/ @elastic/pulse
# Kibana Alerting Services
-/x-pack/legacy/plugins/alerting/ @elastic/kibana-alerting-services
-/x-pack/legacy/plugins/actions/ @elastic/kibana-alerting-services
/x-pack/plugins/alerting/ @elastic/kibana-alerting-services
/x-pack/plugins/actions/ @elastic/kibana-alerting-services
/x-pack/plugins/event_log/ @elastic/kibana-alerting-services
@@ -176,7 +173,6 @@
/x-pack/test/alerting_api_integration/ @elastic/kibana-alerting-services
/x-pack/test/plugin_api_integration/plugins/task_manager/ @elastic/kibana-alerting-services
/x-pack/test/plugin_api_integration/test_suites/task_manager/ @elastic/kibana-alerting-services
-/x-pack/legacy/plugins/triggers_actions_ui/ @elastic/kibana-alerting-services
/x-pack/plugins/triggers_actions_ui/ @elastic/kibana-alerting-services
/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/kibana-alerting-services
/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/kibana-alerting-services
@@ -209,22 +205,21 @@
/x-pack/plugins/watcher/ @elastic/es-ui
# Endpoint
-/x-pack/plugins/endpoint/ @elastic/endpoint-app-team
-/x-pack/test/api_integration/apis/endpoint/ @elastic/endpoint-app-team
-/x-pack/test/endpoint_api_integration_no_ingest/ @elastic/endpoint-app-team
-/x-pack/test/functional_endpoint/ @elastic/endpoint-app-team
-/x-pack/test/functional_endpoint_ingest_failure/ @elastic/endpoint-app-team
-/x-pack/test/functional/es_archives/endpoint/ @elastic/endpoint-app-team
-/x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/endpoint-app-team
-/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/endpoint-app-team
+/x-pack/plugins/endpoint/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/api_integration/apis/endpoint/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/endpoint_api_integration_no_ingest/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/functional_endpoint/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/functional_endpoint_ingest_failure/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/functional/es_archives/endpoint/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/endpoint-app-team @elastic/siem
+/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/endpoint-app-team @elastic/siem
# SIEM
-/x-pack/legacy/plugins/siem/ @elastic/siem
-/x-pack/plugins/siem/ @elastic/siem
-/x-pack/test/detection_engine_api_integration @elastic/siem
-/x-pack/test/api_integration/apis/siem @elastic/siem
-/x-pack/plugins/case @elastic/siem
+/x-pack/plugins/siem/ @elastic/siem @elastic/endpoint-app-team
+/x-pack/test/detection_engine_api_integration @elastic/siem @elastic/endpoint-app-team
+/x-pack/test/api_integration/apis/siem @elastic/siem @elastic/endpoint-app-team
+/x-pack/plugins/case @elastic/siem @elastic/endpoint-app-team
+/x-pack/plugins/lists @elastic/siem @elastic/endpoint-app-team
# Security Intelligence And Analytics
-/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics
/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics
diff --git a/docs/development/core/public/kibana-plugin-core-public.appbase.defaultpath.md b/docs/development/core/public/kibana-plugin-core-public.appbase.defaultpath.md
new file mode 100644
index 0000000000000..51492756ef232
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.appbase.defaultpath.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppBase](./kibana-plugin-core-public.appbase.md) > [defaultPath](./kibana-plugin-core-public.appbase.defaultpath.md)
+
+## AppBase.defaultPath property
+
+Allow to define the default path a user should be directed to when navigating to the app. When defined, this value will be used as a default for the `path` option when calling [navigateToApp](./kibana-plugin-core-public.applicationstart.navigatetoapp.md)\`, and will also be appended to the [application navLink](./kibana-plugin-core-public.chromenavlink.md) in the navigation bar.
+
+Signature:
+
+```typescript
+defaultPath?: string;
+```
diff --git a/docs/development/core/public/kibana-plugin-core-public.appbase.md b/docs/development/core/public/kibana-plugin-core-public.appbase.md
index b73785647f23c..7b624f12ac1df 100644
--- a/docs/development/core/public/kibana-plugin-core-public.appbase.md
+++ b/docs/development/core/public/kibana-plugin-core-public.appbase.md
@@ -18,6 +18,7 @@ export interface AppBase
| [capabilities](./kibana-plugin-core-public.appbase.capabilities.md) | Partial<Capabilities>
| Custom capabilities defined by the app. |
| [category](./kibana-plugin-core-public.appbase.category.md) | AppCategory
| The category definition of the product See [AppCategory](./kibana-plugin-core-public.appcategory.md) See DEFAULT\_APP\_CATEGORIES for more reference |
| [chromeless](./kibana-plugin-core-public.appbase.chromeless.md) | boolean
| Hide the UI chrome when the application is mounted. Defaults to false
. Takes precedence over chrome service visibility settings. |
+| [defaultPath](./kibana-plugin-core-public.appbase.defaultpath.md) | string
| Allow to define the default path a user should be directed to when navigating to the app. When defined, this value will be used as a default for the path
option when calling [navigateToApp](./kibana-plugin-core-public.applicationstart.navigatetoapp.md)\`, and will also be appended to the [application navLink](./kibana-plugin-core-public.chromenavlink.md) in the navigation bar. |
| [euiIconType](./kibana-plugin-core-public.appbase.euiicontype.md) | string
| A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon
property. |
| [icon](./kibana-plugin-core-public.appbase.icon.md) | string
| A URL to an image file used as an icon. Used as a fallback if euiIconType
is not provided. |
| [id](./kibana-plugin-core-public.appbase.id.md) | string
| The unique identifier of the application |
diff --git a/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md
index cdf9171a46aed..3d8b5d115c8a2 100644
--- a/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md
+++ b/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md
@@ -9,5 +9,5 @@ Defines the list of fields that can be updated via an [AppUpdater](./kibana-plug
Signature:
```typescript
-export declare type AppUpdatableFields = Pick;
+export declare type AppUpdatableFields = Pick;
```
diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md
index 1cc1a1194a537..a9fabb38df869 100644
--- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md
+++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md
@@ -29,5 +29,5 @@ export interface ChromeNavLink
| [subUrlBase](./kibana-plugin-core-public.chromenavlink.suburlbase.md) | string
| A url base that legacy apps can set to match deep URLs to an application. |
| [title](./kibana-plugin-core-public.chromenavlink.title.md) | string
| The title of the application. |
| [tooltip](./kibana-plugin-core-public.chromenavlink.tooltip.md) | string
| A tooltip shown when hovering over an app link. |
-| [url](./kibana-plugin-core-public.chromenavlink.url.md) | string
| A url that legacy apps can set to deep link into their applications. |
+| [url](./kibana-plugin-core-public.chromenavlink.url.md) | string
| The route used to open the [default path](./kibana-plugin-core-public.appbase.defaultpath.md) of an application. If unset, baseUrl
will be used instead. |
diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.url.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.url.md
index 0c415ed1a7fad..1e0b890015993 100644
--- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.url.md
+++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.url.md
@@ -4,11 +4,7 @@
## ChromeNavLink.url property
-> Warning: This API is now obsolete.
->
->
-
-A url that legacy apps can set to deep link into their applications.
+The route used to open the [default path](./kibana-plugin-core-public.appbase.defaultpath.md) of an application. If unset, `baseUrl` will be used instead.
Signature:
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationfn.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationfn.md
index a502c40db0cd8..a3294fb0a087a 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationfn.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationfn.md
@@ -9,22 +9,36 @@ A migration function for a [saved object type](./kibana-plugin-core-server.saved
Signature:
```typescript
-export declare type SavedObjectMigrationFn = (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;
+export declare type SavedObjectMigrationFn = (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;
```
## Example
```typescript
-const migrateProperty: SavedObjectMigrationFn = (doc, { log }) => {
- if(doc.attributes.someProp === null) {
- log.warn('Skipping migration');
- } else {
- doc.attributes.someProp = migrateProperty(doc.attributes.someProp);
- }
-
- return doc;
+interface TypeV1Attributes {
+ someKey: string;
+ obsoleteProperty: number;
}
+interface TypeV2Attributes {
+ someKey: string;
+ newProperty: string;
+}
+
+const migrateToV2: SavedObjectMigrationFn = (doc, { log }) => {
+ const { obsoleteProperty, ...otherAttributes } = doc.attributes;
+ // instead of mutating `doc` we make a shallow copy so that we can use separate types for the input
+ // and output attributes. We don't need to make a deep copy, we just need to ensure that obsolete
+ // attributes are not present on the returned doc.
+ return {
+ ...doc,
+ attributes: {
+ ...otherAttributes,
+ newProperty: migrate(obsoleteProperty),
+ },
+ };
+};
+
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md
index 6d4e252fe7532..3f4090619edbf 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md
@@ -9,5 +9,5 @@ Describes Saved Object documents that have passed through the migration framewor
Signature:
```typescript
-export declare type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
+export declare type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md
index be51400addbbc..8e2395ee6310d 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md
@@ -9,5 +9,5 @@ Describes Saved Object documents from Kibana < 7.0.0 which don't have a `refe
Signature:
```typescript
-export declare type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
+export declare type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
```
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md
index 04a0d871cab2d..3969a97fa7789 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md
@@ -7,7 +7,10 @@
Signature:
```typescript
-export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined;
+export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: {
+ forceNow?: Date;
+ fieldName?: string;
+}): import("../..").RangeFilter | undefined;
```
## Parameters
@@ -16,7 +19,7 @@ export declare function getTime(indexPattern: IIndexPattern | undefined, timeRan
| --- | --- | --- |
| indexPattern | IIndexPattern | undefined
| |
| timeRange | TimeRange
| |
-| forceNow | Date
| |
+| options | {
forceNow?: Date;
fieldName?: string;
}
| |
Returns:
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md
new file mode 100644
index 0000000000000..c3998876c9712
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [getTimeField](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md)
+
+## IIndexPattern.getTimeField() method
+
+Signature:
+
+```typescript
+getTimeField?(): IFieldType | undefined;
+```
+Returns:
+
+`IFieldType | undefined`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md
index 1bbd6cf67f0ce..1cb89822eb605 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md
@@ -21,3 +21,9 @@ export interface IIndexPattern
| [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | string
| |
| [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | string
| |
+## Methods
+
+| Method | Description |
+| --- | --- |
+| [getTimeField()](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md) | |
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md
index 0fd82ffb2240c..e1df493143b73 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md
@@ -43,7 +43,7 @@
| [getEsPreference(uiSettings, sessionId)](./kibana-plugin-plugins-data-public.getespreference.md) | |
| [getQueryLog(uiSettings, storage, appName, language)](./kibana-plugin-plugins-data-public.getquerylog.md) | |
| [getSearchErrorType({ message })](./kibana-plugin-plugins-data-public.getsearcherrortype.md) | |
-| [getTime(indexPattern, timeRange, forceNow)](./kibana-plugin-plugins-data-public.gettime.md) | |
+| [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | |
| [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | |
## Interfaces
diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts
index 7411e2df1b613..cc3fa8c2720de 100644
--- a/packages/kbn-optimizer/src/worker/webpack.config.ts
+++ b/packages/kbn-optimizer/src/worker/webpack.config.ts
@@ -37,7 +37,7 @@ const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'
const BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset');
const STATIC_BUNDLE_PLUGINS = [
- // { id: 'data', dirname: 'data' },
+ { id: 'data', dirname: 'data' },
{ id: 'kibanaReact', dirname: 'kibana_react' },
{ id: 'kibanaUtils', dirname: 'kibana_utils' },
{ id: 'esUiShared', dirname: 'es_ui_shared' },
@@ -60,13 +60,8 @@ function dynamicExternals(bundle: Bundle, context: string, request: string) {
return;
}
- // don't allow any static bundle to rely on other static bundles
- if (STATIC_BUNDLE_PLUGINS.some(p => bundle.id === p.id)) {
- return;
- }
-
- // ignore requests that don't include a /data/public, /kibana_react/public, or
- // /kibana_utils/public segment as a cheap way to avoid doing path resolution
+ // ignore requests that don't include a /{dirname}/public for one of our
+ // "static" bundles as a cheap way to avoid doing path resolution
// for paths that couldn't possibly resolve to what we're looking for
const reqToStaticBundle = STATIC_BUNDLE_PLUGINS.some(p =>
request.includes(`/${p.dirname}/public`)
diff --git a/renovate.json5 b/renovate.json5
index ffa006264873d..c0ddcaf4f23c8 100644
--- a/renovate.json5
+++ b/renovate.json5
@@ -846,6 +846,14 @@
'@types/semver',
],
},
+ {
+ groupSlug: 'set-value',
+ groupName: 'set-value related packages',
+ packageNames: [
+ 'set-value',
+ '@types/set-value',
+ ],
+ },
{
groupSlug: 'sinon',
groupName: 'sinon related packages',
diff --git a/src/core/MIGRATION_EXAMPLES.md b/src/core/MIGRATION_EXAMPLES.md
index 8c5fe4875aaea..c91c00bc1aa02 100644
--- a/src/core/MIGRATION_EXAMPLES.md
+++ b/src/core/MIGRATION_EXAMPLES.md
@@ -957,7 +957,7 @@ const migration = (doc, log) => {...}
Would be converted to:
```typescript
-const migration: SavedObjectMigrationFn = (doc, { log }) => {...}
+const migration: SavedObjectMigrationFn = (doc, { log }) => {...}
```
### Remarks
diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts
index c25918c6b7328..e29837aecb125 100644
--- a/src/core/public/application/application_service.test.ts
+++ b/src/core/public/application/application_service.test.ts
@@ -87,7 +87,7 @@ describe('#setup()', () => {
).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`);
});
- it('allows to register a statusUpdater for the application', async () => {
+ it('allows to register an AppUpdater for the application', async () => {
const setup = service.setup(setupDeps);
const pluginId = Symbol('plugin');
@@ -118,6 +118,7 @@ describe('#setup()', () => {
updater$.next(app => ({
status: AppStatus.inaccessible,
tooltip: 'App inaccessible due to reason',
+ defaultPath: 'foo/bar',
}));
applications = await applications$.pipe(take(1)).toPromise();
@@ -128,6 +129,7 @@ describe('#setup()', () => {
legacy: false,
navLinkStatus: AppNavLinkStatus.default,
status: AppStatus.inaccessible,
+ defaultPath: 'foo/bar',
tooltip: 'App inaccessible due to reason',
})
);
@@ -209,7 +211,7 @@ describe('#setup()', () => {
});
});
- describe('registerAppStatusUpdater', () => {
+ describe('registerAppUpdater', () => {
it('updates status fields', async () => {
const setup = service.setup(setupDeps);
@@ -413,6 +415,36 @@ describe('#setup()', () => {
})
);
});
+
+ it('allows to update the basePath', async () => {
+ const setup = service.setup(setupDeps);
+
+ const pluginId = Symbol('plugin');
+ setup.register(pluginId, createApp({ id: 'app1' }));
+
+ const updater = new BehaviorSubject(app => ({}));
+ setup.registerAppUpdater(updater);
+
+ const start = await service.start(startDeps);
+ await start.navigateToApp('app1');
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined);
+ MockHistory.push.mockClear();
+
+ updater.next(app => ({ defaultPath: 'default-path' }));
+ await start.navigateToApp('app1');
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/default-path', undefined);
+ MockHistory.push.mockClear();
+
+ updater.next(app => ({ defaultPath: 'another-path' }));
+ await start.navigateToApp('app1');
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/another-path', undefined);
+ MockHistory.push.mockClear();
+
+ updater.next(app => ({}));
+ await start.navigateToApp('app1');
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined);
+ MockHistory.push.mockClear();
+ });
});
it("`registerMountContext` calls context container's registerContext", () => {
@@ -676,6 +708,57 @@ describe('#start()', () => {
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path#/hash/router/path', undefined);
});
+ it('preserves trailing slash when path contains a hash', async () => {
+ const { register } = service.setup(setupDeps);
+
+ register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/app-path' }));
+
+ const { navigateToApp } = await service.start(startDeps);
+ await navigateToApp('app2', { path: '#/' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path#/', undefined);
+ MockHistory.push.mockClear();
+
+ await navigateToApp('app2', { path: '#/foo/bar/' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path#/foo/bar/', undefined);
+ MockHistory.push.mockClear();
+
+ await navigateToApp('app2', { path: '/path#/' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path#/', undefined);
+ MockHistory.push.mockClear();
+
+ await navigateToApp('app2', { path: '/path#/hash/' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path#/hash/', undefined);
+ MockHistory.push.mockClear();
+
+ await navigateToApp('app2', { path: '/path/' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path', undefined);
+ MockHistory.push.mockClear();
+ });
+
+ it('appends the defaultPath when the path parameter is not specified', async () => {
+ const { register } = service.setup(setupDeps);
+
+ register(Symbol(), createApp({ id: 'app1', defaultPath: 'default/path' }));
+ register(
+ Symbol(),
+ createApp({ id: 'app2', appRoute: '/custom-app-path', defaultPath: '/my-base' })
+ );
+
+ const { navigateToApp } = await service.start(startDeps);
+
+ await navigateToApp('app1', { path: 'defined-path' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/defined-path', undefined);
+
+ await navigateToApp('app1', {});
+ expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/default/path', undefined);
+
+ await navigateToApp('app2', { path: 'defined-path' });
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom-app-path/defined-path', undefined);
+
+ await navigateToApp('app2', {});
+ expect(MockHistory.push).toHaveBeenCalledWith('/custom-app-path/my-base', undefined);
+ });
+
it('includes state if specified', async () => {
const { register } = service.setup(setupDeps);
diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx
index 1c9492d81c7f6..bafa1932e5e92 100644
--- a/src/core/public/application/application_service.tsx
+++ b/src/core/public/application/application_service.tsx
@@ -46,6 +46,7 @@ import {
Mounter,
} from './types';
import { getLeaveAction, isConfirmAction } from './application_leave';
+import { appendAppPath } from './utils';
interface SetupDeps {
context: ContextSetup;
@@ -81,13 +82,7 @@ const getAppUrl = (mounters: Map, appId: string, path: string =
const appBasePath = mounters.get(appId)?.appRoute
? `/${mounters.get(appId)!.appRoute}`
: `/app/${appId}`;
-
- // Only preppend slash if not a hash or query path
- path = path.startsWith('#') || path.startsWith('?') ? path : `/${path}`;
-
- return `${appBasePath}${path}`
- .replace(/\/{2,}/g, '/') // Remove duplicate slashes
- .replace(/\/$/, ''); // Remove trailing slash
+ return appendAppPath(appBasePath, path);
};
const allApplicationsFilter = '__ALL__';
@@ -290,6 +285,9 @@ export class ApplicationService {
},
navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => {
if (await this.shouldNavigate(overlays)) {
+ if (path === undefined) {
+ path = applications$.value.get(appId)?.defaultPath;
+ }
this.appLeaveHandlers.delete(this.currentAppId$.value!);
this.navigate!(getAppUrl(availableMounters, appId, path), state);
this.currentAppId$.next(appId);
diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts
index 318afb652999e..0734e178033e2 100644
--- a/src/core/public/application/types.ts
+++ b/src/core/public/application/types.ts
@@ -66,6 +66,13 @@ export interface AppBase {
*/
navLinkStatus?: AppNavLinkStatus;
+ /**
+ * Allow to define the default path a user should be directed to when navigating to the app.
+ * When defined, this value will be used as a default for the `path` option when calling {@link ApplicationStart.navigateToApp | navigateToApp}`,
+ * and will also be appended to the {@link ChromeNavLink | application navLink} in the navigation bar.
+ */
+ defaultPath?: string;
+
/**
* An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime.
*
@@ -187,7 +194,10 @@ export enum AppNavLinkStatus {
* Defines the list of fields that can be updated via an {@link AppUpdater}.
* @public
*/
-export type AppUpdatableFields = Pick;
+export type AppUpdatableFields = Pick<
+ AppBase,
+ 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'
+>;
/**
* Updater for applications.
@@ -642,7 +652,8 @@ export interface ApplicationStart {
* Navigate to a given app
*
* @param appId
- * @param options.path - optional path inside application to deep link to
+ * @param options.path - optional path inside application to deep link to.
+ * If undefined, will use {@link AppBase.defaultPath | the app's default path}` as default.
* @param options.state - optional state to forward to the application
*/
navigateToApp(appId: string, options?: { path?: string; state?: any }): Promise;
diff --git a/src/core/public/application/utils.test.ts b/src/core/public/application/utils.test.ts
new file mode 100644
index 0000000000000..7ed0919f88c61
--- /dev/null
+++ b/src/core/public/application/utils.test.ts
@@ -0,0 +1,71 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { removeSlashes, appendAppPath } from './utils';
+
+describe('removeSlashes', () => {
+ it('only removes duplicates by default', () => {
+ expect(removeSlashes('/some//url//to//')).toEqual('/some/url/to/');
+ expect(removeSlashes('some/////other//url')).toEqual('some/other/url');
+ });
+
+ it('remove trailing slash when `trailing` is true', () => {
+ expect(removeSlashes('/some//url//to//', { trailing: true })).toEqual('/some/url/to');
+ });
+
+ it('remove leading slash when `leading` is true', () => {
+ expect(removeSlashes('/some//url//to//', { leading: true })).toEqual('some/url/to/');
+ });
+
+ it('does not removes duplicates when `duplicates` is false', () => {
+ expect(removeSlashes('/some//url//to/', { leading: true, duplicates: false })).toEqual(
+ 'some//url//to/'
+ );
+ expect(removeSlashes('/some//url//to/', { trailing: true, duplicates: false })).toEqual(
+ '/some//url//to'
+ );
+ });
+
+ it('accept mixed options', () => {
+ expect(
+ removeSlashes('/some//url//to/', { leading: true, duplicates: false, trailing: true })
+ ).toEqual('some//url//to');
+ expect(
+ removeSlashes('/some//url//to/', { leading: true, duplicates: true, trailing: true })
+ ).toEqual('some/url/to');
+ });
+});
+
+describe('appendAppPath', () => {
+ it('appends the appBasePath with given path', () => {
+ expect(appendAppPath('/app/my-app', '/some-path')).toEqual('/app/my-app/some-path');
+ expect(appendAppPath('/app/my-app/', 'some-path')).toEqual('/app/my-app/some-path');
+ expect(appendAppPath('/app/my-app', 'some-path')).toEqual('/app/my-app/some-path');
+ expect(appendAppPath('/app/my-app', '')).toEqual('/app/my-app');
+ });
+
+ it('preserves the trailing slash only if included in the hash', () => {
+ expect(appendAppPath('/app/my-app', '/some-path/')).toEqual('/app/my-app/some-path');
+ expect(appendAppPath('/app/my-app', '/some-path#/')).toEqual('/app/my-app/some-path#/');
+ expect(appendAppPath('/app/my-app', '/some-path#/hash/')).toEqual(
+ '/app/my-app/some-path#/hash/'
+ );
+ expect(appendAppPath('/app/my-app', '/some-path#/hash')).toEqual('/app/my-app/some-path#/hash');
+ });
+});
diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts
new file mode 100644
index 0000000000000..048f195fe1223
--- /dev/null
+++ b/src/core/public/application/utils.ts
@@ -0,0 +1,54 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Utility to remove trailing, leading or duplicate slashes.
+ * By default will only remove duplicates.
+ */
+export const removeSlashes = (
+ url: string,
+ {
+ trailing = false,
+ leading = false,
+ duplicates = true,
+ }: { trailing?: boolean; leading?: boolean; duplicates?: boolean } = {}
+): string => {
+ if (duplicates) {
+ url = url.replace(/\/{2,}/g, '/');
+ }
+ if (trailing) {
+ url = url.replace(/\/$/, '');
+ }
+ if (leading) {
+ url = url.replace(/^\//, '');
+ }
+ return url;
+};
+
+export const appendAppPath = (appBasePath: string, path: string = '') => {
+ // Only prepend slash if not a hash or query path
+ path = path === '' || path.startsWith('#') || path.startsWith('?') ? path : `/${path}`;
+ // Do not remove trailing slash when in hashbang
+ const removeTrailing = path.indexOf('#') === -1;
+ return removeSlashes(`${appBasePath}${path}`, {
+ trailing: removeTrailing,
+ duplicates: true,
+ leading: false,
+ });
+};
diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts
index d0ef2aeb265fe..fb2972735c2b7 100644
--- a/src/core/public/chrome/nav_links/nav_link.ts
+++ b/src/core/public/chrome/nav_links/nav_link.ts
@@ -44,6 +44,12 @@ export interface ChromeNavLink {
*/
readonly baseUrl: string;
+ /**
+ * The route used to open the {@link AppBase.defaultPath | default path } of an application.
+ * If unset, `baseUrl` will be used instead.
+ */
+ readonly url?: string;
+
/**
* An ordinal used to sort nav links relative to one another for display.
*/
@@ -99,18 +105,6 @@ export interface ChromeNavLink {
*/
readonly linkToLastSubUrl?: boolean;
- /**
- * A url that legacy apps can set to deep link into their applications.
- *
- * @internalRemarks
- * Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should
- * be removed once the ApplicationService is implemented and mounting apps. At that
- * time, each app can handle opening to the previous location when they are mounted.
- *
- * @deprecated
- */
- readonly url?: string;
-
/**
* Indicates whether or not this app is currently on the screen.
*
diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts
index 23fdabe0f3430..4c319873af804 100644
--- a/src/core/public/chrome/nav_links/to_nav_link.test.ts
+++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts
@@ -85,6 +85,38 @@ describe('toNavLink', () => {
expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path');
});
+ it('generates the `url` property', () => {
+ let link = toNavLink(
+ app({
+ appRoute: '/my-route/my-path',
+ }),
+ basePath
+ );
+ expect(link.properties.url).toEqual('http://localhost/base-path/my-route/my-path');
+
+ link = toNavLink(
+ app({
+ appRoute: '/my-route/my-path',
+ defaultPath: 'some/default/path',
+ }),
+ basePath
+ );
+ expect(link.properties.url).toEqual(
+ 'http://localhost/base-path/my-route/my-path/some/default/path'
+ );
+ });
+
+ it('does not generate `url` for legacy app', () => {
+ const link = toNavLink(
+ legacyApp({
+ appUrl: '/my-legacy-app/#foo',
+ defaultPath: '/some/default/path',
+ }),
+ basePath
+ );
+ expect(link.properties.url).toBeUndefined();
+ });
+
it('uses appUrl when converting legacy applications', () => {
expect(
toNavLink(
diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts
index 18e4b7b26b6ba..f79b1df77f8e1 100644
--- a/src/core/public/chrome/nav_links/to_nav_link.ts
+++ b/src/core/public/chrome/nav_links/to_nav_link.ts
@@ -20,9 +20,11 @@
import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application';
import { IBasePath } from '../../http';
import { NavLinkWrapper } from './nav_link';
+import { appendAppPath } from '../../application/utils';
export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper {
const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default;
+ const baseUrl = isLegacyApp(app) ? basePath.prepend(app.appUrl) : basePath.prepend(app.appRoute!);
return new NavLinkWrapper({
...app,
hidden: useAppStatus
@@ -30,9 +32,12 @@ export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWra
: app.navLinkStatus === AppNavLinkStatus.hidden,
disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled,
legacy: isLegacyApp(app),
- baseUrl: isLegacyApp(app)
- ? relativeToAbsolute(basePath.prepend(app.appUrl))
- : relativeToAbsolute(basePath.prepend(app.appRoute!)),
+ baseUrl: relativeToAbsolute(baseUrl),
+ ...(isLegacyApp(app)
+ ? {}
+ : {
+ url: relativeToAbsolute(appendAppPath(baseUrl, app.defaultPath)),
+ }),
});
}
diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx
index 52b59c53b658c..d97ef477c2ee0 100644
--- a/src/core/public/chrome/ui/header/nav_link.tsx
+++ b/src/core/public/chrome/ui/header/nav_link.tsx
@@ -53,7 +53,7 @@ export function euiNavLink(
order,
tooltip,
} = navLink;
- let href = navLink.baseUrl;
+ let href = navLink.url ?? navLink.baseUrl;
if (legacy) {
href = url && !active ? url : baseUrl;
diff --git a/src/core/public/plugins/plugin.test.mocks.ts b/src/core/public/plugins/plugin.test.mocks.ts
index b877847aaa90e..422442c9ca4d2 100644
--- a/src/core/public/plugins/plugin.test.mocks.ts
+++ b/src/core/public/plugins/plugin.test.mocks.ts
@@ -24,8 +24,8 @@ export const mockPlugin = {
};
export const mockInitializer = jest.fn(() => mockPlugin);
-export const mockPluginLoader = jest.fn().mockResolvedValue(mockInitializer);
+export const mockPluginReader = jest.fn(() => mockInitializer);
-jest.mock('./plugin_loader', () => ({
- loadPluginBundle: mockPluginLoader,
+jest.mock('./plugin_reader', () => ({
+ read: mockPluginReader,
}));
diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts
index 39330711f7980..8fe745db9554d 100644
--- a/src/core/public/plugins/plugin.test.ts
+++ b/src/core/public/plugins/plugin.test.ts
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { mockInitializer, mockPlugin, mockPluginLoader } from './plugin.test.mocks';
+import { mockInitializer, mockPlugin, mockPluginReader } from './plugin.test.mocks';
import { DiscoveredPlugin } from '../../server';
import { coreMock } from '../mocks';
@@ -38,10 +38,9 @@ function createManifest(
let plugin: PluginWrapper>;
const opaqueId = Symbol();
const initializerContext = coreMock.createPluginInitializerContext();
-const addBasePath = (path: string) => path;
beforeEach(() => {
- mockPluginLoader.mockClear();
+ mockPluginReader.mockClear();
mockPlugin.setup.mockClear();
mockPlugin.start.mockClear();
mockPlugin.stop.mockClear();
@@ -49,20 +48,8 @@ beforeEach(() => {
});
describe('PluginWrapper', () => {
- test('`load` calls loadPluginBundle', () => {
- plugin.load(addBasePath);
- expect(mockPluginLoader).toHaveBeenCalledWith(addBasePath, 'plugin-a');
- });
-
- test('`setup` fails if load is not called first', async () => {
- await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Plugin \\"plugin-a\\" can't be setup since its bundle isn't loaded."`
- );
- });
-
test('`setup` fails if plugin.setup is not a function', async () => {
mockInitializer.mockReturnValueOnce({ start: jest.fn() } as any);
- await plugin.load(addBasePath);
await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Instance of plugin \\"plugin-a\\" does not define \\"setup\\" function."`
);
@@ -70,20 +57,17 @@ describe('PluginWrapper', () => {
test('`setup` fails if plugin.start is not a function', async () => {
mockInitializer.mockReturnValueOnce({ setup: jest.fn() } as any);
- await plugin.load(addBasePath);
await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Instance of plugin \\"plugin-a\\" does not define \\"start\\" function."`
);
});
test('`setup` calls initializer with initializer context', async () => {
- await plugin.load(addBasePath);
await plugin.setup({} as any, {} as any);
expect(mockInitializer).toHaveBeenCalledWith(initializerContext);
});
test('`setup` calls plugin.setup with context and dependencies', async () => {
- await plugin.load(addBasePath);
const context = { any: 'thing' } as any;
const deps = { otherDep: 'value' };
await plugin.setup(context, deps);
@@ -91,14 +75,12 @@ describe('PluginWrapper', () => {
});
test('`start` fails if setup is not called first', async () => {
- await plugin.load(addBasePath);
await expect(plugin.start({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"plugin-a\\" can't be started since it isn't set up."`
);
});
test('`start` calls plugin.start with context and dependencies', async () => {
- await plugin.load(addBasePath);
await plugin.setup({} as any, {} as any);
const context = { any: 'thing' } as any;
const deps = { otherDep: 'value' };
@@ -114,20 +96,21 @@ describe('PluginWrapper', () => {
};
let startDependenciesResolved = false;
- mockPluginLoader.mockResolvedValueOnce(() => ({
- setup: jest.fn(),
- start: async () => {
- // Add small delay to ensure startDependencies is not resolved until after the plugin instance's start resolves.
- await new Promise(resolve => setTimeout(resolve, 10));
- expect(startDependenciesResolved).toBe(false);
- return pluginStartContract;
- },
- }));
- await plugin.load(addBasePath);
+ mockPluginReader.mockReturnValueOnce(
+ jest.fn(() => ({
+ setup: jest.fn(),
+ start: jest.fn(async () => {
+ // Add small delay to ensure startDependencies is not resolved until after the plugin instance's start resolves.
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(startDependenciesResolved).toBe(false);
+ return pluginStartContract;
+ }),
+ stop: jest.fn(),
+ }))
+ );
await plugin.setup({} as any, {} as any);
const context = { any: 'thing' } as any;
const deps = { otherDep: 'value' };
-
// Add promise callback prior to calling `start` to ensure calls in `setup` will not resolve before `start` is
// called.
const startDependenciesCheck = plugin.startDependencies.then(res => {
@@ -145,7 +128,6 @@ describe('PluginWrapper', () => {
});
test('`stop` calls plugin.stop', async () => {
- await plugin.load(addBasePath);
await plugin.setup({} as any, {} as any);
await plugin.stop();
expect(mockPlugin.stop).toHaveBeenCalled();
@@ -153,7 +135,6 @@ describe('PluginWrapper', () => {
test('`stop` does not fail if plugin.stop does not exist', async () => {
mockInitializer.mockReturnValueOnce({ setup: jest.fn(), start: jest.fn() } as any);
- await plugin.load(addBasePath);
await plugin.setup({} as any, {} as any);
expect(() => plugin.stop()).not.toThrow();
});
diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts
index e51c45040c452..591165fcd2839 100644
--- a/src/core/public/plugins/plugin.ts
+++ b/src/core/public/plugins/plugin.ts
@@ -21,7 +21,7 @@ import { Subject } from 'rxjs';
import { first } from 'rxjs/operators';
import { DiscoveredPlugin, PluginOpaqueId } from '../../server';
import { PluginInitializerContext } from './plugin_context';
-import { loadPluginBundle } from './plugin_loader';
+import { read } from './plugin_reader';
import { CoreStart, CoreSetup } from '..';
/**
@@ -69,7 +69,6 @@ export class PluginWrapper<
public readonly configPath: DiscoveredPlugin['configPath'];
public readonly requiredPlugins: DiscoveredPlugin['requiredPlugins'];
public readonly optionalPlugins: DiscoveredPlugin['optionalPlugins'];
- private initializer?: PluginInitializer;
private instance?: Plugin;
private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart, TStart]>();
@@ -86,18 +85,6 @@ export class PluginWrapper<
this.optionalPlugins = discoveredPlugin.optionalPlugins;
}
- /**
- * Loads the plugin's bundle into the browser. Should be called in parallel with all plugins
- * using `Promise.all`. Must be called before `setup`.
- * @param addBasePath Function that adds the base path to a string for plugin bundle path.
- */
- public async load(addBasePath: (path: string) => string) {
- this.initializer = await loadPluginBundle(
- addBasePath,
- this.name
- );
- }
-
/**
* Instantiates plugin and calls `setup` function exposed by the plugin initializer.
* @param setupContext Context that consists of various core services tailored specifically
@@ -146,11 +133,14 @@ export class PluginWrapper<
}
private async createPluginInstance() {
- if (this.initializer === undefined) {
- throw new Error(`Plugin "${this.name}" can't be setup since its bundle isn't loaded.`);
- }
-
- const instance = this.initializer(this.initializerContext);
+ const initializer = read(this.name) as PluginInitializer<
+ TSetup,
+ TStart,
+ TPluginsSetup,
+ TPluginsStart
+ >;
+
+ const instance = initializer(this.initializerContext);
if (typeof instance.setup !== 'function') {
throw new Error(`Instance of plugin "${this.name}" does not define "setup" function.`);
diff --git a/src/core/public/plugins/plugin_loader.test.ts b/src/core/public/plugins/plugin_loader.test.ts
deleted file mode 100644
index b4e2c3095f14a..0000000000000
--- a/src/core/public/plugins/plugin_loader.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { CoreWindow, loadPluginBundle } from './plugin_loader';
-
-let createdScriptTags = [] as any[];
-let appendChildSpy: jest.SpyInstance;
-let createElementSpy: jest.SpyInstance<
- HTMLElement,
- [string, (ElementCreationOptions | undefined)?]
->;
-
-const coreWindow = (window as unknown) as CoreWindow;
-
-beforeEach(() => {
- // Mock document.createElement to return fake tags we can use to inspect what
- // loadPluginBundles does.
- createdScriptTags = [];
- createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(() => {
- const scriptTag = { setAttribute: jest.fn() } as any;
- createdScriptTags.push(scriptTag);
- return scriptTag;
- });
-
- // Mock document.body.appendChild to avoid errors about appending objects that aren't `Node`'s
- // and so we can verify that the script tags were added to the page.
- appendChildSpy = jest.spyOn(document.body, 'appendChild').mockReturnValue({} as any);
-
- // Mock global fields needed for loading modules.
- coreWindow.__kbnBundles__ = {};
-});
-
-afterEach(() => {
- appendChildSpy.mockRestore();
- createElementSpy.mockRestore();
- delete coreWindow.__kbnBundles__;
-});
-
-const addBasePath = (path: string) => path;
-
-test('`loadPluginBundles` creates a script tag and loads initializer', async () => {
- const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
-
- // Verify it sets up the script tag correctly and adds it to document.body
- expect(createdScriptTags).toHaveLength(1);
- const fakeScriptTag = createdScriptTags[0];
- expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith(
- 'src',
- '/bundles/plugin/plugin-a/plugin-a.plugin.js'
- );
- expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('id', 'kbn-plugin-plugin-a');
- expect(fakeScriptTag.onload).toBeInstanceOf(Function);
- expect(fakeScriptTag.onerror).toBeInstanceOf(Function);
- expect(appendChildSpy).toHaveBeenCalledWith(fakeScriptTag);
-
- // Setup a fake initializer as if a plugin bundle had actually been loaded.
- const fakeInitializer = jest.fn();
- coreWindow.__kbnBundles__['plugin/plugin-a'] = { plugin: fakeInitializer };
- // Call the onload callback
- fakeScriptTag.onload();
- await expect(loadPromise).resolves.toEqual(fakeInitializer);
-});
-
-test('`loadPluginBundles` includes the basePath', async () => {
- loadPluginBundle((path: string) => `/mybasepath${path}`, 'plugin-a');
-
- // Verify it sets up the script tag correctly and adds it to document.body
- expect(createdScriptTags).toHaveLength(1);
- const fakeScriptTag = createdScriptTags[0];
- expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith(
- 'src',
- '/mybasepath/bundles/plugin/plugin-a/plugin-a.plugin.js'
- );
-});
-
-test('`loadPluginBundles` rejects if script.onerror is called', async () => {
- const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
- const fakeScriptTag1 = createdScriptTags[0];
- // Call the error on the second script
- fakeScriptTag1.onerror(new Error('Whoa there!'));
-
- await expect(loadPromise).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Failed to load \\"plugin-a\\" bundle (/bundles/plugin/plugin-a/plugin-a.plugin.js)"`
- );
-});
-
-test('`loadPluginBundles` rejects if timeout is reached', async () => {
- await expect(
- // Override the timeout to 1 ms for testi.
- loadPluginBundle(addBasePath, 'plugin-a', { timeoutMs: 1 })
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Timeout reached when loading \\"plugin-a\\" bundle (/bundles/plugin/plugin-a/plugin-a.plugin.js)"`
- );
-});
-
-test('`loadPluginBundles` rejects if bundle does attach an initializer to window.__kbnBundles__', async () => {
- const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
-
- const fakeScriptTag1 = createdScriptTags[0];
-
- // Setup a fake initializer as if a plugin bundle had actually been loaded.
- coreWindow.__kbnBundles__['plugin/plugin-a'] = undefined;
- // Call the onload callback
- fakeScriptTag1.onload();
-
- await expect(loadPromise).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Definition of plugin \\"plugin-a\\" should be a function (/bundles/plugin/plugin-a/plugin-a.plugin.js)."`
- );
-});
diff --git a/src/core/public/plugins/plugin_loader.ts b/src/core/public/plugins/plugin_loader.ts
deleted file mode 100644
index bf7711055e97b..0000000000000
--- a/src/core/public/plugins/plugin_loader.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { PluginName } from '../../server';
-import { PluginInitializer } from './plugin';
-
-/**
- * Unknown variant for internal use only for when plugins are not known.
- * @internal
- */
-export type UnknownPluginInitializer = PluginInitializer>;
-
-/**
- * Custom window type for loading bundles. Do not extend global Window to avoid leaking these types.
- * @internal
- */
-export interface CoreWindow {
- __kbnBundles__: {
- [pluginBundleName: string]: { plugin: UnknownPluginInitializer } | undefined;
- };
-}
-
-/**
- * Timeout for loading a single script in milliseconds.
- * @internal
- */
-export const LOAD_TIMEOUT = 120 * 1000; // 2 minutes
-
-/**
- * Loads the bundle for a plugin onto the page and returns their PluginInitializer. This should
- * be called for all plugins (once per plugin) in parallel using Promise.all.
- *
- * If this is slowing down browser load time, there are some ways we could make this faster:
- * - Add these bundles in the generated bootstrap.js file so they're loaded immediately
- * - Concatenate all the bundles files on the backend and serve them in single request.
- * - Use HTTP/2 to load these bundles without having to open new connections for each.
- *
- * This may not be much of an issue since these should be cached by the browser after the first
- * page load.
- *
- * @param basePath
- * @param plugins
- * @internal
- */
-export const loadPluginBundle: LoadPluginBundle = <
- TSetup,
- TStart,
- TPluginsSetup extends object,
- TPluginsStart extends object
->(
- addBasePath: (path: string) => string,
- pluginName: PluginName,
- { timeoutMs = LOAD_TIMEOUT }: { timeoutMs?: number } = {}
-) =>
- new Promise>(
- (resolve, reject) => {
- const coreWindow = (window as unknown) as CoreWindow;
- const exportId = `plugin/${pluginName}`;
-
- const readPluginExport = () => {
- const PluginExport: any = coreWindow.__kbnBundles__[exportId];
- if (typeof PluginExport?.plugin !== 'function') {
- reject(
- new Error(`Definition of plugin "${pluginName}" should be a function (${bundlePath}).`)
- );
- } else {
- resolve(
- PluginExport.plugin as PluginInitializer
- );
- }
- };
-
- if (coreWindow.__kbnBundles__[exportId]) {
- readPluginExport();
- return;
- }
-
- const script = document.createElement('script');
- // Assumes that all plugin bundles get put into the bundles/plugins subdirectory
- const bundlePath = addBasePath(`/bundles/plugin/${pluginName}/${pluginName}.plugin.js`);
- script.setAttribute('src', bundlePath);
- script.setAttribute('id', `kbn-plugin-${pluginName}`);
- script.setAttribute('async', '');
-
- const cleanupTag = () => {
- clearTimeout(timeout);
- // Set to null for IE memory leak issue. Webpack does the same thing.
- // @ts-ignore
- script.onload = script.onerror = null;
- };
-
- // Wire up resolve and reject
- script.onload = () => {
- cleanupTag();
- readPluginExport();
- };
-
- script.onerror = () => {
- cleanupTag();
- reject(new Error(`Failed to load "${pluginName}" bundle (${bundlePath})`));
- };
-
- const timeout = setTimeout(() => {
- cleanupTag();
- reject(new Error(`Timeout reached when loading "${pluginName}" bundle (${bundlePath})`));
- }, timeoutMs);
-
- // Add the script tag to the end of the body to start downloading
- document.body.appendChild(script);
- }
- );
-
-/**
- * @internal
- */
-export type LoadPluginBundle = <
- TSetup,
- TStart,
- TPluginsSetup extends object,
- TPluginsStart extends object
->(
- addBasePath: (path: string) => string,
- pluginName: PluginName,
- options?: { timeoutMs?: number }
-) => Promise>;
diff --git a/src/core/public/plugins/plugin_reader.test.ts b/src/core/public/plugins/plugin_reader.test.ts
new file mode 100644
index 0000000000000..d4324f81de8e6
--- /dev/null
+++ b/src/core/public/plugins/plugin_reader.test.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CoreWindow, read, UnknownPluginInitializer } from './plugin_reader';
+
+const coreWindow: CoreWindow = window as any;
+beforeEach(() => {
+ coreWindow.__kbnBundles__ = {};
+});
+
+it('handles undefined plugin exports', () => {
+ coreWindow.__kbnBundles__['plugin/foo'] = undefined;
+
+ expect(() => {
+ read('foo');
+ }).toThrowError(`Definition of plugin "foo" not found and may have failed to load.`);
+});
+
+it('handles plugin exports with a "plugin" export that is not a function', () => {
+ coreWindow.__kbnBundles__['plugin/foo'] = {
+ plugin: 1234,
+ } as any;
+
+ expect(() => {
+ read('foo');
+ }).toThrowError(`Definition of plugin "foo" should be a function.`);
+});
+
+it('returns the plugin initializer when the "plugin" named export is a function', () => {
+ const plugin: UnknownPluginInitializer = () => {
+ return undefined as any;
+ };
+
+ coreWindow.__kbnBundles__['plugin/foo'] = { plugin };
+
+ expect(read('foo')).toBe(plugin);
+});
diff --git a/src/core/public/plugins/plugin_reader.ts b/src/core/public/plugins/plugin_reader.ts
new file mode 100644
index 0000000000000..1907dfa6a3e99
--- /dev/null
+++ b/src/core/public/plugins/plugin_reader.ts
@@ -0,0 +1,52 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { PluginInitializer } from './plugin';
+
+/**
+ * Unknown variant for internal use only for when plugins are not known.
+ * @internal
+ */
+export type UnknownPluginInitializer = PluginInitializer>;
+
+/**
+ * Custom window type for loading bundles. Do not extend global Window to avoid leaking these types.
+ * @internal
+ */
+export interface CoreWindow {
+ __kbnBundles__: {
+ [pluginBundleName: string]: { plugin: UnknownPluginInitializer } | undefined;
+ };
+}
+
+/**
+ * Reads the plugin's bundle declared in the global context.
+ */
+export function read(name: string) {
+ const coreWindow = (window as unknown) as CoreWindow;
+ const exportId = `plugin/${name}`;
+ const pluginExport = coreWindow.__kbnBundles__[exportId];
+ if (!pluginExport) {
+ throw new Error(`Definition of plugin "${name}" not found and may have failed to load.`);
+ } else if (typeof pluginExport.plugin !== 'function') {
+ throw new Error(`Definition of plugin "${name}" should be a function.`);
+ } else {
+ return pluginExport.plugin;
+ }
+}
diff --git a/src/core/public/plugins/plugins_service.test.mocks.ts b/src/core/public/plugins/plugins_service.test.mocks.ts
index a76078932518f..85b84e561056a 100644
--- a/src/core/public/plugins/plugins_service.test.mocks.ts
+++ b/src/core/public/plugins/plugins_service.test.mocks.ts
@@ -19,16 +19,16 @@
import { PluginName } from 'kibana/server';
import { Plugin } from './plugin';
-import { loadPluginBundleMock } from './plugin_loader.mock';
export type MockedPluginInitializer = jest.Mock>, any>;
export const mockPluginInitializerProvider: jest.Mock<
MockedPluginInitializer,
[PluginName]
-> = jest.fn().mockRejectedValue(new Error('No provider specified'));
+> = jest.fn().mockImplementation(() => () => {
+ throw new Error('No provider specified');
+});
-export const mockLoadPluginBundle = loadPluginBundleMock.create(mockPluginInitializerProvider);
-jest.mock('./plugin_loader', () => ({
- loadPluginBundle: mockLoadPluginBundle,
+jest.mock('./plugin_reader', () => ({
+ read: mockPluginInitializerProvider,
}));
diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts
index 688eaf4f2bfc7..6d71844bc19c8 100644
--- a/src/core/public/plugins/plugins_service.test.ts
+++ b/src/core/public/plugins/plugins_service.test.ts
@@ -21,7 +21,6 @@ import { omit, pick } from 'lodash';
import {
MockedPluginInitializer,
- mockLoadPluginBundle,
mockPluginInitializerProvider,
} from './plugins_service.test.mocks';
@@ -32,6 +31,7 @@ import {
PluginsServiceStartDeps,
PluginsServiceSetupDeps,
} from './plugins_service';
+
import { InjectedPluginMetadata } from '../injected_metadata';
import { notificationServiceMock } from '../notifications/notifications_service.mock';
import { applicationServiceMock } from '../application/application_service.mock';
@@ -152,10 +152,6 @@ describe('PluginsService', () => {
] as unknown) as [[PluginName, any]]);
});
- afterEach(() => {
- mockLoadPluginBundle.mockClear();
- });
-
describe('#getOpaqueIds()', () => {
it('returns dependency tree of symbols', () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
@@ -174,15 +170,6 @@ describe('PluginsService', () => {
});
describe('#setup()', () => {
- it('fails if any bundle cannot be loaded', async () => {
- mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle'));
-
- const pluginsService = new PluginsService(mockCoreContext, plugins);
- await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Could not load bundle"`
- );
- });
-
it('fails if any plugin instance does not have a setup function', async () => {
mockPluginInitializers.set('pluginA', (() => ({})) as any);
const pluginsService = new PluginsService(mockCoreContext, plugins);
@@ -191,25 +178,6 @@ describe('PluginsService', () => {
);
});
- it('calls loadPluginBundles with http and plugins', async () => {
- const pluginsService = new PluginsService(mockCoreContext, plugins);
- await pluginsService.setup(mockSetupDeps);
-
- expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3);
- expect(mockLoadPluginBundle).toHaveBeenCalledWith(
- mockSetupDeps.http.basePath.prepend,
- 'pluginA'
- );
- expect(mockLoadPluginBundle).toHaveBeenCalledWith(
- mockSetupDeps.http.basePath.prepend,
- 'pluginB'
- );
- expect(mockLoadPluginBundle).toHaveBeenCalledWith(
- mockSetupDeps.http.basePath.prepend,
- 'pluginC'
- );
- });
-
it('initializes plugins with PluginInitializerContext', async () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
await pluginsService.setup(mockSetupDeps);
@@ -302,7 +270,6 @@ describe('PluginsService', () => {
const pluginsService = new PluginsService(mockCoreContext, plugins);
const promise = pluginsService.setup(mockSetupDeps);
- jest.runAllTimers(); // load plugin bundles
await flushPromises();
jest.runAllTimers(); // setup plugins
diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts
index e698af689036d..862aa5043ad4b 100644
--- a/src/core/public/plugins/plugins_service.ts
+++ b/src/core/public/plugins/plugins_service.ts
@@ -93,9 +93,6 @@ export class PluginsService implements CoreService {
- // Load plugin bundles
- await this.loadPluginBundles(deps.http.basePath.prepend);
-
// Setup each plugin with required and optional plugin contracts
const contracts = new Map();
for (const [pluginName, plugin] of this.plugins.entries()) {
@@ -167,9 +164,4 @@ export class PluginsService implements CoreService string) {
- // Load all bundles in parallel
- return Promise.all([...this.plugins.values()].map(plugin => plugin.load(addBasePath)));
- }
}
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index b92bb209d2607..af06b207889c2 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -36,6 +36,7 @@ export interface AppBase {
capabilities?: Partial;
category?: AppCategory;
chromeless?: boolean;
+ defaultPath?: string;
euiIconType?: string;
icon?: string;
id: string;
@@ -168,7 +169,7 @@ export enum AppStatus {
export type AppUnmount = () => void;
// @public
-export type AppUpdatableFields = Pick;
+export type AppUpdatableFields = Pick;
// @public
export type AppUpdater = (app: AppBase) => Partial | undefined;
@@ -290,7 +291,6 @@ export interface ChromeNavLink {
readonly subUrlBase?: string;
readonly title: string;
readonly tooltip?: string;
- // @deprecated
readonly url?: string;
}
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index 27db79bb94d25..4fb433b5c77ba 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -1068,6 +1068,14 @@ describe('setup contract', () => {
await create();
expect(create()).rejects.toThrowError('A cookieSessionStorageFactory was already created');
});
+
+ it('does not throw if called after stop', async () => {
+ const { createCookieSessionStorageFactory } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ createCookieSessionStorageFactory(cookieOptions);
+ }).not.toThrow();
+ });
});
describe('#isTlsEnabled', () => {
@@ -1113,4 +1121,54 @@ describe('setup contract', () => {
expect(getServerInfo().protocol).toEqual('https');
});
});
+
+ describe('#registerStaticDir', () => {
+ it('does not throw if called after stop', async () => {
+ const { registerStaticDir } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerStaticDir('/path1/{path*}', '/path/to/resource');
+ }).not.toThrow();
+ });
+ });
+
+ describe('#registerOnPreAuth', () => {
+ test('does not throw if called after stop', async () => {
+ const { registerOnPreAuth } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerOnPreAuth((req, res) => res.unauthorized());
+ }).not.toThrow();
+ });
+ });
+
+ describe('#registerOnPostAuth', () => {
+ test('does not throw if called after stop', async () => {
+ const { registerOnPostAuth } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerOnPostAuth((req, res) => res.unauthorized());
+ }).not.toThrow();
+ });
+ });
+
+ describe('#registerOnPreResponse', () => {
+ test('does not throw if called after stop', async () => {
+ const { registerOnPreResponse } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerOnPreResponse((req, res, t) => t.next());
+ }).not.toThrow();
+ });
+ });
+
+ describe('#registerAuth', () => {
+ test('does not throw if called after stop', async () => {
+ const { registerAuth } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerAuth((req, res) => res.unauthorized());
+ }).not.toThrow();
+ });
+ });
});
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index 77d3d99fb48cb..92ac5220735a1 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -74,6 +74,7 @@ export class HttpServer {
private registeredRouters = new Set();
private authRegistered = false;
private cookieSessionStorageCreated = false;
+ private stopped = false;
private readonly log: Logger;
private readonly authState: AuthStateStorage;
@@ -144,6 +145,10 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
+ if (this.stopped) {
+ this.log.warn(`start called after stop`);
+ return;
+ }
this.log.debug('starting http server');
for (const router of this.registeredRouters) {
@@ -189,13 +194,13 @@ export class HttpServer {
}
public async stop() {
+ this.stopped = true;
if (this.server === undefined) {
return;
}
this.log.debug('stopping http server');
await this.server.stop();
- this.server = undefined;
}
private getAuthOption(
@@ -234,6 +239,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`setupConditionalCompression called after stop`);
+ }
const { enabled, referrerWhitelist: list } = config.compression;
if (!enabled) {
@@ -261,6 +269,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`registerOnPostAuth called after stop`);
+ }
this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log));
}
@@ -269,6 +280,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`registerOnPreAuth called after stop`);
+ }
this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log));
}
@@ -277,6 +291,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`registerOnPreResponse called after stop`);
+ }
this.server.ext('onPreResponse', adoptToHapiOnPreResponseFormat(fn, this.log));
}
@@ -288,6 +305,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`createCookieSessionStorageFactory called after stop`);
+ }
if (this.cookieSessionStorageCreated) {
throw new Error('A cookieSessionStorageFactory was already created');
}
@@ -305,6 +325,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
+ if (this.stopped) {
+ this.log.warn(`registerAuth called after stop`);
+ }
if (this.authRegistered) {
throw new Error('Auth interceptor was already registered');
}
@@ -348,6 +371,9 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
+ if (this.stopped) {
+ this.log.warn(`registerStaticDir called after stop`);
+ }
this.server.route({
path,
diff --git a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts
index 1790b096a71ae..dfa2396d5904b 100644
--- a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts
+++ b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts
@@ -48,7 +48,7 @@ describe('logLegacyThirdPartyPluginDeprecationWarning', () => {
expect(log.warn).toHaveBeenCalledTimes(1);
expect(log.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
- "Some installed third party plugin(s) [plugin] are using the legacy plugin format and will no longer work in a future Kibana release. Please refer to https://www.elastic.co/guide/en/kibana/master/breaking-changes-8.0.html for a list of breaking changes and https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md for documentation on how to migrate legacy plugins.",
+ "Some installed third party plugin(s) [plugin] are using the legacy plugin format and will no longer work in a future Kibana release. Please refer to https://ela.st/kibana-breaking-changes-8-0 for a list of breaking changes and https://ela.st/kibana-platform-migration for documentation on how to migrate legacy plugins.",
]
`);
});
@@ -65,7 +65,7 @@ describe('logLegacyThirdPartyPluginDeprecationWarning', () => {
expect(log.warn).toHaveBeenCalledTimes(1);
expect(log.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
- "Some installed third party plugin(s) [pluginA, pluginB, pluginC] are using the legacy plugin format and will no longer work in a future Kibana release. Please refer to https://www.elastic.co/guide/en/kibana/master/breaking-changes-8.0.html for a list of breaking changes and https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md for documentation on how to migrate legacy plugins.",
+ "Some installed third party plugin(s) [pluginA, pluginB, pluginC] are using the legacy plugin format and will no longer work in a future Kibana release. Please refer to https://ela.st/kibana-breaking-changes-8-0 for a list of breaking changes and https://ela.st/kibana-platform-migration for documentation on how to migrate legacy plugins.",
]
`);
});
diff --git a/src/core/server/legacy/plugins/log_legacy_plugins_warning.ts b/src/core/server/legacy/plugins/log_legacy_plugins_warning.ts
index f9c3dcbf554cb..df86f5a2b4031 100644
--- a/src/core/server/legacy/plugins/log_legacy_plugins_warning.ts
+++ b/src/core/server/legacy/plugins/log_legacy_plugins_warning.ts
@@ -22,9 +22,10 @@ import { LegacyPluginSpec } from '../types';
const internalPaths = ['/src/legacy/core_plugins', '/x-pack'];
-const breakingChangesUrl =
- 'https://www.elastic.co/guide/en/kibana/master/breaking-changes-8.0.html';
-const migrationGuideUrl = 'https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md';
+// Use shortened URLs so destinations can be updated if/when documentation moves
+// All platform team members have access to edit these
+const breakingChangesUrl = 'https://ela.st/kibana-breaking-changes-8-0';
+const migrationGuideUrl = 'https://ela.st/kibana-platform-migration';
export const logLegacyThirdPartyPluginDeprecationWarning = ({
specs,
diff --git a/src/core/server/rendering/views/template.tsx b/src/core/server/rendering/views/template.tsx
index b38259a84cb9c..73e119a5a97e7 100644
--- a/src/core/server/rendering/views/template.tsx
+++ b/src/core/server/rendering/views/template.tsx
@@ -104,6 +104,10 @@ export const Template: FunctionComponent = ({
+
+ {/* Inject stylesheets into the before scripts so that KP plugins with bundled styles will override them */}
+
+
{createElement('kbn-csp', {
diff --git a/src/core/server/saved_objects/migrations/core/index.ts b/src/core/server/saved_objects/migrations/core/index.ts
index 466d399f653cd..f7274740ea5fe 100644
--- a/src/core/server/saved_objects/migrations/core/index.ts
+++ b/src/core/server/saved_objects/migrations/core/index.ts
@@ -21,5 +21,5 @@ export { DocumentMigrator } from './document_migrator';
export { IndexMigrator } from './index_migrator';
export { buildActiveMappings } from './build_active_mappings';
export { CallCluster } from './call_cluster';
-export { LogFn } from './migration_logger';
+export { LogFn, SavedObjectsMigrationLogger } from './migration_logger';
export { MigrationResult, MigrationStatus } from './migration_coordinator';
diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts
new file mode 100644
index 0000000000000..76a890d26bfa0
--- /dev/null
+++ b/src/core/server/saved_objects/migrations/mocks.ts
@@ -0,0 +1,43 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SavedObjectMigrationContext } from './types';
+import { SavedObjectsMigrationLogger } from './core';
+
+const createLoggerMock = (): jest.Mocked => {
+ const mock = {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warning: jest.fn(),
+ warn: jest.fn(),
+ };
+
+ return mock;
+};
+
+const createContextMock = (): jest.Mocked => {
+ const mock = {
+ log: createLoggerMock(),
+ };
+ return mock;
+};
+
+export const migrationMocks = {
+ createContext: createContextMock,
+};
diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts
index 6bc085dde872e..85f15b4c18b66 100644
--- a/src/core/server/saved_objects/migrations/types.ts
+++ b/src/core/server/saved_objects/migrations/types.ts
@@ -26,23 +26,37 @@ import { SavedObjectsMigrationLogger } from './core/migration_logger';
*
* @example
* ```typescript
- * const migrateProperty: SavedObjectMigrationFn = (doc, { log }) => {
- * if(doc.attributes.someProp === null) {
- * log.warn('Skipping migration');
- * } else {
- * doc.attributes.someProp = migrateProperty(doc.attributes.someProp);
- * }
+ * interface TypeV1Attributes {
+ * someKey: string;
+ * obsoleteProperty: number;
+ * }
*
- * return doc;
+ * interface TypeV2Attributes {
+ * someKey: string;
+ * newProperty: string;
* }
+ *
+ * const migrateToV2: SavedObjectMigrationFn = (doc, { log }) => {
+ * const { obsoleteProperty, ...otherAttributes } = doc.attributes;
+ * // instead of mutating `doc` we make a shallow copy so that we can use separate types for the input
+ * // and output attributes. We don't need to make a deep copy, we just need to ensure that obsolete
+ * // attributes are not present on the returned doc.
+ * return {
+ * ...doc,
+ * attributes: {
+ * ...otherAttributes,
+ * newProperty: migrate(obsoleteProperty),
+ * },
+ * };
+ * };
* ```
*
* @public
*/
-export type SavedObjectMigrationFn = (
- doc: SavedObjectUnsanitizedDoc,
+export type SavedObjectMigrationFn = (
+ doc: SavedObjectUnsanitizedDoc,
context: SavedObjectMigrationContext
-) => SavedObjectUnsanitizedDoc;
+) => SavedObjectUnsanitizedDoc;
/**
* Migration context provided when invoking a {@link SavedObjectMigrationFn | migration handler}
diff --git a/src/core/server/saved_objects/saved_objects_service.mock.ts b/src/core/server/saved_objects/saved_objects_service.mock.ts
index 7ba4613c857d7..4e1f5981d6a41 100644
--- a/src/core/server/saved_objects/saved_objects_service.mock.ts
+++ b/src/core/server/saved_objects/saved_objects_service.mock.ts
@@ -31,6 +31,7 @@ import { savedObjectsClientProviderMock } from './service/lib/scoped_client_prov
import { savedObjectsRepositoryMock } from './service/lib/repository.mock';
import { savedObjectsClientMock } from './service/saved_objects_client.mock';
import { typeRegistryMock } from './saved_objects_type_registry.mock';
+import { migrationMocks } from './migrations/mocks';
import { ServiceStatusLevels } from '../status';
type SavedObjectsServiceContract = PublicMethodsOf;
@@ -105,4 +106,5 @@ export const savedObjectsServiceMock = {
createSetupContract: createSetupContractMock,
createInternalStartContract: createInternalStartContractMock,
createStartContract: createStartContractMock,
+ createMigrationContext: migrationMocks.createContext,
};
diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts
index a33e16895078e..acd2c7b5284aa 100644
--- a/src/core/server/saved_objects/serialization/types.ts
+++ b/src/core/server/saved_objects/serialization/types.ts
@@ -47,8 +47,8 @@ export interface SavedObjectsRawDocSource {
/**
* Saved Object base document
*/
-interface SavedObjectDoc {
- attributes: any;
+interface SavedObjectDoc {
+ attributes: T;
id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional
type: string;
namespace?: string;
@@ -69,7 +69,7 @@ interface Referencable {
*
* @public
*/
-export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
+export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
/**
* Describes Saved Object documents that have passed through the migration
@@ -77,4 +77,4 @@ export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
*
* @public
*/
-export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
+export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index dc1c9d379d508..e8b77a8570291 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -1680,7 +1680,7 @@ export interface SavedObjectMigrationContext {
}
// @public
-export type SavedObjectMigrationFn = (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;
+export type SavedObjectMigrationFn = (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;
// @public
export interface SavedObjectMigrationMap {
@@ -1708,7 +1708,7 @@ export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOpti
// Warning: (ae-forgotten-export) The symbol "Referencable" needs to be exported by the entry point index.d.ts
//
// @public
-export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
+export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable;
// @public (undocumented)
export interface SavedObjectsBaseOptions {
@@ -2311,7 +2311,7 @@ export class SavedObjectTypeRegistry {
}
// @public
-export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
+export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
// @public
export type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest;
diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js
index 43a2cbd78c502..c5387590fcf66 100644
--- a/src/dev/jest/config.js
+++ b/src/dev/jest/config.js
@@ -40,6 +40,7 @@ export default {
],
collectCoverageFrom: [
'src/plugins/**/*.{ts,tsx}',
+ '!src/plugins/**/{__test__,__snapshots__,__examples__,mocks,tests}/**/*',
'!src/plugins/**/*.d.ts',
'packages/kbn-ui-framework/src/components/**/*.js',
'!packages/kbn-ui-framework/src/components/index.js',
diff --git a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts b/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts
index 6ccbc13aeeb57..6366466103652 100644
--- a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts
+++ b/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts
@@ -21,6 +21,8 @@
// these are necessary to bootstrap the local angular.
// They can stay even after NP cutover
import angular from 'angular';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { EuiIcon } from '@elastic/eui';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import { CoreStart, LegacyCoreStart } from 'kibana/public';
diff --git a/src/legacy/core_plugins/timelion/public/app.js b/src/legacy/core_plugins/timelion/public/app.js
index 7f5c7d4664af8..80ffa440e7285 100644
--- a/src/legacy/core_plugins/timelion/public/app.js
+++ b/src/legacy/core_plugins/timelion/public/app.js
@@ -18,6 +18,8 @@
*/
import _ from 'lodash';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { i18n } from '@kbn/i18n';
diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts
index 0d2f3528c9019..02491ff872981 100644
--- a/src/legacy/server/kbn_server.d.ts
+++ b/src/legacy/server/kbn_server.d.ts
@@ -153,6 +153,7 @@ export default class KbnServer {
public server: Server;
public inject: Server['inject'];
public pluginSpecs: any[];
+ public uiBundles: any;
constructor(
settings: Record,
diff --git a/src/legacy/ui/public/angular_ui_select.js b/src/legacy/ui/public/angular_ui_select.js
index 92b1bf53520ce..99f92587507c9 100644
--- a/src/legacy/ui/public/angular_ui_select.js
+++ b/src/legacy/ui/public/angular_ui_select.js
@@ -19,6 +19,7 @@
import 'jquery';
import 'angular';
+// required for `ngSanitize` angular module
import 'angular-sanitize';
import 'ui-select/dist/select';
diff --git a/src/legacy/ui/public/i18n/index.test.tsx b/src/legacy/ui/public/i18n/index.test.tsx
index c7a778ac18bd3..be8ab4cf8d696 100644
--- a/src/legacy/ui/public/i18n/index.test.tsx
+++ b/src/legacy/ui/public/i18n/index.test.tsx
@@ -21,6 +21,7 @@ import { render } from 'enzyme';
import PropTypes from 'prop-types';
import React from 'react';
+jest.mock('angular-sanitize', () => {});
jest.mock('ui/new_platform', () => ({
npStart: {
core: {
diff --git a/src/legacy/ui/public/i18n/index.tsx b/src/legacy/ui/public/i18n/index.tsx
index c918554563fcb..6f1120dce0c7c 100644
--- a/src/legacy/ui/public/i18n/index.tsx
+++ b/src/legacy/ui/public/i18n/index.tsx
@@ -18,6 +18,8 @@
*/
import React from 'react';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
// @ts-ignore
diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs
index 8a71c6ccb1506..b60442690af3c 100644
--- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs
+++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs
@@ -29,6 +29,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
document.body.innerHTML = err.outerHTML;
}
+ var stylesheetTarget = document.querySelector('head meta[name="add-styles-here"]')
function loadStyleSheet(url, cb) {
var dom = document.createElement('link');
dom.rel = 'stylesheet';
@@ -36,9 +37,10 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
dom.href = url;
dom.addEventListener('error', failure);
dom.addEventListener('load', cb);
- document.head.appendChild(dom);
+ document.head.insertBefore(dom, stylesheetTarget);
}
+ var scriptsTarget = document.querySelector('head meta[name="add-scripts-here"]')
function loadScript(url, cb) {
var dom = document.createElement('script');
{{!-- NOTE: async = false is used to trigger async-download/ordered-execution as outlined here: https://www.html5rocks.com/en/tutorials/speed/script-loading/ --}}
@@ -46,7 +48,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
dom.src = url;
dom.addEventListener('error', failure);
dom.addEventListener('load', cb);
- document.head.appendChild(dom);
+ document.head.insertBefore(dom, scriptsTarget);
}
function load(urls, cb) {
diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js
index 801eecf5b608b..9b44395fa9c68 100644
--- a/src/legacy/ui/ui_render/ui_render_mixin.js
+++ b/src/legacy/ui/ui_render/ui_render_mixin.js
@@ -19,13 +19,15 @@
import { createHash } from 'crypto';
import Boom from 'boom';
-import { resolve } from 'path';
+import Path from 'path';
import { i18n } from '@kbn/i18n';
import * as UiSharedDeps from '@kbn/ui-shared-deps';
import { AppBootstrap } from './bootstrap';
import { getApmConfig } from '../apm';
import { DllCompiler } from '../../../optimize/dynamic_dll_plugin';
+const uniq = (...items) => Array.from(new Set(items));
+
/**
* @typedef {import('../../server/kbn_server').default} KbnServer
* @typedef {import('../../server/kbn_server').ResponseToolkit} ResponseToolkit
@@ -39,7 +41,7 @@ import { DllCompiler } from '../../../optimize/dynamic_dll_plugin';
*/
export function uiRenderMixin(kbnServer, server, config) {
// render all views from ./views
- server.setupViews(resolve(__dirname, 'views'));
+ server.setupViews(Path.resolve(__dirname, 'views'));
const translationsCache = { translations: null, hash: null };
server.route({
@@ -94,9 +96,12 @@ export function uiRenderMixin(kbnServer, server, config) {
? await uiSettings.get('theme:darkMode')
: false;
+ const buildHash = server.newPlatform.env.packageInfo.buildNum;
const basePath = config.get('server.basePath');
- const regularBundlePath = `${basePath}/bundles`;
- const dllBundlePath = `${basePath}/built_assets/dlls`;
+
+ const regularBundlePath = `${basePath}/${buildHash}/bundles`;
+ const dllBundlePath = `${basePath}/${buildHash}/built_assets/dlls`;
+
const dllStyleChunks = DllCompiler.getRawDllConfig().chunks.map(
chunk => `${dllBundlePath}/vendors${chunk}.style.dll.css`
);
@@ -106,15 +111,15 @@ export function uiRenderMixin(kbnServer, server, config) {
const styleSheetPaths = [
...(isCore ? [] : dllStyleChunks),
- `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
+ `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
...(darkMode
? [
- `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`,
+ `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
`${regularBundlePath}/dark_theme.style.css`,
]
: [
- `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
+ `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${regularBundlePath}/light_theme.style.css`,
]),
@@ -129,13 +134,23 @@ export function uiRenderMixin(kbnServer, server, config) {
)
.map(path =>
path.localPath.endsWith('.scss')
- ? `${basePath}/built_assets/css/${path.publicPath}`
+ ? `${basePath}/${buildHash}/built_assets/css/${path.publicPath}`
: `${basePath}/${path.publicPath}`
)
.reverse(),
]),
];
+ const kpPluginIds = uniq(
+ // load these plugins first, they are "shared" and other bundles access their
+ // public/index exports without considering topographic sorting by plugin deps (for now)
+ 'kibanaUtils',
+ 'kibanaReact',
+ 'data',
+ 'esUiShared',
+ ...kbnServer.newPlatform.__internals.uiPlugins.public.keys()
+ );
+
const jsDependencyPaths = [
...UiSharedDeps.jsDepFilenames.map(
filename => `${regularBundlePath}/kbn-ui-shared-deps/${filename}`
@@ -148,20 +163,22 @@ export function uiRenderMixin(kbnServer, server, config) {
...dllJsChunks,
`${regularBundlePath}/commons.bundle.js`,
]),
- `${regularBundlePath}/plugin/kibanaUtils/kibanaUtils.plugin.js`,
- `${regularBundlePath}/plugin/esUiShared/esUiShared.plugin.js`,
- `${regularBundlePath}/plugin/kibanaReact/kibanaReact.plugin.js`,
- ];
- const uiPluginIds = [...kbnServer.newPlatform.__internals.uiPlugins.public.keys()];
+ ...kpPluginIds.map(
+ pluginId => `${regularBundlePath}/plugin/${pluginId}/${pluginId}.plugin.js`
+ ),
+ ];
// These paths should align with the bundle routes configured in
- // src/optimize/bundles_route/bundles_route.js
+ // src/optimize/bundles_route/bundles_route.ts
const publicPathMap = JSON.stringify({
core: `${regularBundlePath}/core/`,
'kbn-ui-shared-deps': `${regularBundlePath}/kbn-ui-shared-deps/`,
- ...uiPluginIds.reduce(
- (acc, pluginId) => ({ ...acc, [pluginId]: `${regularBundlePath}/plugin/${pluginId}/` }),
+ ...kpPluginIds.reduce(
+ (acc, pluginId) => ({
+ ...acc,
+ [pluginId]: `${regularBundlePath}/plugin/${pluginId}/`,
+ }),
{}
),
});
diff --git a/src/optimize/bundles_route/__tests__/bundles_route.js b/src/optimize/bundles_route/__tests__/bundles_route.js
index 0b2aeda11fb0e..902fa59b20569 100644
--- a/src/optimize/bundles_route/__tests__/bundles_route.js
+++ b/src/optimize/bundles_route/__tests__/bundles_route.js
@@ -32,6 +32,7 @@ import { PUBLIC_PATH_PLACEHOLDER } from '../../public_path_placeholder';
const chance = new Chance();
const outputFixture = resolve(__dirname, './fixtures/output');
+const pluginNoPlaceholderFixture = resolve(__dirname, './fixtures/plugin/no_placeholder');
const randomWordsCache = new Set();
const uniqueRandomWord = () => {
@@ -58,6 +59,9 @@ describe('optimizer/bundle route', () => {
dllBundlesPath = outputFixture,
basePublicPath = '',
builtCssPath = outputFixture,
+ npUiPluginPublicDirs = [],
+ buildHash = '1234',
+ isDist = false,
} = options;
const server = new Hapi.Server();
@@ -69,6 +73,9 @@ describe('optimizer/bundle route', () => {
dllBundlesPath,
basePublicPath,
builtCssPath,
+ npUiPluginPublicDirs,
+ buildHash,
+ isDist,
})
);
@@ -158,7 +165,7 @@ describe('optimizer/bundle route', () => {
it('responds with exact file data', async () => {
const server = createServer();
const response = await server.inject({
- url: '/bundles/image.png',
+ url: '/1234/bundles/image.png',
});
expect(response.statusCode).to.be(200);
@@ -173,7 +180,7 @@ describe('optimizer/bundle route', () => {
it('responds with no content-length and exact file data', async () => {
const server = createServer();
const response = await server.inject({
- url: '/bundles/no_placeholder.js',
+ url: '/1234/bundles/no_placeholder.js',
});
expect(response.statusCode).to.be(200);
@@ -187,12 +194,12 @@ describe('optimizer/bundle route', () => {
});
describe('js file with placeholder', () => {
- it('responds with no content-length and modified file data', async () => {
+ it('responds with no content-length and modifiedfile data ', async () => {
const basePublicPath = `/${uniqueRandomWord()}`;
const server = createServer({ basePublicPath });
const response = await server.inject({
- url: '/bundles/with_placeholder.js',
+ url: '/1234/bundles/with_placeholder.js',
});
expect(response.statusCode).to.be(200);
@@ -204,7 +211,7 @@ describe('optimizer/bundle route', () => {
);
expect(response.result.indexOf(source)).to.be(-1);
expect(response.result).to.be(
- replaceAll(source, PUBLIC_PATH_PLACEHOLDER, `${basePublicPath}/bundles/`)
+ replaceAll(source, PUBLIC_PATH_PLACEHOLDER, `${basePublicPath}/1234/bundles/`)
);
});
});
@@ -213,7 +220,7 @@ describe('optimizer/bundle route', () => {
it('responds with no content-length and exact file data', async () => {
const server = createServer();
const response = await server.inject({
- url: '/bundles/no_placeholder.css',
+ url: '/1234/bundles/no_placeholder.css',
});
expect(response.statusCode).to.be(200);
@@ -231,7 +238,7 @@ describe('optimizer/bundle route', () => {
const server = createServer({ basePublicPath });
const response = await server.inject({
- url: '/bundles/with_placeholder.css',
+ url: '/1234/bundles/with_placeholder.css',
});
expect(response.statusCode).to.be(200);
@@ -240,7 +247,7 @@ describe('optimizer/bundle route', () => {
expect(response.headers).to.have.property('content-type', 'text/css; charset=utf-8');
expect(response.result.indexOf(source)).to.be(-1);
expect(response.result).to.be(
- replaceAll(source, PUBLIC_PATH_PLACEHOLDER, `${basePublicPath}/bundles/`)
+ replaceAll(source, PUBLIC_PATH_PLACEHOLDER, `${basePublicPath}/1234/bundles/`)
);
});
});
@@ -250,7 +257,7 @@ describe('optimizer/bundle route', () => {
const server = createServer();
const response = await server.inject({
- url: '/bundles/../outside_output.js',
+ url: '/1234/bundles/../outside_output.js',
});
expect(response.statusCode).to.be(404);
@@ -267,7 +274,7 @@ describe('optimizer/bundle route', () => {
const server = createServer();
const response = await server.inject({
- url: '/bundles/non_existent.js',
+ url: '/1234/bundles/non_existent.js',
});
expect(response.statusCode).to.be(404);
@@ -286,7 +293,7 @@ describe('optimizer/bundle route', () => {
});
const response = await server.inject({
- url: '/bundles/with_placeholder.js',
+ url: '/1234/bundles/with_placeholder.js',
});
expect(response.statusCode).to.be(404);
@@ -306,7 +313,7 @@ describe('optimizer/bundle route', () => {
sinon.assert.notCalled(createHash);
const resp1 = await server.inject({
- url: '/bundles/no_placeholder.js',
+ url: '/1234/bundles/no_placeholder.js',
});
sinon.assert.calledOnce(createHash);
@@ -314,23 +321,23 @@ describe('optimizer/bundle route', () => {
expect(resp1.statusCode).to.be(200);
const resp2 = await server.inject({
- url: '/bundles/no_placeholder.js',
+ url: '/1234/bundles/no_placeholder.js',
});
sinon.assert.notCalled(createHash);
expect(resp2.statusCode).to.be(200);
});
- it('is unique per basePublicPath although content is the same', async () => {
+ it('is unique per basePublicPath although content is the same (by default)', async () => {
const basePublicPath1 = `/${uniqueRandomWord()}`;
const basePublicPath2 = `/${uniqueRandomWord()}`;
const [resp1, resp2] = await Promise.all([
createServer({ basePublicPath: basePublicPath1 }).inject({
- url: '/bundles/no_placeholder.js',
+ url: '/1234/bundles/no_placeholder.js',
}),
createServer({ basePublicPath: basePublicPath2 }).inject({
- url: '/bundles/no_placeholder.js',
+ url: '/1234/bundles/no_placeholder.js',
}),
]);
@@ -349,13 +356,13 @@ describe('optimizer/bundle route', () => {
it('responds with 304 when etag and last modified are sent back', async () => {
const server = createServer();
const resp = await server.inject({
- url: '/bundles/with_placeholder.js',
+ url: '/1234/bundles/with_placeholder.js',
});
expect(resp.statusCode).to.be(200);
const resp2 = await server.inject({
- url: '/bundles/with_placeholder.js',
+ url: '/1234/bundles/with_placeholder.js',
headers: {
'if-modified-since': resp.headers['last-modified'],
'if-none-match': resp.headers.etag,
@@ -366,4 +373,80 @@ describe('optimizer/bundle route', () => {
expect(resp2.result).to.have.length(0);
});
});
+
+ describe('kibana platform assets', () => {
+ describe('caching', () => {
+ describe('for non-distributable mode', () => {
+ it('uses "etag" header to invalidate cache', async () => {
+ const basePublicPath = `/${uniqueRandomWord()}`;
+
+ const npUiPluginPublicDirs = [
+ {
+ id: 'no_placeholder',
+ path: pluginNoPlaceholderFixture,
+ },
+ ];
+ const responce = await createServer({ basePublicPath, npUiPluginPublicDirs }).inject({
+ url: '/1234/bundles/plugin/no_placeholder/no_placeholder.plugin.js',
+ });
+
+ expect(responce.statusCode).to.be(200);
+
+ expect(responce.headers.etag).to.be.a('string');
+ expect(responce.headers['cache-control']).to.be('must-revalidate');
+ });
+
+ it('creates the same "etag" header for the same content with the same basePath', async () => {
+ const npUiPluginPublicDirs = [
+ {
+ id: 'no_placeholder',
+ path: pluginNoPlaceholderFixture,
+ },
+ ];
+ const [resp1, resp2] = await Promise.all([
+ createServer({ basePublicPath: '', npUiPluginPublicDirs }).inject({
+ url: '/1234/bundles/plugin/no_placeholder/no_placeholder.plugin.js',
+ }),
+ createServer({ basePublicPath: '', npUiPluginPublicDirs }).inject({
+ url: '/1234/bundles/plugin/no_placeholder/no_placeholder.plugin.js',
+ }),
+ ]);
+
+ expect(resp1.statusCode).to.be(200);
+ expect(resp2.statusCode).to.be(200);
+
+ expect(resp1.rawPayload).to.eql(resp2.rawPayload);
+
+ expect(resp1.headers.etag).to.be.a('string');
+ expect(resp2.headers.etag).to.be.a('string');
+ expect(resp1.headers.etag).to.eql(resp2.headers.etag);
+ });
+ });
+
+ describe('for distributable mode', () => {
+ it('commands to cache assets for each release for a year', async () => {
+ const basePublicPath = `/${uniqueRandomWord()}`;
+
+ const npUiPluginPublicDirs = [
+ {
+ id: 'no_placeholder',
+ path: pluginNoPlaceholderFixture,
+ },
+ ];
+ const responce = await createServer({
+ basePublicPath,
+ npUiPluginPublicDirs,
+ isDist: true,
+ }).inject({
+ url: '/1234/bundles/plugin/no_placeholder/no_placeholder.plugin.js',
+ });
+
+ expect(responce.statusCode).to.be(200);
+
+ expect(responce.headers.etag).to.be(undefined);
+ expect(responce.headers['cache-control']).to.be('max-age=31536000');
+ });
+ });
+ });
+ });
});
diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.ts
similarity index 74%
rename from src/optimize/bundles_route/bundles_route.js
rename to src/optimize/bundles_route/bundles_route.ts
index 4030988c8552c..e9cfba0130d95 100644
--- a/src/optimize/bundles_route/bundles_route.js
+++ b/src/optimize/bundles_route/bundles_route.ts
@@ -18,10 +18,13 @@
*/
import { isAbsolute, extname, join } from 'path';
-import LruCache from 'lru-cache';
+
+import Hapi from 'hapi';
import * as UiSharedDeps from '@kbn/ui-shared-deps';
+
import { createDynamicAssetResponse } from './dynamic_asset_response';
-import { assertIsNpUiPluginPublicDirs } from '../np_ui_plugin_public_dirs';
+import { FileHashCache } from './file_hash_cache';
+import { assertIsNpUiPluginPublicDirs, NpUiPluginPublicDirs } from '../np_ui_plugin_public_dirs';
import { fromRoot } from '../../core/server/utils';
/**
@@ -44,11 +47,21 @@ export function createBundlesRoute({
basePublicPath,
builtCssPath,
npUiPluginPublicDirs = [],
+ buildHash,
+ isDist = false,
+}: {
+ regularBundlesPath: string;
+ dllBundlesPath: string;
+ basePublicPath: string;
+ builtCssPath: string;
+ npUiPluginPublicDirs?: NpUiPluginPublicDirs;
+ buildHash: string;
+ isDist?: boolean;
}) {
// rather than calculate the fileHash on every request, we
// provide a cache object to `resolveDynamicAssetResponse()` that
// will store the 100 most recently used hashes.
- const fileHashCache = new LruCache(100);
+ const fileHashCache = new FileHashCache();
assertIsNpUiPluginPublicDirs(npUiPluginPublicDirs);
if (typeof regularBundlesPath !== 'string' || !isAbsolute(regularBundlesPath)) {
@@ -73,45 +86,51 @@ export function createBundlesRoute({
return [
buildRouteForBundles({
- publicPath: `${basePublicPath}/bundles/kbn-ui-shared-deps/`,
- routePath: '/bundles/kbn-ui-shared-deps/',
+ publicPath: `${basePublicPath}/${buildHash}/bundles/kbn-ui-shared-deps/`,
+ routePath: `/${buildHash}/bundles/kbn-ui-shared-deps/`,
bundlesPath: UiSharedDeps.distDir,
fileHashCache,
replacePublicPath: false,
+ isDist,
}),
...npUiPluginPublicDirs.map(({ id, path }) =>
buildRouteForBundles({
- publicPath: `${basePublicPath}/bundles/plugin/${id}/`,
- routePath: `/bundles/plugin/${id}/`,
+ publicPath: `${basePublicPath}/${buildHash}/bundles/plugin/${id}/`,
+ routePath: `/${buildHash}/bundles/plugin/${id}/`,
bundlesPath: path,
fileHashCache,
replacePublicPath: false,
+ isDist,
})
),
buildRouteForBundles({
- publicPath: `${basePublicPath}/bundles/core/`,
- routePath: `/bundles/core/`,
+ publicPath: `${basePublicPath}/${buildHash}/bundles/core/`,
+ routePath: `/${buildHash}/bundles/core/`,
bundlesPath: fromRoot(join('src', 'core', 'target', 'public')),
fileHashCache,
replacePublicPath: false,
+ isDist,
}),
buildRouteForBundles({
- publicPath: `${basePublicPath}/bundles/`,
- routePath: '/bundles/',
+ publicPath: `${basePublicPath}/${buildHash}/bundles/`,
+ routePath: `/${buildHash}/bundles/`,
bundlesPath: regularBundlesPath,
fileHashCache,
+ isDist,
}),
buildRouteForBundles({
- publicPath: `${basePublicPath}/built_assets/dlls/`,
- routePath: '/built_assets/dlls/',
+ publicPath: `${basePublicPath}/${buildHash}/built_assets/dlls/`,
+ routePath: `/${buildHash}/built_assets/dlls/`,
bundlesPath: dllBundlesPath,
fileHashCache,
+ isDist,
}),
buildRouteForBundles({
publicPath: `${basePublicPath}/`,
- routePath: '/built_assets/css/',
+ routePath: `/${buildHash}/built_assets/css/`,
bundlesPath: builtCssPath,
fileHashCache,
+ isDist,
}),
];
}
@@ -122,6 +141,14 @@ function buildRouteForBundles({
bundlesPath,
fileHashCache,
replacePublicPath = true,
+ isDist,
+}: {
+ publicPath: string;
+ routePath: string;
+ bundlesPath: string;
+ fileHashCache: FileHashCache;
+ replacePublicPath?: boolean;
+ isDist: boolean;
}) {
return {
method: 'GET',
@@ -130,7 +157,7 @@ function buildRouteForBundles({
auth: false,
ext: {
onPreHandler: {
- method(request, h) {
+ method(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const ext = extname(request.params.path);
if (ext !== '.js' && ext !== '.css') {
@@ -144,6 +171,7 @@ function buildRouteForBundles({
fileHashCache,
publicPath,
replacePublicPath,
+ isDist,
});
},
},
diff --git a/src/optimize/bundles_route/dynamic_asset_response.js b/src/optimize/bundles_route/dynamic_asset_response.ts
similarity index 67%
rename from src/optimize/bundles_route/dynamic_asset_response.js
rename to src/optimize/bundles_route/dynamic_asset_response.ts
index 80c49a26270fd..a020c6935eeec 100644
--- a/src/optimize/bundles_route/dynamic_asset_response.js
+++ b/src/optimize/bundles_route/dynamic_asset_response.ts
@@ -17,15 +17,26 @@
* under the License.
*/
+import Fs from 'fs';
import { resolve } from 'path';
-import { open, fstat, createReadStream, close } from 'fs';
+import { promisify } from 'util';
import Boom from 'boom';
-import { fromNode as fcb } from 'bluebird';
+import Hapi from 'hapi';
+import { FileHashCache } from './file_hash_cache';
import { getFileHash } from './file_hash';
+// @ts-ignore
import { replacePlaceholder } from '../public_path_placeholder';
+const MINUTE = 60;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+const asyncOpen = promisify(Fs.open);
+const asyncClose = promisify(Fs.close);
+const asyncFstat = promisify(Fs.fstat);
+
/**
* Create a Hapi response for the requested path. This is designed
* to replicate a subset of the features provided by Hapi's Inert
@@ -44,55 +55,70 @@ import { replacePlaceholder } from '../public_path_placeholder';
* - cached hash/etag is based on the file on disk, but modified
* by the public path so that individual public paths have
* different etags, but can share a cache
- *
- * @param {Object} options
- * @property {Hapi.Request} options.request
- * @property {string} options.bundlesPath
- * @property {string} options.publicPath
- * @property {LruCache} options.fileHashCache
*/
-export async function createDynamicAssetResponse(options) {
- const { request, h, bundlesPath, publicPath, fileHashCache, replacePublicPath } = options;
+export async function createDynamicAssetResponse({
+ request,
+ h,
+ bundlesPath,
+ publicPath,
+ fileHashCache,
+ replacePublicPath,
+ isDist,
+}: {
+ request: Hapi.Request;
+ h: Hapi.ResponseToolkit;
+ bundlesPath: string;
+ publicPath: string;
+ fileHashCache: FileHashCache;
+ replacePublicPath: boolean;
+ isDist: boolean;
+}) {
+ let fd: number | undefined;
- let fd;
try {
const path = resolve(bundlesPath, request.params.path);
// prevent path traversal, only process paths that resolve within bundlesPath
if (!path.startsWith(bundlesPath)) {
- throw Boom.forbidden(null, 'EACCES');
+ throw Boom.forbidden(undefined, 'EACCES');
}
// we use and manage a file descriptor mostly because
// that's what Inert does, and since we are accessing
// the file 2 or 3 times per request it seems logical
- fd = await fcb(cb => open(path, 'r', cb));
+ fd = await asyncOpen(path, 'r');
- const stat = await fcb(cb => fstat(fd, cb));
- const hash = await getFileHash(fileHashCache, path, stat, fd);
+ const stat = await asyncFstat(fd);
+ const hash = isDist ? undefined : await getFileHash(fileHashCache, path, stat, fd);
- const read = createReadStream(null, {
+ const read = Fs.createReadStream(null as any, {
fd,
start: 0,
autoClose: true,
});
- fd = null; // read stream is now responsible for fd
+ fd = undefined; // read stream is now responsible for fd
const content = replacePublicPath ? replacePlaceholder(read, publicPath) : read;
- const etag = replacePublicPath ? `${hash}-${publicPath}` : hash;
- return h
+ const response = h
.response(content)
.takeover()
.code(200)
- .etag(etag)
- .header('cache-control', 'must-revalidate')
.type(request.server.mime.path(path).type);
+
+ if (isDist) {
+ response.header('cache-control', `max-age=${365 * DAY}`);
+ } else {
+ response.etag(`${hash}-${publicPath}`);
+ response.header('cache-control', 'must-revalidate');
+ }
+
+ return response;
} catch (error) {
if (fd) {
try {
- await fcb(cb => close(fd, cb));
- } catch (error) {
+ await asyncClose(fd);
+ } catch (_) {
// ignore errors from close, we already have one to report
// and it's very likely they are the same
}
diff --git a/src/optimize/bundles_route/file_hash.js b/src/optimize/bundles_route/file_hash.ts
similarity index 73%
rename from src/optimize/bundles_route/file_hash.js
rename to src/optimize/bundles_route/file_hash.ts
index d9464cf05eca1..7b0801098ed10 100644
--- a/src/optimize/bundles_route/file_hash.js
+++ b/src/optimize/bundles_route/file_hash.ts
@@ -18,20 +18,17 @@
*/
import { createHash } from 'crypto';
-import { createReadStream } from 'fs';
+import Fs from 'fs';
import * as Rx from 'rxjs';
-import { merge, mergeMap, takeUntil } from 'rxjs/operators';
+import { takeUntil, map } from 'rxjs/operators';
+
+import { FileHashCache } from './file_hash_cache';
/**
* Get the hash of a file via a file descriptor
- * @param {LruCache} cache
- * @param {string} path
- * @param {Fs.Stat} stat
- * @param {Fs.FileDescriptor} fd
- * @return {Promise}
*/
-export async function getFileHash(cache, path, stat, fd) {
+export async function getFileHash(cache: FileHashCache, path: string, stat: Fs.Stats, fd: number) {
const key = `${path}:${stat.ino}:${stat.size}:${stat.mtime.getTime()}`;
const cached = cache.get(key);
@@ -40,17 +37,21 @@ export async function getFileHash(cache, path, stat, fd) {
}
const hash = createHash('sha1');
- const read = createReadStream(null, {
+ const read = Fs.createReadStream(null as any, {
fd,
start: 0,
autoClose: false,
});
- const promise = Rx.fromEvent(read, 'data')
- .pipe(
- merge(Rx.fromEvent(read, 'error').pipe(mergeMap(Rx.throwError))),
- takeUntil(Rx.fromEvent(read, 'end'))
+ const promise = Rx.merge(
+ Rx.fromEvent(read, 'data'),
+ Rx.fromEvent(read, 'error').pipe(
+ map(error => {
+ throw error;
+ })
)
+ )
+ .pipe(takeUntil(Rx.fromEvent(read, 'end')))
.forEach(chunk => hash.update(chunk))
.then(() => hash.digest('hex'))
.catch(error => {
diff --git a/src/optimize/bundles_route/file_hash_cache.ts b/src/optimize/bundles_route/file_hash_cache.ts
new file mode 100644
index 0000000000000..a7cdabbff13a7
--- /dev/null
+++ b/src/optimize/bundles_route/file_hash_cache.ts
@@ -0,0 +1,36 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import LruCache from 'lru-cache';
+
+export class FileHashCache {
+ private lru = new LruCache>(100);
+
+ get(key: string) {
+ return this.lru.get(key);
+ }
+
+ set(key: string, value: Promise) {
+ this.lru.set(key, value);
+ }
+
+ del(key: string) {
+ this.lru.del(key);
+ }
+}
diff --git a/src/optimize/bundles_route/index.js b/src/optimize/bundles_route/index.ts
similarity index 100%
rename from src/optimize/bundles_route/index.js
rename to src/optimize/bundles_route/index.ts
diff --git a/src/optimize/bundles_route/proxy_bundles_route.js b/src/optimize/bundles_route/proxy_bundles_route.ts
similarity index 70%
rename from src/optimize/bundles_route/proxy_bundles_route.js
rename to src/optimize/bundles_route/proxy_bundles_route.ts
index fff0ec444d95b..1d189054324a1 100644
--- a/src/optimize/bundles_route/proxy_bundles_route.js
+++ b/src/optimize/bundles_route/proxy_bundles_route.ts
@@ -17,15 +17,23 @@
* under the License.
*/
-export function createProxyBundlesRoute({ host, port }) {
+export function createProxyBundlesRoute({
+ host,
+ port,
+ buildHash,
+}: {
+ host: string;
+ port: number;
+ buildHash: string;
+}) {
return [
- buildProxyRouteForBundles('/bundles/', host, port),
- buildProxyRouteForBundles('/built_assets/dlls/', host, port),
- buildProxyRouteForBundles('/built_assets/css/', host, port),
+ buildProxyRouteForBundles(`/${buildHash}/bundles/`, host, port),
+ buildProxyRouteForBundles(`/${buildHash}/built_assets/dlls/`, host, port),
+ buildProxyRouteForBundles(`/${buildHash}/built_assets/css/`, host, port),
];
}
-function buildProxyRouteForBundles(routePath, host, port) {
+function buildProxyRouteForBundles(routePath: string, host: string, port: number) {
return {
path: `${routePath}{path*}`,
method: 'GET',
diff --git a/src/optimize/index.js b/src/optimize/index.js
index b7b9f7712358a..363f81a6a3a96 100644
--- a/src/optimize/index.js
+++ b/src/optimize/index.js
@@ -17,72 +17,5 @@
* under the License.
*/
-import FsOptimizer from './fs_optimizer';
-import { createBundlesRoute } from './bundles_route';
-import { DllCompiler } from './dynamic_dll_plugin';
-import { fromRoot } from '../core/server/utils';
-import { getNpUiPluginPublicDirs } from './np_ui_plugin_public_dirs';
-
-export default async (kbnServer, server, config) => {
- if (!config.get('optimize.enabled')) return;
-
- // the watch optimizer sets up two threads, one is the server listening
- // on 5601 and the other is a server listening on 5602 that builds the
- // bundles in a "middleware" style.
- //
- // the server listening on 5601 may be restarted a number of times, depending
- // on the watch setup managed by the cli. It proxies all bundles/* and built_assets/dlls/*
- // requests to the other server. The server on 5602 is long running, in order
- // to prevent complete rebuilds of the optimize content.
- const watch = config.get('optimize.watch');
- if (watch) {
- return await kbnServer.mixin(require('./watch/watch'));
- }
-
- const { uiBundles } = kbnServer;
- server.route(
- createBundlesRoute({
- regularBundlesPath: uiBundles.getWorkingDir(),
- dllBundlesPath: DllCompiler.getRawDllConfig().outputPath,
- basePublicPath: config.get('server.basePath'),
- builtCssPath: fromRoot('built_assets/css'),
- npUiPluginPublicDirs: getNpUiPluginPublicDirs(kbnServer),
- })
- );
-
- // in prod, only bundle when something is missing or invalid
- const reuseCache = config.get('optimize.useBundleCache')
- ? await uiBundles.areAllBundleCachesValid()
- : false;
-
- // we might not have any work to do
- if (reuseCache) {
- server.log(['debug', 'optimize'], `All bundles are cached and ready to go!`);
- return;
- }
-
- await uiBundles.resetBundleDir();
-
- // only require the FsOptimizer when we need to
- const optimizer = new FsOptimizer({
- logWithMetadata: (tags, message, metadata) => server.logWithMetadata(tags, message, metadata),
- uiBundles,
- profile: config.get('optimize.profile'),
- sourceMaps: config.get('optimize.sourceMaps'),
- workers: config.get('optimize.workers'),
- });
-
- server.log(
- ['info', 'optimize'],
- `Optimizing and caching ${uiBundles.getDescription()}. This may take a few minutes`
- );
-
- const start = Date.now();
- await optimizer.run();
- const seconds = ((Date.now() - start) / 1000).toFixed(2);
-
- server.log(
- ['info', 'optimize'],
- `Optimization of ${uiBundles.getDescription()} complete in ${seconds} seconds`
- );
-};
+import { optimizeMixin } from './optimize_mixin';
+export default optimizeMixin;
diff --git a/src/optimize/np_ui_plugin_public_dirs.js b/src/optimize/np_ui_plugin_public_dirs.ts
similarity index 73%
rename from src/optimize/np_ui_plugin_public_dirs.js
rename to src/optimize/np_ui_plugin_public_dirs.ts
index de05fd2b863b8..e7c3207948f6a 100644
--- a/src/optimize/np_ui_plugin_public_dirs.js
+++ b/src/optimize/np_ui_plugin_public_dirs.ts
@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+import KbnServer from '../legacy/server/kbn_server';
-export function getNpUiPluginPublicDirs(kbnServer) {
+export type NpUiPluginPublicDirs = Array<{
+ id: string;
+ path: string;
+}>;
+
+export function getNpUiPluginPublicDirs(kbnServer: KbnServer): NpUiPluginPublicDirs {
return Array.from(kbnServer.newPlatform.__internals.uiPlugins.internal.entries()).map(
([id, { publicTargetDir }]) => ({
id,
@@ -26,17 +32,17 @@ export function getNpUiPluginPublicDirs(kbnServer) {
);
}
-export function isNpUiPluginPublicDirs(something) {
+export function isNpUiPluginPublicDirs(x: any): x is NpUiPluginPublicDirs {
return (
- Array.isArray(something) &&
- something.every(
+ Array.isArray(x) &&
+ x.every(
s => typeof s === 'object' && s && typeof s.id === 'string' && typeof s.path === 'string'
)
);
}
-export function assertIsNpUiPluginPublicDirs(something) {
- if (!isNpUiPluginPublicDirs(something)) {
+export function assertIsNpUiPluginPublicDirs(x: any): asserts x is NpUiPluginPublicDirs {
+ if (!isNpUiPluginPublicDirs(x)) {
throw new TypeError(
'npUiPluginPublicDirs must be an array of objects with string `id` and `path` properties'
);
diff --git a/src/optimize/optimize_mixin.ts b/src/optimize/optimize_mixin.ts
new file mode 100644
index 0000000000000..9a3f08e2f667e
--- /dev/null
+++ b/src/optimize/optimize_mixin.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import Hapi from 'hapi';
+
+// @ts-ignore not TS yet
+import FsOptimizer from './fs_optimizer';
+import { createBundlesRoute } from './bundles_route';
+// @ts-ignore not TS yet
+import { DllCompiler } from './dynamic_dll_plugin';
+import { fromRoot } from '../core/server/utils';
+import { getNpUiPluginPublicDirs } from './np_ui_plugin_public_dirs';
+import KbnServer, { KibanaConfig } from '../legacy/server/kbn_server';
+
+export const optimizeMixin = async (
+ kbnServer: KbnServer,
+ server: Hapi.Server,
+ config: KibanaConfig
+) => {
+ if (!config.get('optimize.enabled')) return;
+
+ // the watch optimizer sets up two threads, one is the server listening
+ // on 5601 and the other is a server listening on 5602 that builds the
+ // bundles in a "middleware" style.
+ //
+ // the server listening on 5601 may be restarted a number of times, depending
+ // on the watch setup managed by the cli. It proxies all bundles/* and built_assets/dlls/*
+ // requests to the other server. The server on 5602 is long running, in order
+ // to prevent complete rebuilds of the optimize content.
+ const watch = config.get('optimize.watch');
+ if (watch) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ return await kbnServer.mixin(require('./watch/watch'));
+ }
+
+ const { uiBundles } = kbnServer;
+ server.route(
+ createBundlesRoute({
+ regularBundlesPath: uiBundles.getWorkingDir(),
+ dllBundlesPath: DllCompiler.getRawDllConfig().outputPath,
+ basePublicPath: config.get('server.basePath'),
+ builtCssPath: fromRoot('built_assets/css'),
+ npUiPluginPublicDirs: getNpUiPluginPublicDirs(kbnServer),
+ buildHash: kbnServer.newPlatform.env.packageInfo.buildNum.toString(),
+ isDist: kbnServer.newPlatform.env.packageInfo.dist,
+ })
+ );
+
+ // in prod, only bundle when something is missing or invalid
+ const reuseCache = config.get('optimize.useBundleCache')
+ ? await uiBundles.areAllBundleCachesValid()
+ : false;
+
+ // we might not have any work to do
+ if (reuseCache) {
+ server.log(['debug', 'optimize'], `All bundles are cached and ready to go!`);
+ return;
+ }
+
+ await uiBundles.resetBundleDir();
+
+ // only require the FsOptimizer when we need to
+ const optimizer = new FsOptimizer({
+ logWithMetadata: server.logWithMetadata,
+ uiBundles,
+ profile: config.get('optimize.profile'),
+ sourceMaps: config.get('optimize.sourceMaps'),
+ workers: config.get('optimize.workers'),
+ });
+
+ server.log(
+ ['info', 'optimize'],
+ `Optimizing and caching ${uiBundles.getDescription()}. This may take a few minutes`
+ );
+
+ const start = Date.now();
+ await optimizer.run();
+ const seconds = ((Date.now() - start) / 1000).toFixed(2);
+
+ server.log(
+ ['info', 'optimize'],
+ `Optimization of ${uiBundles.getDescription()} complete in ${seconds} seconds`
+ );
+};
diff --git a/src/optimize/public_path_placeholder.js b/src/optimize/public_path_placeholder.ts
similarity index 76%
rename from src/optimize/public_path_placeholder.js
rename to src/optimize/public_path_placeholder.ts
index ef05d9e5ae704..1ec2b4a431aa6 100644
--- a/src/optimize/public_path_placeholder.js
+++ b/src/optimize/public_path_placeholder.ts
@@ -17,14 +17,20 @@
* under the License.
*/
-import { createReplaceStream } from '../legacy/utils';
+import Stream from 'stream';
+import Fs from 'fs';
import * as Rx from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
+import { createReplaceStream } from '../legacy/utils';
export const PUBLIC_PATH_PLACEHOLDER = '__REPLACE_WITH_PUBLIC_PATH__';
-export function replacePlaceholder(read, replacement) {
+interface ClosableTransform extends Stream.Transform {
+ close(): void;
+}
+
+export function replacePlaceholder(read: Stream.Readable, replacement: string) {
const replace = createReplaceStream(PUBLIC_PATH_PLACEHOLDER, replacement);
// handle errors on the read stream by proxying them
@@ -37,13 +43,15 @@ export function replacePlaceholder(read, replacement) {
replace.end();
});
- replace.close = () => {
- read.unpipe();
+ const closableReplace: ClosableTransform = Object.assign(replace, {
+ close: () => {
+ read.unpipe();
- if (read.close) {
- read.close();
- }
- };
+ if ('close' in read) {
+ (read as Fs.ReadStream).close();
+ }
+ },
+ });
- return read.pipe(replace);
+ return read.pipe(closableReplace);
}
diff --git a/src/optimize/watch/optmzr_role.js b/src/optimize/watch/optmzr_role.js
index a31ef7229e5da..1f6107996277c 100644
--- a/src/optimize/watch/optmzr_role.js
+++ b/src/optimize/watch/optmzr_role.js
@@ -49,7 +49,8 @@ export default async (kbnServer, kibanaHapiServer, config) => {
config.get('optimize.watchPort'),
config.get('server.basePath'),
watchOptimizer,
- getNpUiPluginPublicDirs(kbnServer)
+ getNpUiPluginPublicDirs(kbnServer),
+ kbnServer.newPlatform.env.packageInfo.buildNum.toString()
);
watchOptimizer.status$.subscribe({
diff --git a/src/optimize/watch/proxy_role.js b/src/optimize/watch/proxy_role.js
index 6093658ae1a2d..0f6f3b2d4b622 100644
--- a/src/optimize/watch/proxy_role.js
+++ b/src/optimize/watch/proxy_role.js
@@ -26,6 +26,7 @@ export default (kbnServer, server, config) => {
createProxyBundlesRoute({
host: config.get('optimize.watchHost'),
port: config.get('optimize.watchPort'),
+ buildHash: kbnServer.newPlatform.env.packageInfo.buildNum.toString(),
})
);
diff --git a/src/optimize/watch/watch_optimizer.js b/src/optimize/watch/watch_optimizer.js
index 6c20f21c7768e..cdff57a00c2e0 100644
--- a/src/optimize/watch/watch_optimizer.js
+++ b/src/optimize/watch/watch_optimizer.js
@@ -106,7 +106,7 @@ export default class WatchOptimizer extends BaseOptimizer {
});
}
- bindToServer(server, basePath, npUiPluginPublicDirs) {
+ bindToServer(server, basePath, npUiPluginPublicDirs, buildHash) {
// pause all requests received while the compiler is running
// and continue once an outcome is reached (aborting the request
// with an error if it was a failure).
@@ -118,6 +118,7 @@ export default class WatchOptimizer extends BaseOptimizer {
server.route(
createBundlesRoute({
npUiPluginPublicDirs: npUiPluginPublicDirs,
+ buildHash,
regularBundlesPath: this.compiler.outputPath,
dllBundlesPath: DllCompiler.getRawDllConfig().outputPath,
basePublicPath: basePath,
diff --git a/src/optimize/watch/watch_server.js b/src/optimize/watch/watch_server.js
index 74a96dc8aea6e..81e04a5b83956 100644
--- a/src/optimize/watch/watch_server.js
+++ b/src/optimize/watch/watch_server.js
@@ -21,10 +21,11 @@ import { Server } from 'hapi';
import { registerHapiPlugins } from '../../legacy/server/http/register_hapi_plugins';
export default class WatchServer {
- constructor(host, port, basePath, optimizer, npUiPluginPublicDirs) {
+ constructor(host, port, basePath, optimizer, npUiPluginPublicDirs, buildHash) {
this.basePath = basePath;
this.optimizer = optimizer;
this.npUiPluginPublicDirs = npUiPluginPublicDirs;
+ this.buildHash = buildHash;
this.server = new Server({
host: host,
port: port,
@@ -35,7 +36,12 @@ export default class WatchServer {
async init() {
await this.optimizer.init();
- this.optimizer.bindToServer(this.server, this.basePath, this.npUiPluginPublicDirs);
+ this.optimizer.bindToServer(
+ this.server,
+ this.basePath,
+ this.npUiPluginPublicDirs,
+ this.buildHash
+ );
await this.server.start();
}
}
diff --git a/src/plugins/dashboard/public/application/application.ts b/src/plugins/dashboard/public/application/application.ts
index a1696298117b0..37f014d836075 100644
--- a/src/plugins/dashboard/public/application/application.ts
+++ b/src/plugins/dashboard/public/application/application.ts
@@ -21,6 +21,8 @@ import './index.scss';
import { EuiIcon } from '@elastic/eui';
import angular, { IModule } from 'angular';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import {
AppMountContext,
diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx
index 5f6b67ee6ad20..7de054f2eaa9c 100644
--- a/src/plugins/dashboard/public/plugin.tsx
+++ b/src/plugins/dashboard/public/plugin.tsx
@@ -141,10 +141,14 @@ export class DashboardPlugin
if (share) {
share.urlGenerators.registerUrlGenerator(
- createDirectAccessDashboardLinkGenerator(async () => ({
- appBasePath: (await startServices)[0].application.getUrlForApp('dashboard'),
- useHashedUrl: (await startServices)[0].uiSettings.get('state:storeInSessionStorage'),
- }))
+ createDirectAccessDashboardLinkGenerator(async () => {
+ const [coreStart, , selfStart] = await startServices;
+ return {
+ appBasePath: coreStart.application.getUrlForApp('dashboard'),
+ useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'),
+ savedDashboardLoader: selfStart.getSavedDashboardLoader(),
+ };
+ })
);
}
diff --git a/src/plugins/dashboard/public/url_generator.test.ts b/src/plugins/dashboard/public/url_generator.test.ts
index d48aacc1d8c1e..248a3f991d6cb 100644
--- a/src/plugins/dashboard/public/url_generator.test.ts
+++ b/src/plugins/dashboard/public/url_generator.test.ts
@@ -21,10 +21,33 @@ import { createDirectAccessDashboardLinkGenerator } from './url_generator';
import { hashedItemStore } from '../../kibana_utils/public';
// eslint-disable-next-line
import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock';
-import { esFilters } from '../../data/public';
+import { esFilters, Filter } from '../../data/public';
+import { SavedObjectLoader } from '../../saved_objects/public';
const APP_BASE_PATH: string = 'xyz/app/kibana';
+const createMockDashboardLoader = (
+ dashboardToFilters: {
+ [dashboardId: string]: () => Filter[];
+ } = {}
+) => {
+ return {
+ get: async (dashboardId: string) => {
+ return {
+ searchSource: {
+ getField: (field: string) => {
+ if (field === 'filter')
+ return dashboardToFilters[dashboardId] ? dashboardToFilters[dashboardId]() : [];
+ throw new Error(
+ `createMockDashboardLoader > searchSource > getField > ${field} is not mocked`
+ );
+ },
+ },
+ };
+ },
+ } as SavedObjectLoader;
+};
+
describe('dashboard url generator', () => {
beforeEach(() => {
// @ts-ignore
@@ -33,7 +56,11 @@ describe('dashboard url generator', () => {
test('creates a link to a saved dashboard', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({});
expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard?_a=()&_g=()"`);
@@ -41,7 +68,11 @@ describe('dashboard url generator', () => {
test('creates a link with global time range set up', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -53,7 +84,11 @@ describe('dashboard url generator', () => {
test('creates a link with filters, time range, refresh interval and query to a saved object', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -89,7 +124,11 @@ describe('dashboard url generator', () => {
test('if no useHash setting is given, uses the one was start services', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: true,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -99,7 +138,11 @@ describe('dashboard url generator', () => {
test('can override a false useHash ui setting', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -110,7 +153,11 @@ describe('dashboard url generator', () => {
test('can override a true useHash ui setting', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
- Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true })
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: true,
+ savedDashboardLoader: createMockDashboardLoader(),
+ })
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -118,4 +165,150 @@ describe('dashboard url generator', () => {
});
expect(url.indexOf('relative')).toBeGreaterThan(1);
});
+
+ describe('preserving saved filters', () => {
+ const savedFilter1 = {
+ meta: {
+ alias: null,
+ disabled: false,
+ negate: false,
+ },
+ query: { query: 'savedfilter1' },
+ };
+
+ const savedFilter2 = {
+ meta: {
+ alias: null,
+ disabled: false,
+ negate: false,
+ },
+ query: { query: 'savedfilter2' },
+ };
+
+ const appliedFilter = {
+ meta: {
+ alias: null,
+ disabled: false,
+ negate: false,
+ },
+ query: { query: 'appliedfilter' },
+ };
+
+ test('attaches filters from destination dashboard', async () => {
+ const generator = createDirectAccessDashboardLinkGenerator(() =>
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader({
+ ['dashboard1']: () => [savedFilter1],
+ ['dashboard2']: () => [savedFilter2],
+ }),
+ })
+ );
+
+ const urlToDashboard1 = await generator.createUrl!({
+ dashboardId: 'dashboard1',
+ filters: [appliedFilter],
+ });
+
+ expect(urlToDashboard1).toEqual(expect.stringContaining('query:savedfilter1'));
+ expect(urlToDashboard1).toEqual(expect.stringContaining('query:appliedfilter'));
+
+ const urlToDashboard2 = await generator.createUrl!({
+ dashboardId: 'dashboard2',
+ filters: [appliedFilter],
+ });
+
+ expect(urlToDashboard2).toEqual(expect.stringContaining('query:savedfilter2'));
+ expect(urlToDashboard2).toEqual(expect.stringContaining('query:appliedfilter'));
+ });
+
+ test("doesn't fail if can't retrieve filters from destination dashboard", async () => {
+ const generator = createDirectAccessDashboardLinkGenerator(() =>
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader({
+ ['dashboard1']: () => {
+ throw new Error('Not found');
+ },
+ }),
+ })
+ );
+
+ const url = await generator.createUrl!({
+ dashboardId: 'dashboard1',
+ filters: [appliedFilter],
+ });
+
+ expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
+ expect(url).toEqual(expect.stringContaining('query:appliedfilter'));
+ });
+
+ test('can enforce empty filters', async () => {
+ const generator = createDirectAccessDashboardLinkGenerator(() =>
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader({
+ ['dashboard1']: () => [savedFilter1],
+ }),
+ })
+ );
+
+ const url = await generator.createUrl!({
+ dashboardId: 'dashboard1',
+ filters: [],
+ preserveSavedFilters: false,
+ });
+
+ expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
+ expect(url).not.toEqual(expect.stringContaining('query:appliedfilter'));
+ expect(url).toMatchInlineSnapshot(
+ `"xyz/app/kibana#/dashboard/dashboard1?_a=(filters:!())&_g=(filters:!())"`
+ );
+ });
+
+ test('no filters in result url if no filters applied', async () => {
+ const generator = createDirectAccessDashboardLinkGenerator(() =>
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader({
+ ['dashboard1']: () => [savedFilter1],
+ }),
+ })
+ );
+
+ const url = await generator.createUrl!({
+ dashboardId: 'dashboard1',
+ });
+ expect(url).not.toEqual(expect.stringContaining('filters'));
+ expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard/dashboard1?_a=()&_g=()"`);
+ });
+
+ test('can turn off preserving filters', async () => {
+ const generator = createDirectAccessDashboardLinkGenerator(() =>
+ Promise.resolve({
+ appBasePath: APP_BASE_PATH,
+ useHashedUrl: false,
+ savedDashboardLoader: createMockDashboardLoader({
+ ['dashboard1']: () => [savedFilter1],
+ }),
+ })
+ );
+ const urlWithPreservedFiltersTurnedOff = await generator.createUrl!({
+ dashboardId: 'dashboard1',
+ filters: [appliedFilter],
+ preserveSavedFilters: false,
+ });
+
+ expect(urlWithPreservedFiltersTurnedOff).not.toEqual(
+ expect.stringContaining('query:savedfilter1')
+ );
+ expect(urlWithPreservedFiltersTurnedOff).toEqual(
+ expect.stringContaining('query:appliedfilter')
+ );
+ });
+ });
});
diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts
index 0fdf395e75bca..6f121ceb2d373 100644
--- a/src/plugins/dashboard/public/url_generator.ts
+++ b/src/plugins/dashboard/public/url_generator.ts
@@ -27,6 +27,7 @@ import {
} from '../../data/public';
import { setStateToKbnUrl } from '../../kibana_utils/public';
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
+import { SavedObjectLoader } from '../../saved_objects/public';
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
@@ -64,10 +65,22 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{
* whether to hash the data in the url to avoid url length issues.
*/
useHash?: boolean;
+
+ /**
+ * When `true` filters from saved filters from destination dashboard as merged with applied filters
+ * When `false` applied filters take precedence and override saved filters
+ *
+ * true is default
+ */
+ preserveSavedFilters?: boolean;
}>;
export const createDirectAccessDashboardLinkGenerator = (
- getStartServices: () => Promise<{ appBasePath: string; useHashedUrl: boolean }>
+ getStartServices: () => Promise<{
+ appBasePath: string;
+ useHashedUrl: boolean;
+ savedDashboardLoader: SavedObjectLoader;
+ }>
): UrlGeneratorsDefinition => ({
id: DASHBOARD_APP_URL_GENERATOR,
createUrl: async state => {
@@ -76,6 +89,19 @@ export const createDirectAccessDashboardLinkGenerator = (
const appBasePath = startServices.appBasePath;
const hash = state.dashboardId ? `dashboard/${state.dashboardId}` : `dashboard`;
+ const getSavedFiltersFromDestinationDashboardIfNeeded = async (): Promise => {
+ if (state.preserveSavedFilters === false) return [];
+ if (!state.dashboardId) return [];
+ try {
+ const dashboard = await startServices.savedDashboardLoader.get(state.dashboardId);
+ return dashboard?.searchSource?.getField('filter') ?? [];
+ } catch (e) {
+ // in case dashboard is missing, built the url without those filters
+ // dashboard app will handle redirect to landing page with toast message
+ return [];
+ }
+ };
+
const cleanEmptyKeys = (stateObj: Record) => {
Object.keys(stateObj).forEach(key => {
if (stateObj[key] === undefined) {
@@ -85,11 +111,18 @@ export const createDirectAccessDashboardLinkGenerator = (
return stateObj;
};
+ // leave filters `undefined` if no filters was applied
+ // in this case dashboard will restore saved filters on its own
+ const filters = state.filters && [
+ ...(await getSavedFiltersFromDestinationDashboardIfNeeded()),
+ ...state.filters,
+ ];
+
const appStateUrl = setStateToKbnUrl(
STATE_STORAGE_KEY,
cleanEmptyKeys({
query: state.query,
- filters: state.filters?.filter(f => !esFilters.isFilterPinned(f)),
+ filters: filters?.filter(f => !esFilters.isFilterPinned(f)),
}),
{ useHash },
`${appBasePath}#/${hash}`
@@ -99,7 +132,7 @@ export const createDirectAccessDashboardLinkGenerator = (
GLOBAL_STATE_STORAGE_KEY,
cleanEmptyKeys({
time: state.timeRange,
- filters: state.filters?.filter(f => esFilters.isFilterPinned(f)),
+ filters: filters?.filter(f => esFilters.isFilterPinned(f)),
refreshInterval: state.refreshInterval,
}),
{ useHash },
diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts
index 9829498118cc0..22ed18f75c652 100644
--- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts
+++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts
@@ -18,14 +18,17 @@
*/
import { SavedObjectUnsanitizedDoc } from 'kibana/server';
+import { savedObjectsServiceMock } from '../../../../core/server/mocks';
import { dashboardSavedObjectTypeMigrations as migrations } from './dashboard_migrations';
+const contextMock = savedObjectsServiceMock.createMigrationContext();
+
describe('dashboard', () => {
describe('7.0.0', () => {
const migration = migrations['7.0.0'];
test('skips error on empty object', () => {
- expect(migration({} as SavedObjectUnsanitizedDoc)).toMatchInlineSnapshot(`
+ expect(migration({} as SavedObjectUnsanitizedDoc, contextMock)).toMatchInlineSnapshot(`
Object {
"references": Array [],
}
@@ -44,7 +47,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@@ -83,7 +86,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@@ -122,7 +125,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
@@ -160,7 +163,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
@@ -198,7 +201,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@@ -237,7 +240,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@@ -291,7 +294,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
@@ -331,7 +334,7 @@ Object {
panelsJSON: 123,
},
} as SavedObjectUnsanitizedDoc;
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": 123,
@@ -349,7 +352,7 @@ Object {
panelsJSON: '{123abc}',
},
} as SavedObjectUnsanitizedDoc;
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "{123abc}",
@@ -367,7 +370,7 @@ Object {
panelsJSON: '{}',
},
} as SavedObjectUnsanitizedDoc;
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "{}",
@@ -385,7 +388,7 @@ Object {
panelsJSON: '[{"id":"123"}]',
},
} as SavedObjectUnsanitizedDoc;
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "[{\\"id\\":\\"123\\"}]",
@@ -403,7 +406,7 @@ Object {
panelsJSON: '[{"type":"visualization"}]',
},
} as SavedObjectUnsanitizedDoc;
- expect(migration(doc)).toMatchInlineSnapshot(`
+ expect(migration(doc, contextMock)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "[{\\"type\\":\\"visualization\\"}]",
@@ -422,7 +425,7 @@ Object {
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
} as SavedObjectUnsanitizedDoc;
- const migratedDoc = migration(doc);
+ const migratedDoc = migration(doc, contextMock);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts
index 7c1d0568cd3d7..4f7945d6dd601 100644
--- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts
+++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts
@@ -19,7 +19,7 @@
import { get, flow } from 'lodash';
-import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from 'kibana/server';
+import { SavedObjectMigrationFn } from 'kibana/server';
import { migrations730 } from './migrations_730';
import { migrateMatchAllQuery } from './migrate_match_all_query';
import { DashboardDoc700To720 } from '../../common';
@@ -62,7 +62,7 @@ function migrateIndexPattern(doc: DashboardDoc700To720) {
doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource);
}
-const migrations700: SavedObjectMigrationFn = (doc): DashboardDoc700To720 => {
+const migrations700: SavedObjectMigrationFn = (doc): DashboardDoc700To720 => {
// Set new "references" attribute
doc.references = doc.references || [];
@@ -111,7 +111,7 @@ export const dashboardSavedObjectTypeMigrations = {
* in that version. So we apply this twice, once with 6.7.2 and once with 7.0.1 while the backport to 6.7
* only contained the 6.7.2 migration and not the 7.0.1 migration.
*/
- '6.7.2': flow(migrateMatchAllQuery),
- '7.0.0': flow<(doc: SavedObjectUnsanitizedDoc) => DashboardDoc700To720>(migrations700),
- '7.3.0': flow(migrations730),
+ '6.7.2': flow>(migrateMatchAllQuery),
+ '7.0.0': flow>(migrations700),
+ '7.3.0': flow>(migrations730),
};
diff --git a/src/plugins/dashboard/server/saved_objects/migrate_match_all_query.ts b/src/plugins/dashboard/server/saved_objects/migrate_match_all_query.ts
index 5b8582bf821ef..db2fbeb278802 100644
--- a/src/plugins/dashboard/server/saved_objects/migrate_match_all_query.ts
+++ b/src/plugins/dashboard/server/saved_objects/migrate_match_all_query.ts
@@ -21,7 +21,7 @@ import { SavedObjectMigrationFn } from 'kibana/server';
import { get } from 'lodash';
import { DEFAULT_QUERY_LANGUAGE } from '../../../data/common';
-export const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+export const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (searchSourceJSON) {
diff --git a/src/plugins/dashboard/server/saved_objects/migrations_730.test.ts b/src/plugins/dashboard/server/saved_objects/migrations_730.test.ts
index aa744324428a4..a58df547fa522 100644
--- a/src/plugins/dashboard/server/saved_objects/migrations_730.test.ts
+++ b/src/plugins/dashboard/server/saved_objects/migrations_730.test.ts
@@ -17,19 +17,13 @@
* under the License.
*/
+import { savedObjectsServiceMock } from '../../../../core/server/mocks';
import { dashboardSavedObjectTypeMigrations as migrations } from './dashboard_migrations';
import { migrations730 } from './migrations_730';
import { DashboardDoc700To720, DashboardDoc730ToLatest, DashboardDocPre700 } from '../../common';
import { RawSavedDashboardPanel730ToLatest } from '../../common';
-const mockContext = {
- log: {
- warning: () => {},
- warn: () => {},
- debug: () => {},
- info: () => {},
- },
-};
+const mockContext = savedObjectsServiceMock.createMigrationContext();
test('dashboard migration 7.3.0 migrates filters to query on search source', () => {
const doc: DashboardDoc700To720 = {
@@ -95,7 +89,7 @@ test('dashboard migration 7.3.0 migrates filters to query on search source when
},
};
- const doc700: DashboardDoc700To720 = migrations['7.0.0'](doc);
+ const doc700 = migrations['7.0.0'](doc, mockContext);
const newDoc = migrations['7.3.0'](doc700, mockContext);
const parsedSearchSource = JSON.parse(newDoc.attributes.kibanaSavedObjectMeta.searchSourceJSON);
@@ -127,7 +121,7 @@ test('dashboard migration works when panelsJSON is missing panelIndex', () => {
},
};
- const doc700: DashboardDoc700To720 = migrations['7.0.0'](doc);
+ const doc700 = migrations['7.0.0'](doc, mockContext);
const newDoc = migrations['7.3.0'](doc700, mockContext);
const parsedSearchSource = JSON.parse(newDoc.attributes.kibanaSavedObjectMeta.searchSourceJSON);
diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts
index 698edbf9cd6a8..e21d27a70e02a 100644
--- a/src/plugins/data/common/index_patterns/types.ts
+++ b/src/plugins/data/common/index_patterns/types.ts
@@ -26,6 +26,7 @@ export interface IIndexPattern {
id?: string;
type?: string;
timeFieldName?: string;
+ getTimeField?(): IFieldType | undefined;
fieldFormatMap?: Record<
string,
{
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index 86560b3ccf7b1..91dea66f06a94 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -699,7 +699,10 @@ export function getSearchErrorType({ message }: Pick): "
// Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
-export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined;
+export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: {
+ forceNow?: Date;
+ fieldName?: string;
+}): import("../..").RangeFilter | undefined;
// Warning: (ae-missing-release-tag) "IAggConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -842,6 +845,8 @@ export interface IIndexPattern {
// (undocumented)
fields: IFieldType[];
// (undocumented)
+ getTimeField?(): IFieldType | undefined;
+ // (undocumented)
id?: string;
// (undocumented)
timeFieldName?: string;
diff --git a/src/plugins/data/public/query/timefilter/get_time.test.ts b/src/plugins/data/public/query/timefilter/get_time.test.ts
index a8eb3a3fe8102..4dba157a6f554 100644
--- a/src/plugins/data/public/query/timefilter/get_time.test.ts
+++ b/src/plugins/data/public/query/timefilter/get_time.test.ts
@@ -51,5 +51,43 @@ describe('get_time', () => {
});
clock.restore();
});
+
+ test('build range filter for non-primary field', () => {
+ const clock = sinon.useFakeTimers(moment.utc([2000, 1, 1, 0, 0, 0, 0]).valueOf());
+
+ const filter = getTime(
+ {
+ id: 'test',
+ title: 'test',
+ timeFieldName: 'date',
+ fields: [
+ {
+ name: 'date',
+ type: 'date',
+ esTypes: ['date'],
+ aggregatable: true,
+ searchable: true,
+ filterable: true,
+ },
+ {
+ name: 'myCustomDate',
+ type: 'date',
+ esTypes: ['date'],
+ aggregatable: true,
+ searchable: true,
+ filterable: true,
+ },
+ ],
+ } as any,
+ { from: 'now-60y', to: 'now' },
+ { fieldName: 'myCustomDate' }
+ );
+ expect(filter!.range.myCustomDate).toEqual({
+ gte: '1940-02-01T00:00:00.000Z',
+ lte: '2000-02-01T00:00:00.000Z',
+ format: 'strict_date_optional_time',
+ });
+ clock.restore();
+ });
});
});
diff --git a/src/plugins/data/public/query/timefilter/get_time.ts b/src/plugins/data/public/query/timefilter/get_time.ts
index fa15406189041..9cdd25d3213ce 100644
--- a/src/plugins/data/public/query/timefilter/get_time.ts
+++ b/src/plugins/data/public/query/timefilter/get_time.ts
@@ -19,7 +19,7 @@
import dateMath from '@elastic/datemath';
import { IIndexPattern } from '../..';
-import { TimeRange, IFieldType, buildRangeFilter } from '../../../common';
+import { TimeRange, buildRangeFilter } from '../../../common';
interface CalculateBoundsOptions {
forceNow?: Date;
@@ -35,18 +35,27 @@ export function calculateBounds(timeRange: TimeRange, options: CalculateBoundsOp
export function getTime(
indexPattern: IIndexPattern | undefined,
timeRange: TimeRange,
+ options?: { forceNow?: Date; fieldName?: string }
+) {
+ return createTimeRangeFilter(
+ indexPattern,
+ timeRange,
+ options?.fieldName || indexPattern?.timeFieldName,
+ options?.forceNow
+ );
+}
+
+function createTimeRangeFilter(
+ indexPattern: IIndexPattern | undefined,
+ timeRange: TimeRange,
+ fieldName?: string,
forceNow?: Date
) {
if (!indexPattern) {
- // in CI, we sometimes seem to fail here.
return;
}
-
- const timefield: IFieldType | undefined = indexPattern.fields.find(
- field => field.name === indexPattern.timeFieldName
- );
-
- if (!timefield) {
+ const field = indexPattern.fields.find(f => f.name === (fieldName || indexPattern.timeFieldName));
+ if (!field) {
return;
}
@@ -55,7 +64,7 @@ export function getTime(
return;
}
return buildRangeFilter(
- timefield,
+ field,
{
...(bounds.min && { gte: bounds.min.toISOString() }),
...(bounds.max && { lte: bounds.max.toISOString() }),
diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts
index a6260e782c12f..034af03842ab8 100644
--- a/src/plugins/data/public/query/timefilter/index.ts
+++ b/src/plugins/data/public/query/timefilter/index.ts
@@ -22,6 +22,6 @@ export { TimefilterService, TimefilterSetup } from './timefilter_service';
export * from './types';
export { Timefilter, TimefilterContract } from './timefilter';
export { TimeHistory, TimeHistoryContract } from './time_history';
-export { getTime } from './get_time';
+export { getTime, calculateBounds } from './get_time';
export { changeTimeFilter } from './lib/change_time_filter';
export { extractTimeFilter } from './lib/extract_time_filter';
diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts
index 4fbdac47fb3b0..86ef69be572a9 100644
--- a/src/plugins/data/public/query/timefilter/timefilter.ts
+++ b/src/plugins/data/public/query/timefilter/timefilter.ts
@@ -164,7 +164,9 @@ export class Timefilter {
};
public createFilter = (indexPattern: IndexPattern, timeRange?: TimeRange) => {
- return getTime(indexPattern, timeRange ? timeRange : this._time, this.getForceNow());
+ return getTime(indexPattern, timeRange ? timeRange : this._time, {
+ forceNow: this.getForceNow(),
+ });
};
public getBounds(): TimeRangeBounds {
diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts
index 57f3aa85ad944..3ecdc17cb57f3 100644
--- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts
+++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts
@@ -45,7 +45,7 @@ const updateTimeBuckets = (
customBuckets?: IBucketDateHistogramAggConfig['buckets']
) => {
const bounds =
- agg.params.timeRange && agg.fieldIsTimeField()
+ agg.params.timeRange && (agg.fieldIsTimeField() || agg.params.interval === 'auto')
? timefilter.calculateBounds(agg.params.timeRange)
: undefined;
const buckets = customBuckets || agg.buckets;
diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts
index 087b83127079f..eec75b0841133 100644
--- a/src/plugins/data/public/search/expressions/esaggs.ts
+++ b/src/plugins/data/public/search/expressions/esaggs.ts
@@ -32,8 +32,15 @@ import { Adapters } from '../../../../../plugins/inspector/public';
import { IAggConfigs } from '../aggs';
import { ISearchSource } from '../search_source';
import { tabifyAggResponse } from '../tabify';
-import { Filter, Query, serializeFieldFormat, TimeRange } from '../../../common';
-import { FilterManager, getTime } from '../../query';
+import {
+ Filter,
+ Query,
+ serializeFieldFormat,
+ TimeRange,
+ IIndexPattern,
+ isRangeFilter,
+} from '../../../common';
+import { FilterManager, calculateBounds, getTime } from '../../query';
import { getSearchService, getQueryService, getIndexPatterns } from '../../services';
import { buildTabularInspectorData } from './build_tabular_inspector_data';
import { getRequestInspectorStats, getResponseInspectorStats, serializeAggConfig } from './utils';
@@ -42,6 +49,8 @@ export interface RequestHandlerParams {
searchSource: ISearchSource;
aggs: IAggConfigs;
timeRange?: TimeRange;
+ timeFields?: string[];
+ indexPattern?: IIndexPattern;
query?: Query;
filters?: Filter[];
forceFetch: boolean;
@@ -65,12 +74,15 @@ interface Arguments {
partialRows: boolean;
includeFormatHints: boolean;
aggConfigs: string;
+ timeFields?: string[];
}
const handleCourierRequest = async ({
searchSource,
aggs,
timeRange,
+ timeFields,
+ indexPattern,
query,
filters,
forceFetch,
@@ -111,9 +123,19 @@ const handleCourierRequest = async ({
return aggs.onSearchRequestStart(paramSearchSource, options);
});
- if (timeRange) {
+ // If timeFields have been specified, use the specified ones, otherwise use primary time field of index
+ // pattern if it's available.
+ const defaultTimeField = indexPattern?.getTimeField?.();
+ const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
+ const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
+
+ // If a timeRange has been specified and we had at least one timeField available, create range
+ // filters for that those time fields
+ if (timeRange && allTimeFields.length > 0) {
timeFilterSearchSource.setField('filter', () => {
- return getTime(searchSource.getField('index'), timeRange);
+ return allTimeFields
+ .map(fieldName => getTime(indexPattern, timeRange, { fieldName }))
+ .filter(isRangeFilter);
});
}
@@ -181,11 +203,13 @@ const handleCourierRequest = async ({
(searchSource as any).finalResponse = resp;
- const parsedTimeRange = timeRange ? getTime(aggs.indexPattern, timeRange) : null;
+ const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
const tabifyParams = {
metricsAtAllLevels,
partialRows,
- timeRange: parsedTimeRange ? parsedTimeRange.range : undefined,
+ timeRange: parsedTimeRange
+ ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields }
+ : undefined,
};
const tabifyCacheHash = calculateObjectHash({ tabifyAggs: aggs, ...tabifyParams });
@@ -242,6 +266,11 @@ export const esaggs = (): ExpressionFunctionDefinition {
const check = (aggResp: any, count: number, keys: string[]) => {
@@ -187,9 +192,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
- gte: 150,
- lte: 350,
- name: 'date',
+ from: moment(150),
+ to: moment(350),
+ timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@@ -204,9 +209,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
- gte: 150,
- lte: 350,
- name: 'date',
+ from: moment(150),
+ to: moment(350),
+ timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@@ -221,9 +226,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
- gte: 100,
- lte: 400,
- name: 'date',
+ from: moment(100),
+ to: moment(400),
+ timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@@ -238,13 +243,47 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
- gte: 150,
- lte: 350,
- name: 'date',
+ from: moment(150),
+ to: moment(350),
+ timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
expect(buckets).toHaveLength(4);
});
+
+ test('does drop bucket when multiple time fields specified', () => {
+ const aggParams = {
+ drop_partials: true,
+ field: {
+ name: 'date',
+ },
+ };
+ const timeRange = {
+ from: moment(100),
+ to: moment(350),
+ timeFields: ['date', 'other_datefield'],
+ };
+ const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
+
+ expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([100, 200]);
+ });
+
+ test('does not drop bucket when no timeFields have been specified', () => {
+ const aggParams = {
+ drop_partials: true,
+ field: {
+ name: 'date',
+ },
+ };
+ const timeRange = {
+ from: moment(100),
+ to: moment(350),
+ timeFields: [],
+ };
+ const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
+
+ expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([0, 100, 200, 300]);
+ });
});
});
diff --git a/src/plugins/data/public/search/tabify/buckets.ts b/src/plugins/data/public/search/tabify/buckets.ts
index 971e820ac6ddf..cd52a09caeaad 100644
--- a/src/plugins/data/public/search/tabify/buckets.ts
+++ b/src/plugins/data/public/search/tabify/buckets.ts
@@ -20,7 +20,7 @@
import { get, isPlainObject, keys, findKey } from 'lodash';
import moment from 'moment';
import { IAggConfig } from '../aggs';
-import { AggResponseBucket, TabbedRangeFilterParams } from './types';
+import { AggResponseBucket, TabbedRangeFilterParams, TimeRangeInformation } from './types';
type AggParams = IAggConfig['params'] & {
drop_partials: boolean;
@@ -36,7 +36,7 @@ export class TabifyBuckets {
buckets: any;
_keys: any[] = [];
- constructor(aggResp: any, aggParams?: AggParams, timeRange?: TabbedRangeFilterParams) {
+ constructor(aggResp: any, aggParams?: AggParams, timeRange?: TimeRangeInformation) {
if (aggResp && aggResp.buckets) {
this.buckets = aggResp.buckets;
} else if (aggResp) {
@@ -107,12 +107,12 @@ export class TabifyBuckets {
// dropPartials should only be called if the aggParam setting is enabled,
// and the agg field is the same as the Time Range.
- private dropPartials(params: AggParams, timeRange?: TabbedRangeFilterParams) {
+ private dropPartials(params: AggParams, timeRange?: TimeRangeInformation) {
if (
!timeRange ||
this.buckets.length <= 1 ||
this.objectMode ||
- params.field.name !== timeRange.name
+ !timeRange.timeFields.includes(params.field.name)
) {
return;
}
@@ -120,10 +120,10 @@ export class TabifyBuckets {
const interval = this.buckets[1].key - this.buckets[0].key;
this.buckets = this.buckets.filter((bucket: AggResponseBucket) => {
- if (moment(bucket.key).isBefore(timeRange.gte)) {
+ if (moment(bucket.key).isBefore(timeRange.from)) {
return false;
}
- if (moment(bucket.key + interval).isAfter(timeRange.lte)) {
+ if (moment(bucket.key + interval).isAfter(timeRange.to)) {
return false;
}
return true;
diff --git a/src/plugins/data/public/search/tabify/tabify.ts b/src/plugins/data/public/search/tabify/tabify.ts
index e93e989034252..9cb55f94537c5 100644
--- a/src/plugins/data/public/search/tabify/tabify.ts
+++ b/src/plugins/data/public/search/tabify/tabify.ts
@@ -20,7 +20,7 @@
import { get } from 'lodash';
import { TabbedAggResponseWriter } from './response_writer';
import { TabifyBuckets } from './buckets';
-import { TabbedResponseWriterOptions, TabbedRangeFilterParams } from './types';
+import { TabbedResponseWriterOptions } from './types';
import { AggResponseBucket } from './types';
import { AggGroupNames, IAggConfigs } from '../aggs';
@@ -54,7 +54,7 @@ export function tabifyAggResponse(
switch (agg.type.type) {
case AggGroupNames.Buckets:
const aggBucket = get(bucket, agg.id);
- const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, timeRange);
+ const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, respOpts?.timeRange);
if (tabifyBuckets.length) {
tabifyBuckets.forEach((subBucket, tabifyBucketKey) => {
@@ -153,20 +153,6 @@ export function tabifyAggResponse(
doc_count: esResponse.hits.total,
};
- let timeRange: TabbedRangeFilterParams | undefined;
-
- // Extract the time range object if provided
- if (respOpts && respOpts.timeRange) {
- const [timeRangeKey] = Object.keys(respOpts.timeRange);
-
- if (timeRangeKey) {
- timeRange = {
- name: timeRangeKey,
- ...respOpts.timeRange[timeRangeKey],
- };
- }
- }
-
collectBucket(aggConfigs, write, topLevelBucket, '', 1);
return write.response();
diff --git a/src/plugins/data/public/search/tabify/types.ts b/src/plugins/data/public/search/tabify/types.ts
index 1e051880d3f19..72e91eb58c8a9 100644
--- a/src/plugins/data/public/search/tabify/types.ts
+++ b/src/plugins/data/public/search/tabify/types.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { Moment } from 'moment';
import { RangeFilterParams } from '../../../common';
import { IAggConfig } from '../aggs';
@@ -25,11 +26,18 @@ export interface TabbedRangeFilterParams extends RangeFilterParams {
name: string;
}
+/** @internal */
+export interface TimeRangeInformation {
+ from?: Moment;
+ to?: Moment;
+ timeFields: string[];
+}
+
/** @internal **/
export interface TabbedResponseWriterOptions {
metricsAtAllLevels: boolean;
partialRows: boolean;
- timeRange?: { [key: string]: RangeFilterParams };
+ timeRange?: TimeRangeInformation;
}
/** @internal */
diff --git a/src/plugins/data/server/saved_objects/index_pattern_migrations.ts b/src/plugins/data/server/saved_objects/index_pattern_migrations.ts
index 7a16386ea484c..c64f7361a8cf4 100644
--- a/src/plugins/data/server/saved_objects/index_pattern_migrations.ts
+++ b/src/plugins/data/server/saved_objects/index_pattern_migrations.ts
@@ -20,7 +20,7 @@
import { flow, omit } from 'lodash';
import { SavedObjectMigrationFn } from 'kibana/server';
-const migrateAttributeTypeAndAttributeTypeMeta: SavedObjectMigrationFn = doc => ({
+const migrateAttributeTypeAndAttributeTypeMeta: SavedObjectMigrationFn = doc => ({
...doc,
attributes: {
...doc.attributes,
@@ -29,7 +29,7 @@ const migrateAttributeTypeAndAttributeTypeMeta: SavedObjectMigrationFn = doc =>
},
});
-const migrateSubTypeAndParentFieldProperties: SavedObjectMigrationFn = doc => {
+const migrateSubTypeAndParentFieldProperties: SavedObjectMigrationFn = doc => {
if (!doc.attributes.fields) return doc;
const fieldsString = doc.attributes.fields;
diff --git a/src/plugins/data/server/saved_objects/search_migrations.ts b/src/plugins/data/server/saved_objects/search_migrations.ts
index 45fa5e11e2a3d..c8ded51193c92 100644
--- a/src/plugins/data/server/saved_objects/search_migrations.ts
+++ b/src/plugins/data/server/saved_objects/search_migrations.ts
@@ -21,7 +21,7 @@ import { flow, get } from 'lodash';
import { SavedObjectMigrationFn } from 'kibana/server';
import { DEFAULT_QUERY_LANGUAGE } from '../../common';
-const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (searchSourceJSON) {
@@ -55,7 +55,7 @@ const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
return doc;
};
-const migrateIndexPattern: SavedObjectMigrationFn = doc => {
+const migrateIndexPattern: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (typeof searchSourceJSON !== 'string') {
return doc;
@@ -97,13 +97,13 @@ const migrateIndexPattern: SavedObjectMigrationFn = doc => {
return doc;
};
-const setNewReferences: SavedObjectMigrationFn = (doc, context) => {
+const setNewReferences: SavedObjectMigrationFn = (doc, context) => {
doc.references = doc.references || [];
// Migrate index pattern
return migrateIndexPattern(doc, context);
};
-const migrateSearchSortToNestedArray: SavedObjectMigrationFn = doc => {
+const migrateSearchSortToNestedArray: SavedObjectMigrationFn = doc => {
const sort = get(doc, 'attributes.sort');
if (!sort) return doc;
@@ -122,7 +122,7 @@ const migrateSearchSortToNestedArray: SavedObjectMigrationFn = doc => {
};
export const searchSavedObjectTypeMigrations = {
- '6.7.2': flow(migrateMatchAllQuery),
- '7.0.0': flow(setNewReferences),
- '7.4.0': flow(migrateSearchSortToNestedArray),
+ '6.7.2': flow>(migrateMatchAllQuery),
+ '7.0.0': flow>(setNewReferences),
+ '7.4.0': flow>(migrateSearchSortToNestedArray),
};
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index 5d94b6516c2ba..df4ba23244b4d 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -408,6 +408,8 @@ export interface IIndexPattern {
// (undocumented)
fields: IFieldType[];
// (undocumented)
+ getTimeField?(): IFieldType | undefined;
+ // (undocumented)
id?: string;
// (undocumented)
timeFieldName?: string;
diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx
index fe3150fc0bb07..c1daf3445219f 100644
--- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx
@@ -17,23 +17,15 @@
* under the License.
*/
-import React, { useEffect } from 'react';
+import React, { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
-import { HashRouter, Switch, Route, useParams, useLocation } from 'react-router-dom';
-import { parse } from 'query-string';
-import { get } from 'lodash';
-import { i18n } from '@kbn/i18n';
+import { HashRouter, Switch, Route } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n/react';
-import { CoreSetup, CoreStart, ChromeBreadcrumb, Capabilities } from 'src/core/public';
+import { EuiLoadingSpinner } from '@elastic/eui';
+import { CoreSetup, Capabilities } from 'src/core/public';
import { ManagementAppMountParams } from '../../../management/public';
-import { DataPublicPluginStart } from '../../../data/public';
import { StartDependencies, SavedObjectsManagementPluginStart } from '../plugin';
-import {
- ISavedObjectsManagementServiceRegistry,
- SavedObjectsManagementActionServiceStart,
-} from '../services';
-import { SavedObjectsTable } from './objects_table';
-import { SavedObjectEdition } from './object_view';
+import { ISavedObjectsManagementServiceRegistry } from '../services';
import { getAllowedTypes } from './../lib';
interface MountParams {
@@ -44,6 +36,8 @@ interface MountParams {
let allowedObjectTypes: string[] | undefined;
+const SavedObjectsEditionPage = lazy(() => import('./saved_objects_edition_page'));
+const SavedObjectsTablePage = lazy(() => import('./saved_objects_table_page'));
export const mountManagementSection = async ({
core,
mountParams,
@@ -63,23 +57,27 @@ export const mountManagementSection = async ({
-
+ }>
+
+
-
+ }>
+
+
@@ -103,110 +101,3 @@ const RedirectToHomeIfUnauthorized: React.FunctionComponent<{
}
return children! as React.ReactElement;
};
-
-const SavedObjectsEditionPage = ({
- coreStart,
- serviceRegistry,
- setBreadcrumbs,
-}: {
- coreStart: CoreStart;
- serviceRegistry: ISavedObjectsManagementServiceRegistry;
- setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
-}) => {
- const { service: serviceName, id } = useParams<{ service: string; id: string }>();
- const capabilities = coreStart.application.capabilities;
-
- const { search } = useLocation();
- const query = parse(search);
- const service = serviceRegistry.get(serviceName);
-
- useEffect(() => {
- setBreadcrumbs([
- {
- text: i18n.translate('savedObjectsManagement.breadcrumb.index', {
- defaultMessage: 'Saved objects',
- }),
- href: '#/management/kibana/objects',
- },
- {
- text: i18n.translate('savedObjectsManagement.breadcrumb.edit', {
- defaultMessage: 'Edit {savedObjectType}',
- values: { savedObjectType: service?.service.type ?? 'object' },
- }),
- },
- ]);
- }, [setBreadcrumbs, service]);
-
- return (
-
- );
-};
-
-const SavedObjectsTablePage = ({
- coreStart,
- dataStart,
- allowedTypes,
- serviceRegistry,
- actionRegistry,
- setBreadcrumbs,
-}: {
- coreStart: CoreStart;
- dataStart: DataPublicPluginStart;
- allowedTypes: string[];
- serviceRegistry: ISavedObjectsManagementServiceRegistry;
- actionRegistry: SavedObjectsManagementActionServiceStart;
- setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
-}) => {
- const capabilities = coreStart.application.capabilities;
- const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50);
-
- useEffect(() => {
- setBreadcrumbs([
- {
- text: i18n.translate('savedObjectsManagement.breadcrumb.index', {
- defaultMessage: 'Saved objects',
- }),
- href: '#/management/kibana/objects',
- },
- ]);
- }, [setBreadcrumbs]);
-
- return (
- {
- const { editUrl } = savedObject.meta;
- if (editUrl) {
- // previously, kbnUrl.change(object.meta.editUrl); was used.
- // using direct access to location.hash seems the only option for now,
- // as using react-router-dom will prefix the url with the router's basename
- // which should be ignored there.
- window.location.hash = editUrl;
- }
- }}
- canGoInApp={savedObject => {
- const { inAppUrl } = savedObject.meta;
- return inAppUrl ? get(capabilities, inAppUrl.uiCapabilitiesPath) : false;
- }}
- />
- );
-};
diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx
new file mode 100644
index 0000000000000..5ac6e8e103c47
--- /dev/null
+++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx
@@ -0,0 +1,76 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useEffect } from 'react';
+import { useParams, useLocation } from 'react-router-dom';
+import { parse } from 'query-string';
+import { i18n } from '@kbn/i18n';
+import { CoreStart, ChromeBreadcrumb } from 'src/core/public';
+import { ISavedObjectsManagementServiceRegistry } from '../services';
+import { SavedObjectEdition } from './object_view';
+
+const SavedObjectsEditionPage = ({
+ coreStart,
+ serviceRegistry,
+ setBreadcrumbs,
+}: {
+ coreStart: CoreStart;
+ serviceRegistry: ISavedObjectsManagementServiceRegistry;
+ setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
+}) => {
+ const { service: serviceName, id } = useParams<{ service: string; id: string }>();
+ const capabilities = coreStart.application.capabilities;
+
+ const { search } = useLocation();
+ const query = parse(search);
+ const service = serviceRegistry.get(serviceName);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ {
+ text: i18n.translate('savedObjectsManagement.breadcrumb.index', {
+ defaultMessage: 'Saved objects',
+ }),
+ href: '#/management/kibana/objects',
+ },
+ {
+ text: i18n.translate('savedObjectsManagement.breadcrumb.edit', {
+ defaultMessage: 'Edit {savedObjectType}',
+ values: { savedObjectType: service?.service.type ?? 'object' },
+ }),
+ },
+ ]);
+ }, [setBreadcrumbs, service]);
+
+ return (
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export { SavedObjectsEditionPage as default };
diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx
new file mode 100644
index 0000000000000..7660d17f91c5b
--- /dev/null
+++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx
@@ -0,0 +1,91 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useEffect } from 'react';
+import { get } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { CoreStart, ChromeBreadcrumb } from 'src/core/public';
+import { DataPublicPluginStart } from '../../../data/public';
+import {
+ ISavedObjectsManagementServiceRegistry,
+ SavedObjectsManagementActionServiceStart,
+} from '../services';
+import { SavedObjectsTable } from './objects_table';
+
+const SavedObjectsTablePage = ({
+ coreStart,
+ dataStart,
+ allowedTypes,
+ serviceRegistry,
+ actionRegistry,
+ setBreadcrumbs,
+}: {
+ coreStart: CoreStart;
+ dataStart: DataPublicPluginStart;
+ allowedTypes: string[];
+ serviceRegistry: ISavedObjectsManagementServiceRegistry;
+ actionRegistry: SavedObjectsManagementActionServiceStart;
+ setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
+}) => {
+ const capabilities = coreStart.application.capabilities;
+ const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ {
+ text: i18n.translate('savedObjectsManagement.breadcrumb.index', {
+ defaultMessage: 'Saved objects',
+ }),
+ href: '#/management/kibana/objects',
+ },
+ ]);
+ }, [setBreadcrumbs]);
+
+ return (
+ {
+ const { editUrl } = savedObject.meta;
+ if (editUrl) {
+ // previously, kbnUrl.change(object.meta.editUrl); was used.
+ // using direct access to location.hash seems the only option for now,
+ // as using react-router-dom will prefix the url with the router's basename
+ // which should be ignored there.
+ window.location.hash = editUrl;
+ }
+ }}
+ canGoInApp={savedObject => {
+ const { inAppUrl } = savedObject.meta;
+ return inAppUrl ? get(capabilities, inAppUrl.uiCapabilitiesPath) : false;
+ }}
+ />
+ );
+};
+// eslint-disable-next-line import/no-default-export
+export { SavedObjectsTablePage as default };
diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap
new file mode 100644
index 0000000000000..8c0117e5a7266
--- /dev/null
+++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap
@@ -0,0 +1,492 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TelemetryManagementSectionComponent renders as expected 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ }
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+ ,
+ }
+ }
+ />
+
+
+
+
+
+
+ ,
+ "displayName": "Provide usage statistics",
+ "name": "telemetry:enabled",
+ "type": "boolean",
+ "value": true,
+ }
+ }
+ toasts={null}
+ />
+
+
+
+`;
+
+exports[`TelemetryManagementSectionComponent renders null because allowChangingOptInStatus is false 1`] = `
+
+`;
+
+exports[`TelemetryManagementSectionComponent renders null because query does not match the SEARCH_TERMS 1`] = `
+
+`;
+
+exports[`TelemetryManagementSectionComponent test the wrapper (for coverage purposes) 1`] = `""`;
diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx
new file mode 100644
index 0000000000000..d0c2bd13f802d
--- /dev/null
+++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx
@@ -0,0 +1,284 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
+import { TelemetryManagementSection } from './telemetry_management_section';
+import { TelemetryService } from '../../../telemetry/public/services';
+import { coreMock } from '../../../../core/public/mocks';
+import { telemetryManagementSectionWrapper } from './telemetry_management_section_wrapper';
+
+describe('TelemetryManagementSectionComponent', () => {
+ const coreStart = coreMock.createStart();
+ const coreSetup = coreMock.createSetup();
+
+ it('renders as expected', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: true,
+ optIn: true,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ expect(
+ shallowWithIntl(
+
+ )
+ ).toMatchSnapshot();
+ });
+
+ it('renders null because query does not match the SEARCH_TERMS', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: true,
+ optIn: false,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ const component = mountWithIntl(
+
+ );
+ try {
+ expect(
+ component.setProps({ ...component.props(), query: { text: 'asssdasdsad' } })
+ ).toMatchSnapshot();
+ expect(onQueryMatchChange).toHaveBeenCalledWith(false);
+ expect(onQueryMatchChange).toHaveBeenCalledTimes(1);
+ } finally {
+ component.unmount();
+ }
+ });
+
+ it('renders because query matches the SEARCH_TERMS', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: true,
+ optIn: false,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ const component = mountWithIntl(
+
+ );
+ try {
+ expect(
+ component.setProps({ ...component.props(), query: { text: 'TeLEMetry' } }).html()
+ ).not.toBe(''); // Renders something.
+ // I can't check against snapshot because of https://github.com/facebook/jest/issues/8618
+ // expect(component).toMatchSnapshot();
+
+ // It should also render if there is no query at all.
+ expect(component.setProps({ ...component.props(), query: {} }).html()).not.toBe('');
+ expect(onQueryMatchChange).toHaveBeenCalledWith(true);
+
+ // Should only be called once because the second time does not change the result
+ expect(onQueryMatchChange).toHaveBeenCalledTimes(1);
+ } finally {
+ component.unmount();
+ }
+ });
+
+ it('renders null because allowChangingOptInStatus is false', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: false,
+ optIn: true,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ const component = mountWithIntl(
+
+ );
+ try {
+ expect(component).toMatchSnapshot();
+ component.setProps({ ...component.props(), query: { text: 'TeLEMetry' } });
+ expect(onQueryMatchChange).toHaveBeenCalledWith(false);
+ } finally {
+ component.unmount();
+ }
+ });
+
+ it('shows the OptInExampleFlyout', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: true,
+ optIn: false,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ const component = mountWithIntl(
+
+ );
+ try {
+ const toggleExampleComponent = component.find('p > EuiLink[onClick]');
+ const updatedView = toggleExampleComponent.simulate('click');
+ updatedView.find('OptInExampleFlyout');
+ updatedView.simulate('close');
+ } finally {
+ component.unmount();
+ }
+ });
+
+ it('toggles the OptIn button', async () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: true,
+ optIn: false,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+
+ const component = mountWithIntl(
+
+ );
+ try {
+ const toggleOptInComponent = component.find('Field');
+ await expect(
+ toggleOptInComponent.prop('handleChange')()
+ ).resolves.toBe(true);
+ expect((component.state() as any).enabled).toBe(true);
+ await expect(
+ toggleOptInComponent.prop('handleChange')()
+ ).resolves.toBe(true);
+ expect((component.state() as any).enabled).toBe(false);
+ telemetryService.setOptIn = jest.fn().mockRejectedValue(Error('test-error'));
+ await expect(
+ toggleOptInComponent.prop('handleChange')()
+ ).rejects.toStrictEqual(Error('test-error'));
+ } finally {
+ component.unmount();
+ }
+ });
+
+ it('test the wrapper (for coverage purposes)', () => {
+ const onQueryMatchChange = jest.fn();
+ const telemetryService = new TelemetryService({
+ config: {
+ enabled: true,
+ url: '',
+ banner: true,
+ allowChangingOptInStatus: false,
+ optIn: false,
+ optInStatusUrl: '',
+ sendUsageFrom: 'browser',
+ },
+ reportOptInStatusChange: false,
+ notifications: coreStart.notifications,
+ http: coreSetup.http,
+ });
+ const Wrapper = telemetryManagementSectionWrapper(telemetryService);
+ expect(
+ shallowWithIntl(
+
+ ).html()
+ ).toMatchSnapshot();
+ });
+});
diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx
index 26e075b666593..361c5ff719c54 100644
--- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx
+++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx
@@ -69,7 +69,9 @@ export class TelemetryManagementSection extends Component {
const { query } = nextProps;
const searchTerm = (query.text || '').toLowerCase();
- const searchTermMatches = SEARCH_TERMS.some(term => term.indexOf(searchTerm) >= 0);
+ const searchTermMatches =
+ this.props.telemetryService.getCanChangeOptInStatus() &&
+ SEARCH_TERMS.some(term => term.indexOf(searchTerm) >= 0);
if (searchTermMatches !== this.state.queryMatches) {
this.setState(
diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts
index b84d9638eb973..3309330d7527c 100644
--- a/src/plugins/vis_type_markdown/public/markdown_vis.ts
+++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts
@@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n';
import { MarkdownVisWrapper } from './markdown_vis_controller';
import { MarkdownOptions } from './markdown_options';
-import { SettingsOptions } from './settings_options';
+import { SettingsOptions } from './settings_options_lazy';
import { DefaultEditorSize } from '../../vis_default_editor/public';
export const markdownVisDefinition = {
diff --git a/src/plugins/vis_type_markdown/public/settings_options.tsx b/src/plugins/vis_type_markdown/public/settings_options.tsx
index 6f6a80564ce07..bf4570db5d4a0 100644
--- a/src/plugins/vis_type_markdown/public/settings_options.tsx
+++ b/src/plugins/vis_type_markdown/public/settings_options.tsx
@@ -52,4 +52,6 @@ function SettingsOptions({ stateParams, setValue }: VisOptionsProps import('./settings_options'));
+
+export const SettingsOptions = (props: any) => (
+ }>
+
+
+);
diff --git a/src/plugins/vis_type_table/public/get_inner_angular.ts b/src/plugins/vis_type_table/public/get_inner_angular.ts
index d69b9bba31b03..e8404f918d609 100644
--- a/src/plugins/vis_type_table/public/get_inner_angular.ts
+++ b/src/plugins/vis_type_table/public/get_inner_angular.ts
@@ -21,6 +21,8 @@
// these are necessary to bootstrap the local angular.
// They can stay even after NP cutover
import angular from 'angular';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import 'angular-recursion';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import { CoreStart, IUiSettingsClient, PluginInitializerContext } from 'kibana/public';
diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js
index 7075e86eb56bf..9fdb8ccc919b7 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js
@@ -240,3 +240,7 @@ VisEditor.propTypes = {
timeRange: PropTypes.object,
appState: PropTypes.object,
};
+
+// default export required for React.Lazy
+// eslint-disable-next-line import/no-default-export
+export { VisEditor as default };
diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_lazy.tsx b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_lazy.tsx
new file mode 100644
index 0000000000000..d81bd95d8d771
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_lazy.tsx
@@ -0,0 +1,30 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { lazy, Suspense } from 'react';
+import { EuiLoadingSpinner } from '@elastic/eui';
+
+// @ts-ignore
+const VisEditorComponent = lazy(() => import('./vis_editor'));
+
+export const VisEditor = (props: any) => (
+ }>
+
+
+);
diff --git a/src/plugins/vis_type_timeseries/public/application/editor_controller.js b/src/plugins/vis_type_timeseries/public/application/editor_controller.js
index af50d3a06d1fc..f21b5f947bca7 100644
--- a/src/plugins/vis_type_timeseries/public/application/editor_controller.js
+++ b/src/plugins/vis_type_timeseries/public/application/editor_controller.js
@@ -21,7 +21,7 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { fetchIndexPatternFields } from './lib/fetch_fields';
import { getSavedObjectsClient, getUISettings, getI18n } from '../services';
-import { VisEditor } from './components/vis_editor';
+import { VisEditor } from './components/vis_editor_lazy';
export class EditorController {
constructor(el, vis, eventEmitter, embeddableHandler) {
diff --git a/src/plugins/vis_type_timeseries/public/metrics_fn.ts b/src/plugins/vis_type_timeseries/public/metrics_fn.ts
index 008b13cce6565..b573225feaab1 100644
--- a/src/plugins/vis_type_timeseries/public/metrics_fn.ts
+++ b/src/plugins/vis_type_timeseries/public/metrics_fn.ts
@@ -20,7 +20,6 @@
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition, KibanaContext, Render } from '../../expressions/public';
-import { PersistedState } from '../../visualizations/public';
// @ts-ignore
import { metricsRequestHandler } from './request_handler';
@@ -76,6 +75,7 @@ export const createMetricsFn = (): ExpressionFunctionDefinition<
const params = JSON.parse(args.params);
const uiStateParams = JSON.parse(args.uiState);
const savedObjectId = args.savedObjectId;
+ const { PersistedState } = await import('../../visualizations/public');
const uiState = new PersistedState(uiStateParams);
const response = await metricsRequestHandler({
diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts
index c525ce7fa0b3b..2b0734ceb4d4d 100644
--- a/src/plugins/vis_type_timeseries/public/metrics_type.ts
+++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts
@@ -25,6 +25,7 @@ import { EditorController } from './application';
// @ts-ignore
import { PANEL_TYPES } from '../common/panel_types';
import { defaultFeedbackMessage } from '../../kibana_utils/public';
+import { VisEditor } from './application/components/vis_editor_lazy';
export const metricsVisDefinition = {
name: 'metrics',
@@ -69,7 +70,7 @@ export const metricsVisDefinition = {
show_legend: 1,
show_grid: 1,
},
- component: require('./application/components/vis_editor').VisEditor,
+ component: VisEditor,
},
editor: EditorController,
options: {
diff --git a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts
index 34922976f22ff..1e5508b44ee0e 100644
--- a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts
+++ b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts
@@ -20,7 +20,7 @@
import { flow } from 'lodash';
import { SavedObjectMigrationFn, SavedObjectsType } from 'kibana/server';
-const resetCount: SavedObjectMigrationFn = doc => ({
+const resetCount: SavedObjectMigrationFn = doc => ({
...doc,
attributes: {
...doc.attributes,
diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts
index 196e8fdcbafda..efc02e368efa8 100644
--- a/src/plugins/vis_type_vega/public/vega_request_handler.ts
+++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts
@@ -19,8 +19,6 @@
import { Filter, esQuery, TimeRange, Query } from '../../data/public';
-// @ts-ignore
-import { VegaParser } from './data_model/vega_parser';
// @ts-ignore
import { SearchCache } from './data_model/search_cache';
// @ts-ignore
@@ -46,7 +44,12 @@ export function createVegaRequestHandler({
const { timefilter } = data.query.timefilter;
const timeCache = new TimeCache(timefilter, 3 * 1000);
- return ({ timeRange, filters, query, visParams }: VegaRequestHandlerParams) => {
+ return async function vegaRequestHandler({
+ timeRange,
+ filters,
+ query,
+ visParams,
+ }: VegaRequestHandlerParams) {
if (!searchCache) {
searchCache = new SearchCache(getData().search.__LEGACY.esClient, {
max: 10,
@@ -58,8 +61,10 @@ export function createVegaRequestHandler({
const esQueryConfigs = esQuery.getEsQueryConfig(uiSettings);
const filtersDsl = esQuery.buildEsQuery(undefined, query, filters, esQueryConfigs);
+ // @ts-ignore
+ const { VegaParser } = await import('./data_model/vega_parser');
const vp = new VegaParser(visParams.spec, searchCache, timeCache, filtersDsl, serviceSettings);
- return vp.parseAsync();
+ return await vp.parseAsync();
};
}
diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js
index a6e911de7f0cb..1fcb89f04457d 100644
--- a/src/plugins/vis_type_vega/public/vega_visualization.js
+++ b/src/plugins/vis_type_vega/public/vega_visualization.js
@@ -17,8 +17,6 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
-import { VegaView } from './vega_view/vega_view';
-import { VegaMapView } from './vega_view/vega_map_view';
import { getNotifications, getData, getSavedObjects } from './services';
export const createVegaVisualization = ({ serviceSettings }) =>
@@ -117,8 +115,10 @@ export const createVegaVisualization = ({ serviceSettings }) =>
if (vegaParser.useMap) {
const services = { toastService: getNotifications().toasts };
+ const { VegaMapView } = await import('./vega_view/vega_map_view');
this._vegaView = new VegaMapView(vegaViewParams, services);
} else {
+ const { VegaView } = await import('./vega_view/vega_view');
this._vegaView = new VegaView(vegaViewParams);
}
await this._vegaView.init();
diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
index 94473e35a942d..f6455d0c1e43f 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
@@ -21,7 +21,7 @@ import { SavedObjectMigrationFn } from 'kibana/server';
import { cloneDeep, get, omit, has, flow } from 'lodash';
import { DEFAULT_QUERY_LANGUAGE } from '../../../data/common';
-const migrateIndexPattern: SavedObjectMigrationFn = doc => {
+const migrateIndexPattern: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (typeof searchSourceJSON !== 'string') {
return doc;
@@ -64,7 +64,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = doc => {
};
// [TSVB] Migrate percentile-rank aggregation (value -> values)
-const migratePercentileRankAggregation: SavedObjectMigrationFn = doc => {
+const migratePercentileRankAggregation: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
let visState;
@@ -100,7 +100,7 @@ const migratePercentileRankAggregation: SavedObjectMigrationFn = doc => {
};
// [TSVB] Remove stale opperator key
-const migrateOperatorKeyTypo: SavedObjectMigrationFn = doc => {
+const migrateOperatorKeyTypo: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
let visState;
@@ -132,7 +132,7 @@ const migrateOperatorKeyTypo: SavedObjectMigrationFn = doc => {
};
// Migrate date histogram aggregation (remove customInterval)
-const migrateDateHistogramAggregation: SavedObjectMigrationFn = doc => {
+const migrateDateHistogramAggregation: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
let visState;
@@ -174,7 +174,7 @@ const migrateDateHistogramAggregation: SavedObjectMigrationFn = doc => {
return doc;
};
-const removeDateHistogramTimeZones: SavedObjectMigrationFn = doc => {
+const removeDateHistogramTimeZones: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
if (visStateJSON) {
let visState;
@@ -206,7 +206,7 @@ const removeDateHistogramTimeZones: SavedObjectMigrationFn = doc => {
// migrate gauge verticalSplit to alignment
// https://github.com/elastic/kibana/issues/34636
-const migrateGaugeVerticalSplitToAlignment: SavedObjectMigrationFn = (doc, logger) => {
+const migrateGaugeVerticalSplitToAlignment: SavedObjectMigrationFn = (doc, logger) => {
const visStateJSON = get(doc, 'attributes.visState');
if (visStateJSON) {
@@ -241,7 +241,7 @@ const migrateGaugeVerticalSplitToAlignment: SavedObjectMigrationFn = (doc, logge
Path to the series array is thus:
attributes.visState.
*/
-const transformFilterStringToQueryObject: SavedObjectMigrationFn = (doc, logger) => {
+const transformFilterStringToQueryObject: SavedObjectMigrationFn = (doc, logger) => {
// Migrate filters
// If any filters exist and they are a string, we assume it to be lucene and transform the filter into an object accordingly
const newDoc = cloneDeep(doc);
@@ -325,7 +325,7 @@ const transformFilterStringToQueryObject: SavedObjectMigrationFn = (doc, logger)
return newDoc;
};
-const transformSplitFiltersStringToQueryObject: SavedObjectMigrationFn = doc => {
+const transformSplitFiltersStringToQueryObject: SavedObjectMigrationFn = doc => {
// Migrate split_filters in TSVB objects that weren't migrated in 7.3
// If any filters exist and they are a string, we assume them to be lucene syntax and transform the filter into an object accordingly
const newDoc = cloneDeep(doc);
@@ -370,7 +370,7 @@ const transformSplitFiltersStringToQueryObject: SavedObjectMigrationFn = doc =>
return newDoc;
};
-const migrateFiltersAggQuery: SavedObjectMigrationFn = doc => {
+const migrateFiltersAggQuery: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
if (visStateJSON) {
@@ -402,7 +402,7 @@ const migrateFiltersAggQuery: SavedObjectMigrationFn = doc => {
return doc;
};
-const replaceMovAvgToMovFn: SavedObjectMigrationFn = (doc, logger) => {
+const replaceMovAvgToMovFn: SavedObjectMigrationFn = (doc, logger) => {
const visStateJSON = get(doc, 'attributes.visState');
let visState;
@@ -450,7 +450,7 @@ const replaceMovAvgToMovFn: SavedObjectMigrationFn = (doc, logger) => {
return doc;
};
-const migrateFiltersAggQueryStringQueries: SavedObjectMigrationFn = (doc, logger) => {
+const migrateFiltersAggQueryStringQueries: SavedObjectMigrationFn = (doc, logger) => {
const visStateJSON = get(doc, 'attributes.visState');
if (visStateJSON) {
@@ -483,12 +483,12 @@ const migrateFiltersAggQueryStringQueries: SavedObjectMigrationFn = (doc, logger
return doc;
};
-const addDocReferences: SavedObjectMigrationFn = doc => ({
+const addDocReferences: SavedObjectMigrationFn = doc => ({
...doc,
references: doc.references || [],
});
-const migrateSavedSearch: SavedObjectMigrationFn = doc => {
+const migrateSavedSearch: SavedObjectMigrationFn = doc => {
const savedSearchId = get(doc, 'attributes.savedSearchId');
if (savedSearchId && doc.references) {
@@ -505,7 +505,7 @@ const migrateSavedSearch: SavedObjectMigrationFn = doc => {
return doc;
};
-const migrateControls: SavedObjectMigrationFn = doc => {
+const migrateControls: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
if (visStateJSON) {
@@ -536,7 +536,7 @@ const migrateControls: SavedObjectMigrationFn = doc => {
return doc;
};
-const migrateTableSplits: SavedObjectMigrationFn = doc => {
+const migrateTableSplits: SavedObjectMigrationFn = doc => {
try {
const visState = JSON.parse(doc.attributes.visState);
if (get(visState, 'type') !== 'table') {
@@ -572,7 +572,7 @@ const migrateTableSplits: SavedObjectMigrationFn = doc => {
}
};
-const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (searchSourceJSON) {
@@ -606,7 +606,7 @@ const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
};
// [TSVB] Default color palette is changing, keep the default for older viz
-const migrateTsvbDefaultColorPalettes: SavedObjectMigrationFn = doc => {
+const migrateTsvbDefaultColorPalettes: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
let visState;
@@ -649,27 +649,30 @@ export const visualizationSavedObjectTypeMigrations = {
* in that version. So we apply this twice, once with 6.7.2 and once with 7.0.1 while the backport to 6.7
* only contained the 6.7.2 migration and not the 7.0.1 migration.
*/
- '6.7.2': flow(migrateMatchAllQuery, removeDateHistogramTimeZones),
- '7.0.0': flow(
+ '6.7.2': flow>(
+ migrateMatchAllQuery,
+ removeDateHistogramTimeZones
+ ),
+ '7.0.0': flow>(
addDocReferences,
migrateIndexPattern,
migrateSavedSearch,
migrateControls,
migrateTableSplits
),
- '7.0.1': flow(removeDateHistogramTimeZones),
- '7.2.0': flow(
+ '7.0.1': flow>(removeDateHistogramTimeZones),
+ '7.2.0': flow>(
migratePercentileRankAggregation,
migrateDateHistogramAggregation
),
- '7.3.0': flow(
+ '7.3.0': flow>(
migrateGaugeVerticalSplitToAlignment,
transformFilterStringToQueryObject,
migrateFiltersAggQuery,
replaceMovAvgToMovFn
),
- '7.3.1': flow(migrateFiltersAggQueryStringQueries),
- '7.4.2': flow(transformSplitFiltersStringToQueryObject),
- '7.7.0': flow(migrateOperatorKeyTypo),
- '7.8.0': flow(migrateTsvbDefaultColorPalettes),
+ '7.3.1': flow>(migrateFiltersAggQueryStringQueries),
+ '7.4.2': flow>(transformSplitFiltersStringToQueryObject),
+ '7.7.0': flow>(migrateOperatorKeyTypo),
+ '7.8.0': flow>(migrateTsvbDefaultColorPalettes),
};
diff --git a/src/plugins/visualize/public/application/application.ts b/src/plugins/visualize/public/application/application.ts
index 9d8a1b98ef023..19551bba9a43e 100644
--- a/src/plugins/visualize/public/application/application.ts
+++ b/src/plugins/visualize/public/application/application.ts
@@ -20,6 +20,8 @@
import './index.scss';
import angular, { IModule } from 'angular';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import { AppMountContext } from 'kibana/public';
diff --git a/tasks/config/karma.js b/tasks/config/karma.js
index 1ec7c831b4864..f87edbf04f220 100644
--- a/tasks/config/karma.js
+++ b/tasks/config/karma.js
@@ -25,6 +25,7 @@ import { DllCompiler } from '../../src/optimize/dynamic_dll_plugin';
const TOTAL_CI_SHARDS = 4;
const ROOT = dirname(require.resolve('../../package.json'));
+const buildHash = String(Number.MAX_SAFE_INTEGER);
module.exports = function(grunt) {
function pickBrowser() {
@@ -57,27 +58,30 @@ module.exports = function(grunt) {
'http://localhost:5610/test_bundle/karma/globals.js',
...UiSharedDeps.jsDepFilenames.map(
- chunkFilename => `http://localhost:5610/bundles/kbn-ui-shared-deps/${chunkFilename}`
+ chunkFilename =>
+ `http://localhost:5610/${buildHash}/bundles/kbn-ui-shared-deps/${chunkFilename}`
),
- `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.jsFilename}`,
+ `http://localhost:5610/${buildHash}/bundles/kbn-ui-shared-deps/${UiSharedDeps.jsFilename}`,
- 'http://localhost:5610/built_assets/dlls/vendors_runtime.bundle.dll.js',
+ `http://localhost:5610/${buildHash}/built_assets/dlls/vendors_runtime.bundle.dll.js`,
...DllCompiler.getRawDllConfig().chunks.map(
- chunk => `http://localhost:5610/built_assets/dlls/vendors${chunk}.bundle.dll.js`
+ chunk =>
+ `http://localhost:5610/${buildHash}/built_assets/dlls/vendors${chunk}.bundle.dll.js`
),
shardNum === undefined
- ? `http://localhost:5610/bundles/tests.bundle.js`
- : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`,
+ ? `http://localhost:5610/${buildHash}/bundles/tests.bundle.js`
+ : `http://localhost:5610/${buildHash}/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`,
- `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
+ `http://localhost:5610/${buildHash}/bundles/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
// this causes tilemap tests to fail, probably because the eui styles haven't been
// included in the karma harness a long some time, if ever
// `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
...DllCompiler.getRawDllConfig().chunks.map(
- chunk => `http://localhost:5610/built_assets/dlls/vendors${chunk}.style.dll.css`
+ chunk =>
+ `http://localhost:5610/${buildHash}/built_assets/dlls/vendors${chunk}.style.dll.css`
),
- 'http://localhost:5610/bundles/tests.style.css',
+ `http://localhost:5610/${buildHash}/bundles/tests.style.css`,
];
}
@@ -127,9 +131,9 @@ module.exports = function(grunt) {
proxies: {
'/tests/': 'http://localhost:5610/tests/',
- '/bundles/': 'http://localhost:5610/bundles/',
- '/built_assets/dlls/': 'http://localhost:5610/built_assets/dlls/',
'/test_bundle/': 'http://localhost:5610/test_bundle/',
+ [`/${buildHash}/bundles/`]: `http://localhost:5610/${buildHash}/bundles/`,
+ [`/${buildHash}/built_assets/dlls/`]: `http://localhost:5610/${buildHash}/built_assets/dlls/`,
},
client: {
diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js
index c5bfc847d0041..0c4028905657d 100644
--- a/test/api_integration/apis/index.js
+++ b/test/api_integration/apis/index.js
@@ -33,5 +33,6 @@ export default function({ loadTestFile }) {
loadTestFile(require.resolve('./status'));
loadTestFile(require.resolve('./stats'));
loadTestFile(require.resolve('./ui_metric'));
+ loadTestFile(require.resolve('./telemetry'));
});
}
diff --git a/src/core/public/plugins/plugin_loader.mock.ts b/test/api_integration/apis/telemetry/index.js
similarity index 57%
rename from src/core/public/plugins/plugin_loader.mock.ts
rename to test/api_integration/apis/telemetry/index.js
index abdd9d4ddce2a..c79f5cb470890 100644
--- a/src/core/public/plugins/plugin_loader.mock.ts
+++ b/test/api_integration/apis/telemetry/index.js
@@ -17,17 +17,10 @@
* under the License.
*/
-import { PluginName } from 'src/core/server';
-import { LoadPluginBundle, UnknownPluginInitializer } from './plugin_loader';
-
-/**
- * @param initializerProvider A function provided by the test to resolve initializers.
- */
-const createLoadPluginBundleMock = (
- initializerProvider: (name: PluginName) => UnknownPluginInitializer
-): jest.Mock, Parameters> =>
- jest.fn((addBasePath, pluginName, _ = {}) => {
- return Promise.resolve(initializerProvider(pluginName)) as any;
+export default function({ loadTestFile }) {
+ describe('Telemetry', () => {
+ loadTestFile(require.resolve('./telemetry_local'));
+ loadTestFile(require.resolve('./opt_in'));
+ loadTestFile(require.resolve('./telemetry_optin_notice_seen'));
});
-
-export const loadPluginBundleMock = { create: createLoadPluginBundleMock };
+}
diff --git a/test/api_integration/apis/telemetry/opt_in.ts b/test/api_integration/apis/telemetry/opt_in.ts
new file mode 100644
index 0000000000000..e4654ee3985f3
--- /dev/null
+++ b/test/api_integration/apis/telemetry/opt_in.ts
@@ -0,0 +1,123 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import expect from '@kbn/expect';
+
+import { TelemetrySavedObjectAttributes } from 'src/plugins/telemetry/server/telemetry_repository';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function optInTest({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const kibanaServer = getService('kibanaServer');
+ describe('/api/telemetry/v2/optIn API', () => {
+ let defaultAttributes: TelemetrySavedObjectAttributes;
+ let kibanaVersion: any;
+ before(async () => {
+ const kibanaVersionAccessor = kibanaServer.version;
+ kibanaVersion = await kibanaVersionAccessor.get();
+ defaultAttributes =
+ (await getSavedObjectAttributes(supertest).catch(err => {
+ if (err.message === 'expected 200 "OK", got 404 "Not Found"') {
+ return null;
+ }
+ throw err;
+ })) || {};
+
+ expect(typeof kibanaVersion).to.eql('string');
+ expect(kibanaVersion.length).to.be.greaterThan(0);
+ });
+
+ afterEach(async () => {
+ await updateSavedObjectAttributes(supertest, defaultAttributes);
+ });
+
+ it('should support sending false with allowChangingOptInStatus true', async () => {
+ await updateSavedObjectAttributes(supertest, {
+ ...defaultAttributes,
+ allowChangingOptInStatus: true,
+ });
+ await postTelemetryV2Optin(supertest, false, 200);
+ const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest);
+ expect(enabled).to.be(false);
+ expect(lastVersionChecked).to.be(kibanaVersion);
+ });
+
+ it('should support sending true with allowChangingOptInStatus true', async () => {
+ await updateSavedObjectAttributes(supertest, {
+ ...defaultAttributes,
+ allowChangingOptInStatus: true,
+ });
+ await postTelemetryV2Optin(supertest, true, 200);
+ const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest);
+ expect(enabled).to.be(true);
+ expect(lastVersionChecked).to.be(kibanaVersion);
+ });
+
+ it('should not support sending false with allowChangingOptInStatus false', async () => {
+ await updateSavedObjectAttributes(supertest, {
+ ...defaultAttributes,
+ allowChangingOptInStatus: false,
+ });
+ await postTelemetryV2Optin(supertest, false, 400);
+ });
+
+ it('should not support sending true with allowChangingOptInStatus false', async () => {
+ await updateSavedObjectAttributes(supertest, {
+ ...defaultAttributes,
+ allowChangingOptInStatus: false,
+ });
+ await postTelemetryV2Optin(supertest, true, 400);
+ });
+
+ it('should not support sending null', async () => {
+ await postTelemetryV2Optin(supertest, null, 400);
+ });
+
+ it('should not support sending junk', async () => {
+ await postTelemetryV2Optin(supertest, 42, 400);
+ });
+ });
+}
+
+async function postTelemetryV2Optin(supertest: any, value: any, statusCode: number): Promise {
+ const { body } = await supertest
+ .post('/api/telemetry/v2/optIn')
+ .set('kbn-xsrf', 'xxx')
+ .send({ enabled: value })
+ .expect(statusCode);
+
+ return body;
+}
+
+async function updateSavedObjectAttributes(
+ supertest: any,
+ attributes: TelemetrySavedObjectAttributes
+): Promise {
+ return await supertest
+ .post('/api/saved_objects/telemetry/telemetry')
+ .query({ overwrite: true })
+ .set('kbn-xsrf', 'xxx')
+ .send({ attributes })
+ .expect(200);
+}
+
+async function getSavedObjectAttributes(supertest: any): Promise {
+ const { body } = await supertest.get('/api/saved_objects/telemetry/telemetry').expect(200);
+ return body.attributes;
+}
diff --git a/test/api_integration/apis/telemetry/telemetry_local.js b/test/api_integration/apis/telemetry/telemetry_local.js
new file mode 100644
index 0000000000000..84bfd8a755c11
--- /dev/null
+++ b/test/api_integration/apis/telemetry/telemetry_local.js
@@ -0,0 +1,133 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import expect from '@kbn/expect';
+import _ from 'lodash';
+
+/*
+ * Create a single-level array with strings for all the paths to values in the
+ * source object, up to 3 deep. Going deeper than 3 causes a bit too much churn
+ * in the tests.
+ */
+function flatKeys(source) {
+ const recursivelyFlatKeys = (obj, path = [], depth = 0) => {
+ return depth < 3 && _.isObject(obj)
+ ? _.map(obj, (v, k) => recursivelyFlatKeys(v, [...path, k], depth + 1))
+ : path.join('.');
+ };
+
+ return _.uniq(_.flattenDeep(recursivelyFlatKeys(source))).sort((a, b) => a.localeCompare(b));
+}
+
+export default function({ getService }) {
+ const supertest = getService('supertest');
+
+ describe('/api/telemetry/v2/clusters/_stats', () => {
+ it('should pull local stats and validate data types', async () => {
+ const timeRange = {
+ min: '2018-07-23T22:07:00Z',
+ max: '2018-07-23T22:13:00Z',
+ };
+
+ const { body } = await supertest
+ .post('/api/telemetry/v2/clusters/_stats')
+ .set('kbn-xsrf', 'xxx')
+ .send({ timeRange, unencrypted: true })
+ .expect(200);
+
+ expect(body.length).to.be(1);
+ const stats = body[0];
+ expect(stats.collection).to.be('local');
+ expect(stats.stack_stats.kibana.count).to.be.a('number');
+ expect(stats.stack_stats.kibana.indices).to.be.a('number');
+ expect(stats.stack_stats.kibana.os.platforms[0].platform).to.be.a('string');
+ expect(stats.stack_stats.kibana.os.platforms[0].count).to.be(1);
+ expect(stats.stack_stats.kibana.os.platformReleases[0].platformRelease).to.be.a('string');
+ expect(stats.stack_stats.kibana.os.platformReleases[0].count).to.be(1);
+ expect(stats.stack_stats.kibana.plugins.telemetry.opt_in_status).to.be(false);
+ expect(stats.stack_stats.kibana.plugins.telemetry.usage_fetcher).to.be.a('string');
+ expect(stats.stack_stats.kibana.plugins.stack_management).to.be.an('object');
+ expect(stats.stack_stats.kibana.plugins.ui_metric).to.be.an('object');
+ expect(stats.stack_stats.kibana.plugins.application_usage).to.be.an('object');
+ expect(stats.stack_stats.kibana.plugins.kql.defaultQueryLanguage).to.be.a('string');
+ expect(stats.stack_stats.kibana.plugins['tsvb-validation']).to.be.an('object');
+ expect(stats.stack_stats.kibana.plugins.localization).to.be.an('object');
+ expect(stats.stack_stats.kibana.plugins.csp.strict).to.be(true);
+ expect(stats.stack_stats.kibana.plugins.csp.warnLegacyBrowsers).to.be(true);
+ expect(stats.stack_stats.kibana.plugins.csp.rulesChangedFromDefault).to.be(false);
+ });
+
+ it('should pull local stats and validate fields', async () => {
+ const timeRange = {
+ min: '2018-07-23T22:07:00Z',
+ max: '2018-07-23T22:13:00Z',
+ };
+
+ const { body } = await supertest
+ .post('/api/telemetry/v2/clusters/_stats')
+ .set('kbn-xsrf', 'xxx')
+ .send({ timeRange, unencrypted: true })
+ .expect(200);
+
+ const stats = body[0];
+
+ const actual = flatKeys(stats);
+ expect(actual).to.be.an('array');
+ const expected = [
+ 'cluster_name',
+ 'cluster_stats.cluster_uuid',
+ 'cluster_stats.indices.analysis',
+ 'cluster_stats.indices.completion',
+ 'cluster_stats.indices.count',
+ 'cluster_stats.indices.docs',
+ 'cluster_stats.indices.fielddata',
+ 'cluster_stats.indices.mappings',
+ 'cluster_stats.indices.query_cache',
+ 'cluster_stats.indices.segments',
+ 'cluster_stats.indices.shards',
+ 'cluster_stats.indices.store',
+ 'cluster_stats.nodes.count',
+ 'cluster_stats.nodes.discovery_types',
+ 'cluster_stats.nodes.fs',
+ 'cluster_stats.nodes.ingest',
+ 'cluster_stats.nodes.jvm',
+ 'cluster_stats.nodes.network_types',
+ 'cluster_stats.nodes.os',
+ 'cluster_stats.nodes.packaging_types',
+ 'cluster_stats.nodes.plugins',
+ 'cluster_stats.nodes.process',
+ 'cluster_stats.nodes.versions',
+ 'cluster_stats.status',
+ 'cluster_stats.timestamp',
+ 'cluster_uuid',
+ 'collection',
+ 'collectionSource',
+ 'stack_stats.kibana.count',
+ 'stack_stats.kibana.indices',
+ 'stack_stats.kibana.os',
+ 'stack_stats.kibana.plugins',
+ 'stack_stats.kibana.versions',
+ 'timestamp',
+ 'version',
+ ];
+
+ expect(expected.every(m => actual.includes(m))).to.be.ok();
+ });
+ });
+}
diff --git a/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts
new file mode 100644
index 0000000000000..4413c672fb46c
--- /dev/null
+++ b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import expect from '@kbn/expect';
+import { Client, DeleteDocumentParams, GetParams, GetResponse } from 'elasticsearch';
+import { TelemetrySavedObjectAttributes } from 'src/plugins/telemetry/server/telemetry_repository';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function optInTest({ getService }: FtrProviderContext) {
+ const client: Client = getService('legacyEs');
+ const supertest = getService('supertest');
+
+ describe('/api/telemetry/v2/userHasSeenNotice API Telemetry User has seen OptIn Notice', () => {
+ it('should update telemetry setting field via PUT', async () => {
+ try {
+ await client.delete({
+ index: '.kibana',
+ id: 'telemetry:telemetry',
+ } as DeleteDocumentParams);
+ } catch (err) {
+ if (err.statusCode !== 404) {
+ throw err;
+ }
+ }
+
+ await supertest
+ .put('/api/telemetry/v2/userHasSeenNotice')
+ .set('kbn-xsrf', 'xxx')
+ .expect(200);
+
+ const {
+ _source: { telemetry },
+ }: GetResponse<{
+ telemetry: TelemetrySavedObjectAttributes;
+ }> = await client.get({
+ index: '.kibana',
+ id: 'telemetry:telemetry',
+ } as GetParams);
+
+ expect(telemetry.userHasSeenNotice).to.be(true);
+ });
+ });
+}
diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js
index eeef3333aab0f..dcd185eba00e6 100644
--- a/test/functional/apps/discover/_discover_histogram.js
+++ b/test/functional/apps/discover/_discover_histogram.js
@@ -48,7 +48,7 @@ export default function({ getService, getPageObjects }) {
log.debug('create long_window_logstash index pattern');
// NOTE: long_window_logstash load does NOT create index pattern
- await PageObjects.settings.createIndexPattern('long-window-logstash-');
+ await PageObjects.settings.createIndexPattern('long-window-logstash-*');
await kibanaServer.uiSettings.replace(defaultSettings);
await browser.refresh();
diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js
index 9a4bb0081b7ad..3a3d6b93e166b 100644
--- a/test/functional/apps/getting_started/_shakespeare.js
+++ b/test/functional/apps/getting_started/_shakespeare.js
@@ -59,9 +59,9 @@ export default function({ getService, getPageObjects }) {
it('should create shakespeare index pattern', async function() {
log.debug('Create shakespeare index pattern');
- await PageObjects.settings.createIndexPattern('shakes', null);
+ await PageObjects.settings.createIndexPattern('shakespeare', null);
const patternName = await PageObjects.settings.getIndexPageHeading();
- expect(patternName).to.be('shakes*');
+ expect(patternName).to.be('shakespeare');
});
// https://www.elastic.co/guide/en/kibana/current/tutorial-visualizing.html
@@ -74,7 +74,7 @@ export default function({ getService, getPageObjects }) {
log.debug('create shakespeare vertical bar chart');
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVerticalBarChart();
- await PageObjects.visualize.clickNewSearch('shakes*');
+ await PageObjects.visualize.clickNewSearch('shakespeare');
await PageObjects.visChart.waitForVisualization();
const expectedChartValues = [111396];
diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js
index 616e2297b2f51..2545b8f324d1b 100644
--- a/test/functional/apps/management/_index_pattern_create_delete.js
+++ b/test/functional/apps/management/_index_pattern_create_delete.js
@@ -44,10 +44,7 @@ export default function({ getService, getPageObjects }) {
it('should handle special charaters in template input', async () => {
await PageObjects.settings.clickAddNewIndexPatternButton();
await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.settings.setIndexPatternField({
- indexPatternName: '❤️',
- expectWildcard: false,
- });
+ await PageObjects.settings.setIndexPatternField('❤️');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts
index 862e5127bb670..93debdcc37f0a 100644
--- a/test/functional/page_objects/common_page.ts
+++ b/test/functional/page_objects/common_page.ts
@@ -44,6 +44,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
ensureCurrentUrl: boolean;
shouldLoginIfPrompted: boolean;
useActualUrl: boolean;
+ insertTimestamp: boolean;
}
class CommonPage {
@@ -65,7 +66,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
* Logins to Kibana as default user and navigates to provided app
* @param appUrl Kibana URL
*/
- private async loginIfPrompted(appUrl: string) {
+ private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) {
let currentUrl = await browser.getCurrentUrl();
log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`);
await testSubjects.find('kibanaChrome', 6 * defaultFindTimeout); // 60 sec waiting
@@ -87,7 +88,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
'[data-test-subj="kibanaChrome"] nav:not(.ng-hide)',
6 * defaultFindTimeout
);
- await browser.get(appUrl);
+ await browser.get(appUrl, insertTimestamp);
currentUrl = await browser.getCurrentUrl();
log.debug(`Finished login process currentUrl = ${currentUrl}`);
}
@@ -95,7 +96,13 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
}
private async navigate(navigateProps: NavigateProps) {
- const { appConfig, ensureCurrentUrl, shouldLoginIfPrompted, useActualUrl } = navigateProps;
+ const {
+ appConfig,
+ ensureCurrentUrl,
+ shouldLoginIfPrompted,
+ useActualUrl,
+ insertTimestamp,
+ } = navigateProps;
const appUrl = getUrl.noAuth(config.get('servers.kibana'), appConfig);
await retry.try(async () => {
@@ -111,7 +118,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
}
const currentUrl = shouldLoginIfPrompted
- ? await this.loginIfPrompted(appUrl)
+ ? await this.loginIfPrompted(appUrl, insertTimestamp)
: await browser.getCurrentUrl();
if (ensureCurrentUrl && !currentUrl.includes(appUrl)) {
@@ -134,6 +141,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
ensureCurrentUrl = true,
shouldLoginIfPrompted = true,
useActualUrl = false,
+ insertTimestamp = true,
} = {}
) {
const appConfig = {
@@ -146,6 +154,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
ensureCurrentUrl,
shouldLoginIfPrompted,
useActualUrl,
+ insertTimestamp,
});
}
@@ -165,6 +174,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
ensureCurrentUrl = true,
shouldLoginIfPrompted = true,
useActualUrl = true,
+ insertTimestamp = true,
} = {}
) {
const appConfig = {
@@ -178,6 +188,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
ensureCurrentUrl,
shouldLoginIfPrompted,
useActualUrl,
+ insertTimestamp,
});
}
@@ -208,7 +219,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
async navigateToApp(
appName: string,
- { basePath = '', shouldLoginIfPrompted = true, hash = '' } = {}
+ { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {}
) {
let appUrl: string;
if (config.has(['apps', appName])) {
@@ -239,7 +250,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo
log.debug('returned from get, calling refresh');
await browser.refresh();
let currentUrl = shouldLoginIfPrompted
- ? await this.loginIfPrompted(appUrl)
+ ? await this.loginIfPrompted(appUrl, insertTimestamp)
: await browser.getCurrentUrl();
if (currentUrl.includes('app/kibana')) {
diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts
index 8864eda3823ef..81d22838d1e8b 100644
--- a/test/functional/page_objects/settings_page.ts
+++ b/test/functional/page_objects/settings_page.ts
@@ -334,7 +334,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
}
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
- await this.setIndexPatternField({ indexPatternName });
+ await this.setIndexPatternField(indexPatternName);
});
await PageObjects.common.sleep(2000);
await (await this.getCreateIndexPatternGoToStep2Button()).click();
@@ -375,14 +375,32 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
return indexPatternId;
}
- async setIndexPatternField({ indexPatternName = 'logstash-', expectWildcard = true } = {}) {
+ async setIndexPatternField(indexPatternName = 'logstash-*') {
log.debug(`setIndexPatternField(${indexPatternName})`);
const field = await this.getIndexPatternField();
await field.clearValue();
- await field.type(indexPatternName, { charByChar: true });
+ if (
+ indexPatternName.charAt(0) === '*' &&
+ indexPatternName.charAt(indexPatternName.length - 1) === '*'
+ ) {
+ // this is a special case when the index pattern name starts with '*'
+ // like '*:makelogs-*' where the UI will not append *
+ await field.type(indexPatternName, { charByChar: true });
+ } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') {
+ // the common case where the UI will append '*' automatically so we won't type it
+ const tempName = indexPatternName.slice(0, -1);
+ await field.type(tempName, { charByChar: true });
+ } else {
+ // case where we don't want the * appended so we'll remove it if it was added
+ await field.type(indexPatternName, { charByChar: true });
+ const tempName = await field.getAttribute('value');
+ if (tempName.length > indexPatternName.length) {
+ await field.type(browser.keys.DELETE, { charByChar: true });
+ }
+ }
const currentName = await field.getAttribute('value');
log.debug(`setIndexPatternField set to ${currentName}`);
- expect(currentName).to.eql(`${indexPatternName}${expectWildcard ? '*' : ''}`);
+ expect(currentName).to.eql(indexPatternName);
}
async getCreateIndexPatternGoToStep2Button() {
diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts
new file mode 100644
index 0000000000000..5ea151dffdc8e
--- /dev/null
+++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts
@@ -0,0 +1,93 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import expect from '@kbn/expect';
+import { ExpectExpression, expectExpressionProvider } from './helpers';
+import { FtrProviderContext } from '../../../functional/ftr_provider_context';
+
+function getCell(esaggsResult: any, column: number, row: number): unknown | undefined {
+ const columnId = esaggsResult?.columns[column]?.id;
+ if (!columnId) {
+ return;
+ }
+ return esaggsResult?.rows[row]?.[columnId];
+}
+
+export default function({
+ getService,
+ updateBaselines,
+}: FtrProviderContext & { updateBaselines: boolean }) {
+ let expectExpression: ExpectExpression;
+ describe('esaggs pipeline expression tests', () => {
+ before(() => {
+ expectExpression = expectExpressionProvider({ getService, updateBaselines });
+ });
+
+ describe('correctly renders tagcloud', () => {
+ it('filters on index pattern primary date field by default', async () => {
+ const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
+ const timeRange = {
+ from: '2006-09-21T00:00:00Z',
+ to: '2015-09-22T00:00:00Z',
+ };
+ const expression = `
+ kibana_context timeRange='${JSON.stringify(timeRange)}'
+ | esaggs index='logstash-*' aggConfigs='${JSON.stringify(aggConfigs)}'
+ `;
+ const result = await expectExpression('esaggs_primary_timefield', expression).getResponse();
+ expect(getCell(result, 0, 0)).to.be(9375);
+ });
+
+ it('filters on the specified date field', async () => {
+ const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
+ const timeRange = {
+ from: '2006-09-21T00:00:00Z',
+ to: '2015-09-22T00:00:00Z',
+ };
+ const expression = `
+ kibana_context timeRange='${JSON.stringify(timeRange)}'
+ | esaggs index='logstash-*' timeFields='relatedContent.article:published_time' aggConfigs='${JSON.stringify(
+ aggConfigs
+ )}'
+ `;
+ const result = await expectExpression('esaggs_other_timefield', expression).getResponse();
+ expect(getCell(result, 0, 0)).to.be(11134);
+ });
+
+ it('filters on multiple specified date field', async () => {
+ const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
+ const timeRange = {
+ from: '2006-09-21T00:00:00Z',
+ to: '2015-09-22T00:00:00Z',
+ };
+ const expression = `
+ kibana_context timeRange='${JSON.stringify(timeRange)}'
+ | esaggs index='logstash-*' timeFields='relatedContent.article:published_time' timeFields='@timestamp' aggConfigs='${JSON.stringify(
+ aggConfigs
+ )}'
+ `;
+ const result = await expectExpression(
+ 'esaggs_multiple_timefields',
+ expression
+ ).getResponse();
+ expect(getCell(result, 0, 0)).to.be(7452);
+ });
+ });
+ });
+}
diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts
index 031a0e3576ccc..9590f9f8c1794 100644
--- a/test/interpreter_functional/test_suites/run_pipeline/index.ts
+++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts
@@ -46,5 +46,6 @@ export default function({ getService, getPageObjects, loadTestFile }: FtrProvide
loadTestFile(require.resolve('./basic'));
loadTestFile(require.resolve('./tag_cloud'));
loadTestFile(require.resolve('./metric'));
+ loadTestFile(require.resolve('./esaggs'));
});
}
diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts
index b6d13a5604011..c384e41851e15 100644
--- a/test/plugin_functional/test_suites/core_plugins/application_status.ts
+++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import url from 'url';
import expect from '@kbn/expect';
import {
AppNavLinkStatus,
@@ -26,6 +27,15 @@ import {
import { PluginFunctionalProviderContext } from '../../services';
import '../../plugins/core_app_status/public/types';
+const getKibanaUrl = (pathname?: string, search?: string) =>
+ url.format({
+ protocol: 'http:',
+ hostname: process.env.TEST_KIBANA_HOST || 'localhost',
+ port: process.env.TEST_KIBANA_PORT || '5620',
+ pathname,
+ search,
+ });
+
// eslint-disable-next-line import/no-default-export
export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) {
const PageObjects = getPageObjects(['common']);
@@ -97,6 +107,22 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
expect(await testSubjects.exists('appStatusApp')).to.eql(true);
});
+ it('allows to change the defaultPath of an application', async () => {
+ let link = await appsMenu.getLink('App Status');
+ expect(link!.href).to.eql(getKibanaUrl('/app/app_status'));
+
+ await setAppStatus({
+ defaultPath: '/arbitrary/path',
+ });
+
+ link = await appsMenu.getLink('App Status');
+ expect(link!.href).to.eql(getKibanaUrl('/app/app_status/arbitrary/path'));
+
+ await navigateToApp('app_status');
+ expect(await testSubjects.exists('appStatusApp')).to.eql(true);
+ expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/app_status/arbitrary/path'));
+ });
+
it('can change the state of the currently mounted app', async () => {
await setAppStatus({
status: AppStatus.accessible,
diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh
index 67d88b308ed91..951ba8e22d885 100755
--- a/test/scripts/jenkins_xpack.sh
+++ b/test/scripts/jenkins_xpack.sh
@@ -39,7 +39,7 @@ else
# build runtime for canvas
echo "NODE_ENV=$NODE_ENV"
node ./legacy/plugins/canvas/scripts/shareable_runtime
- node --max-old-space-size=6144 scripts/jest --ci --verbose --coverage
+ node --max-old-space-size=6144 scripts/jest --ci --verbose --detectOpenHandles --coverage
# rename file in order to be unique one
test -f ../target/kibana-coverage/jest/coverage-final.json \
&& mv ../target/kibana-coverage/jest/coverage-final.json \
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index ae1a55254c702..4acb170d12574 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -43,7 +43,7 @@
"xpack.transform": "plugins/transform",
"xpack.triggersActionsUI": "plugins/triggers_actions_ui",
"xpack.upgradeAssistant": "plugins/upgrade_assistant",
- "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"],
+ "xpack.uptime": ["plugins/uptime"],
"xpack.watcher": "plugins/watcher"
},
"translations": [
diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js
index af5ace8e3cd3b..4f1251321b005 100644
--- a/x-pack/dev-tools/jest/create_jest_config.js
+++ b/x-pack/dev-tools/jest/create_jest_config.js
@@ -34,6 +34,20 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) {
'^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`,
'^(!!)?file-loader!': fileMockPath,
},
+ collectCoverageFrom: [
+ 'legacy/plugins/**/*.{js,jsx,ts,tsx}',
+ 'legacy/server/**/*.{js,jsx,ts,tsx}',
+ 'plugins/**/*.{js,jsx,ts,tsx}',
+ '!**/{__test__,__snapshots__,__examples__,integration_tests,tests}/**',
+ '!**/*.test.{js,ts,tsx}',
+ '!**/flot-charts/**',
+ '!**/test/**',
+ '!**/build/**',
+ '!**/scripts/**',
+ '!**/mocks/**',
+ '!**/plugins/apm/e2e/**',
+ ],
+ coveragePathIgnorePatterns: ['.*\\.d\\.ts'],
coverageDirectory: '/../target/kibana-coverage/jest',
coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'],
setupFiles: [
diff --git a/x-pack/index.js b/x-pack/index.js
index fe4c325405933..cfadddac3994a 100644
--- a/x-pack/index.js
+++ b/x-pack/index.js
@@ -16,12 +16,8 @@ import { spaces } from './legacy/plugins/spaces';
import { canvas } from './legacy/plugins/canvas';
import { infra } from './legacy/plugins/infra';
import { taskManager } from './legacy/plugins/task_manager';
-import { uptime } from './legacy/plugins/uptime';
import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects';
-import { actions } from './legacy/plugins/actions';
-import { alerting } from './legacy/plugins/alerting';
import { ingestManager } from './legacy/plugins/ingest_manager';
-import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui';
module.exports = function(kibana) {
return [
@@ -37,11 +33,7 @@ module.exports = function(kibana) {
canvas(kibana),
infra(kibana),
taskManager(kibana),
- uptime(kibana),
encryptedSavedObjects(kibana),
- actions(kibana),
- alerting(kibana),
ingestManager(kibana),
- triggersActionsUI(kibana),
];
};
diff --git a/x-pack/legacy/plugins/actions/server/index.ts b/x-pack/legacy/plugins/actions/server/index.ts
deleted file mode 100644
index 63dd6f99f9c24..0000000000000
--- a/x-pack/legacy/plugins/actions/server/index.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-import { Root } from 'joi';
-import { Legacy } from 'kibana';
-import mappings from './mappings.json';
-import {
- LegacyPluginApi,
- LegacyPluginSpec,
- ArrayOrItem,
-} from '../../../../../src/legacy/plugin_discovery/types';
-
-export function actions(kibana: LegacyPluginApi): ArrayOrItem {
- return new kibana.Plugin({
- id: 'actions',
- configPrefix: 'xpack.actions',
- config(Joi: Root) {
- return Joi.object({
- enabled: Joi.boolean().default(true),
- })
- .unknown(true)
- .default();
- },
- require: ['kibana', 'elasticsearch'],
- isEnabled(config: Legacy.KibanaConfig) {
- return (
- config.get('xpack.encryptedSavedObjects.enabled') === true &&
- config.get('xpack.actions.enabled') === true &&
- config.get('xpack.task_manager.enabled') === true
- );
- },
- uiExports: {
- mappings,
- },
- } as Legacy.PluginSpecOptions);
-}
diff --git a/x-pack/legacy/plugins/alerting/server/index.ts b/x-pack/legacy/plugins/alerting/server/index.ts
deleted file mode 100644
index 065af7dedebd9..0000000000000
--- a/x-pack/legacy/plugins/alerting/server/index.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { Legacy } from 'kibana';
-import { Root } from 'joi';
-import mappings from './mappings.json';
-import {
- LegacyPluginApi,
- LegacyPluginSpec,
- ArrayOrItem,
-} from '../../../../../src/legacy/plugin_discovery/types';
-
-export function alerting(kibana: LegacyPluginApi): ArrayOrItem {
- return new kibana.Plugin({
- id: 'alerting',
- configPrefix: 'xpack.alerting',
- require: ['kibana', 'elasticsearch', 'actions', 'task_manager', 'encryptedSavedObjects'],
- isEnabled(config: Legacy.KibanaConfig) {
- return (
- config.get('xpack.alerting.enabled') === true &&
- config.get('xpack.actions.enabled') === true &&
- config.get('xpack.encryptedSavedObjects.enabled') === true &&
- config.get('xpack.task_manager.enabled') === true
- );
- },
- config(Joi: Root) {
- return Joi.object()
- .keys({
- enabled: Joi.boolean().default(true),
- })
- .default();
- },
- uiExports: {
- mappings,
- },
- } as Legacy.PluginSpecOptions);
-}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx
index 27095264461ee..f57ddb5cf69a2 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx
@@ -71,10 +71,10 @@ function getSpanTypes(span: Span) {
};
}
-const SpanBadge = styled(EuiBadge)`
+const SpanBadge = (styled(EuiBadge)`
display: inline-block;
margin-right: ${px(units.quarter)};
-` as any;
+` as unknown) as typeof EuiBadge;
const HttpInfoContainer = styled('div')`
margin-right: ${px(units.quarter)};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx
index a5d8902ff1626..f01b2aa335a3a 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx
@@ -10,10 +10,10 @@ import React from 'react';
import styled from 'styled-components';
import { px, units } from '../../../../../../style/variables';
-const SpanBadge = styled(EuiBadge)`
+const SpanBadge = (styled(EuiBadge)`
display: inline-block;
margin-right: ${px(units.quarter)};
-` as any;
+` as unknown) as typeof EuiBadge;
interface SyncBadgeProps {
/**
diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx
index 858e6d29bfa5e..2be3c82a8385b 100644
--- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx
@@ -17,7 +17,7 @@ interface Props {
const Badge = (styled(EuiBadge)`
margin-top: ${px(units.eighth)};
-` as any) as any;
+` as unknown) as typeof EuiBadge;
export const ErrorCountSummaryItemBadge = ({ count }: Props) => (
diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx
index 1e1d49b2cf417..d499dddeeb8b3 100644
--- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx
@@ -13,7 +13,7 @@ import { HttpStatusBadge } from '../HttpStatusBadge';
const HttpInfoBadge = (styled(EuiBadge)`
margin-right: ${px(units.quarter)};
-` as any) as any;
+` as unknown) as typeof EuiBadge;
const Url = styled('span')`
display: inline-block;
diff --git a/x-pack/legacy/plugins/canvas/public/components/app/track_route_change.js b/x-pack/legacy/plugins/canvas/public/components/app/track_route_change.js
index e837f5200a159..2886aa868eb9e 100644
--- a/x-pack/legacy/plugins/canvas/public/components/app/track_route_change.js
+++ b/x-pack/legacy/plugins/canvas/public/components/app/track_route_change.js
@@ -7,13 +7,17 @@
import { get } from 'lodash';
import { getWindow } from '../../lib/get_window';
import { CANVAS_APP } from '../../../common/lib/constants';
-import { getCoreStart, getStartPlugins } from '../../legacy';
+import { platformService } from '../../services';
export function trackRouteChange() {
- const basePath = getCoreStart().http.basePath.get();
- // storage.set(LOCALSTORAGE_LASTPAGE, pathname);
- getStartPlugins().__LEGACY.trackSubUrlForApp(
- CANVAS_APP,
- getStartPlugins().__LEGACY.absoluteToParsedUrl(get(getWindow(), 'location.href'), basePath)
- );
+ const basePath = platformService.getService().coreStart.http.basePath.get();
+
+ platformService
+ .getService()
+ .startPlugins.__LEGACY.trackSubUrlForApp(
+ CANVAS_APP,
+ platformService
+ .getService()
+ .startPlugins.__LEGACY.absoluteToParsedUrl(get(getWindow(), 'location.href'), basePath)
+ );
}
diff --git a/x-pack/legacy/plugins/canvas/public/legacy.ts b/x-pack/legacy/plugins/canvas/public/legacy.ts
index 5bb628909c32e..f83887bbcbdfd 100644
--- a/x-pack/legacy/plugins/canvas/public/legacy.ts
+++ b/x-pack/legacy/plugins/canvas/public/legacy.ts
@@ -35,8 +35,6 @@ const shimStartPlugins: CanvasStartDeps = {
__LEGACY: {
// ToDo: Copy directly into canvas
absoluteToParsedUrl,
- // ToDo: Copy directly into canvas
- formatMsg,
// ToDo: Won't be a part of New Platform. Will need to handle internally
trackSubUrlForApp: chrome.trackSubUrlForApp,
},
diff --git a/x-pack/legacy/plugins/canvas/public/lib/breadcrumbs.ts b/x-pack/legacy/plugins/canvas/public/lib/breadcrumbs.ts
index 834d5868c35ea..57b513affd781 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/breadcrumbs.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/breadcrumbs.ts
@@ -5,7 +5,7 @@
*/
import { ChromeBreadcrumb } from '../../../../../../src/core/public';
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
export const getBaseBreadcrumb = () => ({
text: 'Canvas',
@@ -24,6 +24,6 @@ export const getWorkpadBreadcrumb = ({
};
export const setBreadcrumb = (paths: ChromeBreadcrumb | ChromeBreadcrumb[]) => {
- const setBreadCrumbs = getCoreStart().chrome.setBreadcrumbs;
+ const setBreadCrumbs = platformService.getService().coreStart.chrome.setBreadcrumbs;
setBreadCrumbs(Array.isArray(paths) ? paths : [paths]);
};
diff --git a/x-pack/legacy/plugins/canvas/public/lib/custom_element_service.ts b/x-pack/legacy/plugins/canvas/public/lib/custom_element_service.ts
index 4118bb81b8363..8952802dc2f2b 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/custom_element_service.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/custom_element_service.ts
@@ -8,10 +8,10 @@ import { AxiosPromise } from 'axios';
import { API_ROUTE_CUSTOM_ELEMENT } from '../../common/lib/constants';
import { fetch } from '../../common/lib/fetch';
import { CustomElement } from '../../types';
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
const getApiPath = function() {
- const basePath = getCoreStart().http.basePath.get();
+ const basePath = platformService.getService().coreStart.http.basePath.get();
return `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`;
};
diff --git a/x-pack/legacy/plugins/canvas/public/lib/documentation_links.ts b/x-pack/legacy/plugins/canvas/public/lib/documentation_links.ts
index 40e09ee39d714..6430f7d87d4f7 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/documentation_links.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/documentation_links.ts
@@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
export const getDocumentationLinks = () => ({
- canvas: `${getCoreStart().docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${
- getCoreStart().docLinks.DOC_LINK_VERSION
+ canvas: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${
+ platformService.getService().coreStart.docLinks.DOC_LINK_VERSION
}/canvas.html`,
- numeral: `${getCoreStart().docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${
- getCoreStart().docLinks.DOC_LINK_VERSION
+ numeral: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${
+ platformService.getService().coreStart.docLinks.DOC_LINK_VERSION
}/guide/numeral.html`,
});
diff --git a/x-pack/legacy/plugins/canvas/public/lib/es_service.ts b/x-pack/legacy/plugins/canvas/public/lib/es_service.ts
index 6aa4968f29155..184f4f3c8af7c 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/es_service.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/es_service.ts
@@ -11,21 +11,21 @@ import { API_ROUTE } from '../../common/lib/constants';
import { fetch } from '../../common/lib/fetch';
import { ErrorStrings } from '../../i18n';
import { notifyService } from '../services';
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
const { esService: strings } = ErrorStrings;
const getApiPath = function() {
- const basePath = getCoreStart().http.basePath.get();
+ const basePath = platformService.getService().coreStart.http.basePath.get();
return basePath + API_ROUTE;
};
const getSavedObjectsClient = function() {
- return getCoreStart().savedObjects.client;
+ return platformService.getService().coreStart.savedObjects.client;
};
const getAdvancedSettings = function() {
- return getCoreStart().uiSettings;
+ return platformService.getService().coreStart.uiSettings;
};
export const getFields = (index = '_all') => {
diff --git a/x-pack/legacy/plugins/canvas/public/lib/workpad_service.js b/x-pack/legacy/plugins/canvas/public/lib/workpad_service.js
index f3681f50c56a5..e6628399f53c2 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/workpad_service.js
+++ b/x-pack/legacy/plugins/canvas/public/lib/workpad_service.js
@@ -11,7 +11,7 @@ import {
DEFAULT_WORKPAD_CSS,
} from '../../common/lib/constants';
import { fetch } from '../../common/lib/fetch';
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
/*
Remove any top level keys from the workpad which will be rejected by validation
*/
@@ -43,17 +43,17 @@ const sanitizeWorkpad = function(workpad) {
};
const getApiPath = function() {
- const basePath = getCoreStart().http.basePath.get();
+ const basePath = platformService.getService().coreStart.http.basePath.get();
return `${basePath}${API_ROUTE_WORKPAD}`;
};
const getApiPathStructures = function() {
- const basePath = getCoreStart().http.basePath.get();
+ const basePath = platformService.getService().coreStart.http.basePath.get();
return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`;
};
const getApiPathAssets = function() {
- const basePath = getCoreStart().http.basePath.get();
+ const basePath = platformService.getService().coreStart.http.basePath.get();
return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`;
};
diff --git a/x-pack/legacy/plugins/canvas/public/plugin.tsx b/x-pack/legacy/plugins/canvas/public/plugin.tsx
index 36ce1974be272..baeb4ebd453d2 100644
--- a/x-pack/legacy/plugins/canvas/public/plugin.tsx
+++ b/x-pack/legacy/plugins/canvas/public/plugin.tsx
@@ -44,7 +44,6 @@ export interface CanvasStartDeps {
uiActions: UiActionsStart;
__LEGACY: {
absoluteToParsedUrl: (url: string, basePath: string) => any;
- formatMsg: any;
trackSubUrlForApp: Chrome['trackSubUrlForApp'];
};
}
@@ -64,6 +63,7 @@ export class CanvasPlugin
implements Plugin {
// TODO: Do we want to completely move canvas_plugin_src into it's own plugin?
private srcPlugin = new CanvasSrcPlugin();
+ private startPlugins: CanvasStartDeps | undefined;
public setup(core: CoreSetup, plugins: CanvasSetupDeps) {
const { api: canvasApi, registries } = getPluginApi(plugins.expressions);
@@ -73,14 +73,26 @@ export class CanvasPlugin
core.application.register({
id: 'canvas',
title: 'Canvas App',
- async mount(context, params) {
+ mount: async (context, params) => {
// Load application bundle
const { renderApp, initializeCanvas, teardownCanvas } = await import('./application');
// Get start services
const [coreStart, depsStart] = await core.getStartServices();
- const canvasStore = await initializeCanvas(core, coreStart, plugins, depsStart, registries);
+ // TODO: We only need this to get the __LEGACY stuff that isn't coming from getStartSevices.
+ // We won't need this as soon as we move over to NP Completely
+ if (!this.startPlugins) {
+ throw new Error('Start Plugins not ready at mount time');
+ }
+
+ const canvasStore = await initializeCanvas(
+ core,
+ coreStart,
+ plugins,
+ this.startPlugins,
+ registries
+ );
const unmount = renderApp(coreStart, depsStart, params, canvasStore);
@@ -115,6 +127,7 @@ export class CanvasPlugin
}
public start(core: CoreStart, plugins: CanvasStartDeps) {
+ this.startPlugins = plugins;
this.srcPlugin.start(core, plugins);
initLoadingIndicator(core.http.addLoadingCountSource);
}
diff --git a/x-pack/legacy/plugins/canvas/public/services/index.ts b/x-pack/legacy/plugins/canvas/public/services/index.ts
index 12c0a687bf308..17d836f1441c9 100644
--- a/x-pack/legacy/plugins/canvas/public/services/index.ts
+++ b/x-pack/legacy/plugins/canvas/public/services/index.ts
@@ -7,6 +7,7 @@
import { CoreSetup, CoreStart } from '../../../../../../src/core/public';
import { CanvasSetupDeps, CanvasStartDeps } from '../plugin';
import { notifyServiceFactory } from './notify';
+import { platformServiceFactory } from './platform';
export type CanvasServiceFactory = (
coreSetup: CoreSetup,
@@ -49,6 +50,7 @@ export type ServiceFromProvider = P extends CanvasServiceProvider ?
export const services = {
notify: new CanvasServiceProvider(notifyServiceFactory),
+ platform: new CanvasServiceProvider(platformServiceFactory),
};
export interface CanvasServices {
@@ -70,4 +72,4 @@ export const stopServices = () => {
Object.entries(services).forEach(([key, provider]) => provider.stop());
};
-export const { notify: notifyService } = services;
+export const { notify: notifyService, platform: platformService } = services;
diff --git a/x-pack/legacy/plugins/canvas/public/services/platform.ts b/x-pack/legacy/plugins/canvas/public/services/platform.ts
new file mode 100644
index 0000000000000..440e9523044c1
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/services/platform.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CanvasServiceFactory } from '.';
+import { CoreStart, CoreSetup, CanvasSetupDeps, CanvasStartDeps } from '../plugin';
+
+interface PlatformService {
+ coreSetup: CoreSetup;
+ coreStart: CoreStart;
+ setupPlugins: CanvasSetupDeps;
+ startPlugins: CanvasStartDeps;
+}
+
+export const platformServiceFactory: CanvasServiceFactory = (
+ coreSetup,
+ coreStart,
+ setupPlugins,
+ startPlugins
+) => {
+ return { coreSetup, coreStart, setupPlugins, startPlugins };
+};
diff --git a/x-pack/legacy/plugins/canvas/public/state/initial_state.js b/x-pack/legacy/plugins/canvas/public/state/initial_state.js
index 40c017543147f..bfa68b33908e0 100644
--- a/x-pack/legacy/plugins/canvas/public/state/initial_state.js
+++ b/x-pack/legacy/plugins/canvas/public/state/initial_state.js
@@ -5,7 +5,7 @@
*/
import { get } from 'lodash';
-import { getCoreStart } from '../legacy';
+import { platformService } from '../services';
import { getDefaultWorkpad } from './defaults';
export const getInitialState = path => {
@@ -13,7 +13,7 @@ export const getInitialState = path => {
app: {}, // Kibana stuff in here
assets: {}, // assets end up here
transient: {
- canUserWrite: getCoreStart().application.capabilities.canvas.save,
+ canUserWrite: platformService.getService().coreStart.application.capabilities.canvas.save,
zoomScale: 1,
elementStats: {
total: 0,
diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/workpad.js b/x-pack/legacy/plugins/canvas/public/state/reducers/workpad.js
index 12733680ed32d..30f9c638a054f 100644
--- a/x-pack/legacy/plugins/canvas/public/state/reducers/workpad.js
+++ b/x-pack/legacy/plugins/canvas/public/state/reducers/workpad.js
@@ -5,7 +5,7 @@
*/
import { handleActions } from 'redux-actions';
-import { getCoreStart } from '../../legacy';
+import { platformService } from '../../services';
import { getDefaultWorkpad } from '../defaults';
import {
setWorkpad,
@@ -22,11 +22,13 @@ import { APP_ROUTE_WORKPAD } from '../../../common/lib/constants';
export const workpadReducer = handleActions(
{
[setWorkpad]: (workpadState, { payload }) => {
- getCoreStart().chrome.recentlyAccessed.add(
- `${APP_ROUTE_WORKPAD}/${payload.id}`,
- payload.name,
- payload.id
- );
+ platformService
+ .getService()
+ .coreStart.chrome.recentlyAccessed.add(
+ `${APP_ROUTE_WORKPAD}/${payload.id}`,
+ payload.name,
+ payload.id
+ );
return payload;
},
@@ -39,11 +41,13 @@ export const workpadReducer = handleActions(
},
[setName]: (workpadState, { payload }) => {
- getCoreStart().chrome.recentlyAccessed.add(
- `${APP_ROUTE_WORKPAD}/${workpadState.id}`,
- payload,
- workpadState.id
- );
+ platformService
+ .getService()
+ .coreStart.chrome.recentlyAccessed.add(
+ `${APP_ROUTE_WORKPAD}/${workpadState.id}`,
+ payload,
+ workpadState.id
+ );
return { ...workpadState, name: payload };
},
diff --git a/x-pack/legacy/plugins/maps/index.js b/x-pack/legacy/plugins/maps/index.js
index d1e8892fa2c98..a1186e04ee27a 100644
--- a/x-pack/legacy/plugins/maps/index.js
+++ b/x-pack/legacy/plugins/maps/index.js
@@ -54,7 +54,7 @@ export function maps(kibana) {
emsLandingPageUrl: mapConfig.emsLandingPageUrl,
kbnPkgVersion: serverConfig.get('pkg.version'),
regionmapLayers: _.get(mapConfig, 'regionmap.layers', []),
- tilemap: _.get(mapConfig, 'tilemap', []),
+ tilemap: _.get(mapConfig, 'tilemap', {}),
};
},
styleSheetPaths: `${__dirname}/public/index.scss`,
diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js
index 1b1fbf111fe04..bb1a6b74d43a7 100644
--- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js
+++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js
@@ -336,7 +336,7 @@ app.controller(
function addFilters(newFilters) {
newFilters.forEach(filter => {
- filter.$state = esFilters.FilterStateStore.APP_STATE;
+ filter.$state = { store: esFilters.FilterStateStore.APP_STATE };
});
$scope.updateFiltersAndDispatch([...$scope.filters, ...newFilters]);
}
diff --git a/x-pack/legacy/plugins/monitoring/common/constants.ts b/x-pack/legacy/plugins/monitoring/common/constants.ts
index 3a4c7b71dcd03..36030e1fa7f2a 100644
--- a/x-pack/legacy/plugins/monitoring/common/constants.ts
+++ b/x-pack/legacy/plugins/monitoring/common/constants.ts
@@ -251,7 +251,7 @@ export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION, ALERT_TYPE_CLUSTER_ST
/**
* Matches the id for the built-in in email action type
- * See x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts
+ * See x-pack/plugins/actions/server/builtin_action_types/email.ts
*/
export const ALERT_ACTION_TYPE_EMAIL = '.email';
diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts
index a047c25c2b1d7..c6031cb220334 100644
--- a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts
+++ b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts
@@ -5,6 +5,8 @@
*/
import angular, { IWindowService } from 'angular';
+// required for `ngSanitize` angular module
+import 'angular-sanitize';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import { AppMountContext } from 'kibana/public';
diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts
index 5b2218af1fd52..b1dec2ce82c52 100644
--- a/x-pack/legacy/plugins/security/index.ts
+++ b/x-pack/legacy/plugins/security/index.ts
@@ -78,9 +78,7 @@ export const security = (kibana: Record) =>
// features are up to date.
xpackInfo
.feature(this.id)
- .registerLicenseCheckResultsGenerator(() =>
- securityPlugin.__legacyCompat.license.getFeatures()
- );
+ .registerLicenseCheckResultsGenerator(() => securityPlugin.license.getFeatures());
server.expose({
getUser: async (request: LegacyRequest) =>
diff --git a/x-pack/legacy/plugins/task_manager/server/index.ts b/x-pack/legacy/plugins/task_manager/server/index.ts
index 3ea687f7003f4..a3167920efa06 100644
--- a/x-pack/legacy/plugins/task_manager/server/index.ts
+++ b/x-pack/legacy/plugins/task_manager/server/index.ts
@@ -6,8 +6,6 @@
import { Root } from 'joi';
import { Legacy } from 'kibana';
-import mappings from './mappings.json';
-import { migrations } from './migrations';
import { createLegacyApi, getTaskManagerSetup } from './legacy';
export { LegacyTaskManagerApi, getTaskManagerSetup, getTaskManagerStart } from './legacy';
@@ -21,19 +19,6 @@ import {
ArrayOrItem,
} from '../../../../../src/legacy/plugin_discovery/types';
-const savedObjectSchemas = {
- task: {
- hidden: true,
- isNamespaceAgnostic: true,
- convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`,
- // legacy config is marked as any in core, no choice here
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- indexPattern(config: any) {
- return config.get('xpack.task_manager.index');
- },
- },
-};
-
export function taskManager(kibana: LegacyPluginApi): ArrayOrItem {
return new kibana.Plugin({
id: 'task_manager',
@@ -58,7 +43,7 @@ export function taskManager(kibana: LegacyPluginApi): ArrayOrItem {
// we can't tell the Kibana Platform Task Manager plugin to
// to wait to `start` as that happens before legacy plugins
@@ -77,10 +62,5 @@ export function taskManager(kibana: LegacyPluginApi): ArrayOrItem
- new kibana.Plugin({
- configPrefix: 'xpack.uptime',
- id: PLUGIN.ID,
- publicDir: resolve(__dirname, 'public'),
- require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'],
- uiExports: {
- app: {
- description: i18n.translate('xpack.uptime.pluginDescription', {
- defaultMessage: 'Uptime monitoring',
- description: 'The description text that will be shown to users in Kibana',
- }),
- icon: 'plugins/uptime/icons/heartbeat_white.svg',
- euiIconType: 'uptimeApp',
- title: i18n.translate('xpack.uptime.uptimeFeatureCatalogueTitle', {
- defaultMessage: 'Uptime',
- }),
- main: 'plugins/uptime/app',
- order: 8900,
- url: '/app/uptime#/',
- category: DEFAULT_APP_CATEGORIES.observability,
- },
- home: ['plugins/uptime/register_feature'],
- },
- });
diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts
deleted file mode 100644
index d58bf8398fcde..0000000000000
--- a/x-pack/legacy/plugins/uptime/public/apps/index.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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { npSetup } from 'ui/new_platform';
-import { Plugin } from './plugin';
-import 'uiExports/embeddableFactories';
-
-const plugin = new Plugin({
- opaqueId: Symbol('uptime'),
- env: {} as any,
- config: { get: () => ({} as any) },
-});
-plugin.setup(npSetup);
diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts
deleted file mode 100644
index e73598c44c9f0..0000000000000
--- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { LegacyCoreSetup, PluginInitializerContext, AppMountParameters } from 'src/core/public';
-import { PluginsSetup } from 'ui/new_platform/new_platform';
-import { FeatureCatalogueCategory } from '../../../../../../src/plugins/home/public';
-import { UMFrontendLibs } from '../lib/lib';
-import { PLUGIN } from '../../common/constants';
-import { getKibanaFrameworkAdapter } from '../lib/adapters/framework/new_platform_adapter';
-
-export interface SetupObject {
- core: LegacyCoreSetup;
- plugins: PluginsSetup;
-}
-
-export class Plugin {
- constructor(
- // @ts-ignore this is added to satisfy the New Platform typing constraint,
- // but we're not leveraging any of its functionality yet.
- private readonly initializerContext: PluginInitializerContext
- ) {}
-
- public setup(setup: SetupObject) {
- const { core, plugins } = setup;
- const { home } = plugins;
-
- home.featureCatalogue.register({
- category: FeatureCatalogueCategory.DATA,
- description: PLUGIN.DESCRIPTION,
- icon: 'uptimeApp',
- id: PLUGIN.ID,
- path: '/app/uptime#/',
- showOnHomePage: true,
- title: PLUGIN.TITLE,
- });
-
- core.application.register({
- id: PLUGIN.ID,
- euiIconType: 'uptimeApp',
- order: 8900,
- title: 'Uptime',
- async mount(params: AppMountParameters) {
- const [coreStart] = await core.getStartServices();
- const { element } = params;
- const libs: UMFrontendLibs = {
- framework: getKibanaFrameworkAdapter(coreStart, plugins),
- };
- libs.framework.render(element);
- return () => {};
- },
- });
- }
-}
diff --git a/x-pack/legacy/plugins/uptime/public/register_feature.ts b/x-pack/legacy/plugins/uptime/public/register_feature.ts
deleted file mode 100644
index 2f83fa33ba4bc..0000000000000
--- a/x-pack/legacy/plugins/uptime/public/register_feature.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { i18n } from '@kbn/i18n';
-import { npSetup } from 'ui/new_platform';
-import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public';
-
-const {
- plugins: { home },
-} = npSetup;
-
-home.featureCatalogue.register({
- id: 'uptime',
- title: i18n.translate('xpack.uptime.uptimeFeatureCatalogueTitle', { defaultMessage: 'Uptime' }),
- description: i18n.translate('xpack.uptime.featureCatalogueDescription', {
- defaultMessage: 'Perform endpoint health checks and uptime monitoring.',
- }),
- icon: 'uptimeApp',
- path: `uptime#/`,
- showOnHomePage: true,
- category: FeatureCatalogueCategory.DATA,
-});
diff --git a/x-pack/legacy/plugins/uptime/tsconfig.json b/x-pack/legacy/plugins/uptime/tsconfig.json
deleted file mode 100644
index 53425909db3e8..0000000000000
--- a/x-pack/legacy/plugins/uptime/tsconfig.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": "../../../tsconfig.json",
- "exclude": ["**/node_modules/**"],
- "paths": {
- "react": ["../../../node_modules/@types/react"]
- }
-}
\ No newline at end of file
diff --git a/x-pack/package.json b/x-pack/package.json
index 604889c6094b9..dcc9b8c61cb96 100644
--- a/x-pack/package.json
+++ b/x-pack/package.json
@@ -105,6 +105,7 @@
"@types/recompose": "^0.30.6",
"@types/reduce-reducers": "^1.0.0",
"@types/redux-actions": "^2.6.1",
+ "@types/set-value": "^2.0.0",
"@types/sinon": "^7.0.13",
"@types/styled-components": "^4.4.2",
"@types/supertest": "^2.0.5",
@@ -344,6 +345,7 @@
"rison-node": "0.3.1",
"rxjs": "^6.5.3",
"semver": "5.7.0",
+ "set-value": "^3.0.2",
"squel": "^5.13.0",
"stats-lite": "^2.2.0",
"style-it": "^2.1.3",
diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts
index 101e18f2583e3..3e9262c05efac 100644
--- a/x-pack/plugins/actions/server/lib/action_executor.ts
+++ b/x-pack/plugins/actions/server/lib/action_executor.ts
@@ -17,7 +17,7 @@ import {
import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server';
import { SpacesServiceSetup } from '../../../spaces/server';
import { EVENT_LOG_ACTIONS } from '../plugin';
-import { IEvent, IEventLogger } from '../../../event_log/server';
+import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
export interface ActionExecutorContext {
logger: Logger;
@@ -110,7 +110,16 @@ export class ActionExecutor {
const actionLabel = `${actionTypeId}:${actionId}: ${name}`;
const event: IEvent = {
event: { action: EVENT_LOG_ACTIONS.execute },
- kibana: { saved_objects: [{ type: 'action', id: actionId, ...namespace }] },
+ kibana: {
+ saved_objects: [
+ {
+ rel: SAVED_OBJECT_REL_PRIMARY,
+ type: 'action',
+ id: actionId,
+ ...namespace,
+ },
+ ],
+ },
};
eventLogger.startTiming(event);
diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts
index 10e4d64584340..a6cc1fb5463bb 100644
--- a/x-pack/plugins/actions/server/plugin.ts
+++ b/x-pack/plugins/actions/server/plugin.ts
@@ -53,6 +53,7 @@ import {
} from './routes';
import { IEventLogger, IEventLogService } from '../../event_log/server';
import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task';
+import { setupSavedObjects } from './saved_objects';
const EVENT_LOG_PROVIDER = 'actions';
export const EVENT_LOG_ACTIONS = {
@@ -133,19 +134,7 @@ export class ActionsPlugin implements Plugin, Plugi
);
}
- // Encrypted attributes
- // - `secrets` properties will be encrypted
- // - `config` will be included in AAD
- // - everything else excluded from AAD
- plugins.encryptedSavedObjects.registerType({
- type: 'action',
- attributesToEncrypt: new Set(['secrets']),
- attributesToExcludeFromAAD: new Set(['name']),
- });
- plugins.encryptedSavedObjects.registerType({
- type: 'action_task_params',
- attributesToEncrypt: new Set(['apiKey']),
- });
+ setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects);
plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS));
this.eventLogger = plugins.eventLog.getLogger({
diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts
new file mode 100644
index 0000000000000..dbd7925f96871
--- /dev/null
+++ b/x-pack/plugins/actions/server/saved_objects/index.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SavedObjectsServiceSetup } from 'kibana/server';
+import mappings from './mappings.json';
+import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
+
+export function setupSavedObjects(
+ savedObjects: SavedObjectsServiceSetup,
+ encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
+) {
+ savedObjects.registerType({
+ name: 'action',
+ hidden: false,
+ namespaceType: 'single',
+ mappings: mappings.action,
+ });
+
+ // Encrypted attributes
+ // - `secrets` properties will be encrypted
+ // - `config` will be included in AAD
+ // - everything else excluded from AAD
+ encryptedSavedObjects.registerType({
+ type: 'action',
+ attributesToEncrypt: new Set(['secrets']),
+ attributesToExcludeFromAAD: new Set(['name']),
+ });
+
+ savedObjects.registerType({
+ name: 'action_task_params',
+ hidden: false,
+ namespaceType: 'single',
+ mappings: mappings.action_task_params,
+ });
+ encryptedSavedObjects.registerType({
+ type: 'action_task_params',
+ attributesToEncrypt: new Set(['apiKey']),
+ });
+}
diff --git a/x-pack/legacy/plugins/actions/server/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json
similarity index 100%
rename from x-pack/legacy/plugins/actions/server/mappings.json
rename to x-pack/plugins/actions/server/saved_objects/mappings.json
diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts
index c03d3506a051d..8cdde2eeb9877 100644
--- a/x-pack/plugins/alerting/server/plugin.ts
+++ b/x-pack/plugins/alerting/server/plugin.ts
@@ -58,6 +58,7 @@ import { Services } from './types';
import { registerAlertsUsageCollector } from './usage';
import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task';
import { IEventLogger, IEventLogService } from '../../event_log/server';
+import { setupSavedObjects } from './saved_objects';
const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = {
@@ -134,17 +135,7 @@ export class AlertingPlugin {
);
}
- // Encrypted attributes
- plugins.encryptedSavedObjects.registerType({
- type: 'alert',
- attributesToEncrypt: new Set(['apiKey']),
- attributesToExcludeFromAAD: new Set([
- 'scheduledTaskId',
- 'muteAll',
- 'mutedInstanceIds',
- 'updatedBy',
- ]),
- });
+ setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects);
plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS));
this.eventLogger = plugins.eventLog.getLogger({
diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts
new file mode 100644
index 0000000000000..4efec2fe55ef0
--- /dev/null
+++ b/x-pack/plugins/alerting/server/saved_objects/index.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SavedObjectsServiceSetup } from 'kibana/server';
+import mappings from './mappings.json';
+import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
+
+export function setupSavedObjects(
+ savedObjects: SavedObjectsServiceSetup,
+ encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
+) {
+ savedObjects.registerType({
+ name: 'alert',
+ hidden: false,
+ namespaceType: 'single',
+ mappings: mappings.alert,
+ });
+
+ // Encrypted attributes
+ encryptedSavedObjects.registerType({
+ type: 'alert',
+ attributesToEncrypt: new Set(['apiKey']),
+ attributesToExcludeFromAAD: new Set([
+ 'scheduledTaskId',
+ 'muteAll',
+ 'mutedInstanceIds',
+ 'updatedBy',
+ ]),
+ });
+}
diff --git a/x-pack/legacy/plugins/alerting/server/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json
similarity index 100%
rename from x-pack/legacy/plugins/alerting/server/mappings.json
rename to x-pack/plugins/alerting/server/saved_objects/mappings.json
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 0e46ef4919626..a564b87f2ca50 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
@@ -95,6 +95,7 @@ test('calls actionsPlugin.execute per selected action', async () => {
"saved_objects": Array [
Object {
"id": "1",
+ "rel": "primary",
"type": "alert",
},
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 5c3e36b88879d..16fadc8b06cd5 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
@@ -9,7 +9,7 @@ import { AlertAction, State, Context, AlertType } from '../types';
import { Logger } from '../../../../../src/core/server';
import { transformActionParams } from './transform_action_params';
import { PluginStartContract as ActionsPluginStartContract } from '../../../../plugins/actions/server';
-import { IEventLogger, IEvent } from '../../../event_log/server';
+import { IEventLogger, IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { EVENT_LOG_ACTIONS } from '../plugin';
interface CreateExecutionHandlerOptions {
@@ -96,7 +96,7 @@ export function createExecutionHandler({
instance_id: alertInstanceId,
},
saved_objects: [
- { type: 'alert', id: alertId, ...namespace },
+ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, ...namespace },
{ type: 'action', id: action.id, ...namespace },
],
},
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 26d8a1d1777c0..35a0018049c33 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
@@ -172,6 +172,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
@@ -234,6 +235,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
@@ -254,6 +256,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
@@ -274,6 +277,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
Object {
@@ -351,6 +355,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
@@ -371,6 +376,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
@@ -568,6 +574,7 @@ describe('Task Runner', () => {
Object {
"id": "1",
"namespace": undefined,
+ "rel": "primary",
"type": "alert",
},
],
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 26970dc6b2b0d..bf005301adc07 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
@@ -25,7 +25,7 @@ import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
import { AlertInstances } from '../alert_instance/alert_instance';
import { EVENT_LOG_ACTIONS } from '../plugin';
-import { IEvent, IEventLogger } from '../../../event_log/server';
+import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error';
const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' };
@@ -174,7 +174,16 @@ export class TaskRunner {
const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`;
const event: IEvent = {
event: { action: EVENT_LOG_ACTIONS.execute },
- kibana: { saved_objects: [{ type: 'alert', id: alertId, namespace }] },
+ kibana: {
+ saved_objects: [
+ {
+ rel: SAVED_OBJECT_REL_PRIMARY,
+ type: 'alert',
+ id: alertId,
+ namespace,
+ },
+ ],
+ },
};
eventLogger.startTiming(event);
@@ -393,7 +402,14 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst
alerting: {
instance_id: id,
},
- saved_objects: [{ type: 'alert', id: params.alertId, namespace: params.namespace }],
+ saved_objects: [
+ {
+ rel: SAVED_OBJECT_REL_PRIMARY,
+ type: 'alert',
+ id: params.alertId,
+ namespace: params.namespace,
+ },
+ ],
},
message,
};
diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts
index 61fbb779986dc..e40fc3e386bc8 100644
--- a/x-pack/plugins/endpoint/common/generate_data.ts
+++ b/x-pack/plugins/endpoint/common/generate_data.ts
@@ -307,7 +307,7 @@ export class EndpointDocGenerator {
process: {
entity_id: options.entityID ? options.entityID : this.randomString(10),
parent: options.parentEntityID ? { entity_id: options.parentEntityID } : undefined,
- name: options.processName ? options.processName : 'powershell.exe',
+ name: options.processName ? options.processName : randomProcessName(),
},
};
}
@@ -645,3 +645,16 @@ export class EndpointDocGenerator {
return uuid.v4({ random: [...this.randomNGenerator(255, 16)] });
}
}
+
+const fakeProcessNames = [
+ 'lsass.exe',
+ 'notepad.exe',
+ 'mimikatz.exe',
+ 'powershell.exe',
+ 'iexlorer.exe',
+ 'explorer.exe',
+];
+/** Return a random fake process name */
+function randomProcessName(): string {
+ return fakeProcessNames[Math.floor(Math.random() * fakeProcessNames.length)];
+}
diff --git a/x-pack/plugins/endpoint/common/models/event.ts b/x-pack/plugins/endpoint/common/models/event.ts
index 650486f3c3858..47f39d2d11797 100644
--- a/x-pack/plugins/endpoint/common/models/event.ts
+++ b/x-pack/plugins/endpoint/common/models/event.ts
@@ -4,17 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EndpointEvent, LegacyEndpointEvent } from '../types';
+import { LegacyEndpointEvent, ResolverEvent } from '../types';
-export function isLegacyEvent(
- event: EndpointEvent | LegacyEndpointEvent
-): event is LegacyEndpointEvent {
+export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEvent {
return (event as LegacyEndpointEvent).endgame !== undefined;
}
-export function eventTimestamp(
- event: EndpointEvent | LegacyEndpointEvent
-): string | undefined | number {
+export function eventTimestamp(event: ResolverEvent): string | undefined | number {
if (isLegacyEvent(event)) {
return event.endgame.timestamp_utc;
} else {
@@ -22,10 +18,31 @@ export function eventTimestamp(
}
}
-export function eventName(event: EndpointEvent | LegacyEndpointEvent): string {
+export function eventName(event: ResolverEvent): string {
if (isLegacyEvent(event)) {
return event.endgame.process_name ? event.endgame.process_name : '';
} else {
return event.process.name;
}
}
+
+export function eventId(event: ResolverEvent): string {
+ if (isLegacyEvent(event)) {
+ return event.endgame.serial_event_id ? String(event.endgame.serial_event_id) : '';
+ }
+ return event.event.id;
+}
+
+export function entityId(event: ResolverEvent): string {
+ if (isLegacyEvent(event)) {
+ return event.endgame.unique_pid ? String(event.endgame.unique_pid) : '';
+ }
+ return event.process.entity_id;
+}
+
+export function parentEntityId(event: ResolverEvent): string | undefined {
+ if (isLegacyEvent(event)) {
+ return event.endgame.unique_ppid ? String(event.endgame.unique_ppid) : undefined;
+ }
+ return event.process.parent?.entity_id;
+}
diff --git a/x-pack/plugins/endpoint/common/schema/resolver.ts b/x-pack/plugins/endpoint/common/schema/resolver.ts
new file mode 100644
index 0000000000000..f21307e407fd0
--- /dev/null
+++ b/x-pack/plugins/endpoint/common/schema/resolver.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+/**
+ * Used to validate GET requests for a complete resolver tree.
+ */
+export const validateTree = {
+ params: schema.object({ id: schema.string() }),
+ query: schema.object({
+ children: schema.number({ defaultValue: 10, min: 0, max: 100 }),
+ generations: schema.number({ defaultValue: 3, min: 0, max: 3 }),
+ ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }),
+ events: schema.number({ defaultValue: 100, min: 0, max: 1000 }),
+ afterEvent: schema.maybe(schema.string()),
+ afterChild: schema.maybe(schema.string()),
+ legacyEndpointID: schema.maybe(schema.string()),
+ }),
+};
+
+/**
+ * Used to validate GET requests for non process events for a specific event.
+ */
+export const validateEvents = {
+ params: schema.object({ id: schema.string() }),
+ query: schema.object({
+ events: schema.number({ defaultValue: 100, min: 1, max: 1000 }),
+ afterEvent: schema.maybe(schema.string()),
+ legacyEndpointID: schema.maybe(schema.string()),
+ }),
+};
+
+/**
+ * Used to validate GET requests for the ancestors of a process event.
+ */
+export const validateAncestry = {
+ params: schema.object({ id: schema.string() }),
+ query: schema.object({
+ ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }),
+ legacyEndpointID: schema.maybe(schema.string()),
+ }),
+};
+
+/**
+ * Used to validate GET requests for children of a specified process event.
+ */
+export const validateChildren = {
+ params: schema.object({ id: schema.string() }),
+ query: schema.object({
+ children: schema.number({ defaultValue: 10, min: 10, max: 100 }),
+ generations: schema.number({ defaultValue: 3, min: 0, max: 3 }),
+ afterChild: schema.maybe(schema.string()),
+ legacyEndpointID: schema.maybe(schema.string()),
+ }),
+};
diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts
index 8773c5441860a..8fce15d1c794c 100644
--- a/x-pack/plugins/endpoint/common/types.ts
+++ b/x-pack/plugins/endpoint/common/types.ts
@@ -35,6 +35,31 @@ type ImmutableObject = { readonly [K in keyof T]: Immutable };
*/
export type AlertAPIOrdering = 'asc' | 'desc';
+export interface ResolverNodeStats {
+ totalEvents: number;
+ totalAlerts: number;
+}
+
+export interface ResolverNodePagination {
+ nextChild?: string | null;
+ nextEvent?: string | null;
+ nextAncestor?: string | null;
+ nextAlert?: string | null;
+}
+
+/**
+ * A node that contains pointers to other nodes, arrrays of resolver events, and any metadata associated with resolver specific data
+ */
+export interface ResolverNode {
+ id: string;
+ children: ResolverNode[];
+ events: ResolverEvent[];
+ lifecycle: ResolverEvent[];
+ ancestors?: ResolverNode[];
+ pagination: ResolverNodePagination;
+ stats?: ResolverNodeStats;
+}
+
/**
* Returned by 'api/endpoint/alerts'
*/
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts
index d1b9a2cde4b31..bcfd6b96c9eb8 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts
@@ -70,7 +70,6 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = core
type: 'serverReturnedHostDetails',
payload: response,
});
- // FIXME: once we have the API implementation in place, we should call it parallel with the above api call and then dispatch this with the results of the second call
dispatch({
type: 'serverReturnedHostPolicyResponse',
payload: {
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx
index d18bc59a35f52..d32ad4dd9defc 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx
@@ -33,5 +33,6 @@ export const AlertDetailResolver = styled(
width: 100%;
display: flex;
flex-grow: 1;
- min-height: 500px;
+ /* gross demo hack */
+ min-height: calc(100vh - 505px);
`;
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts
index 373afa89921dc..3ec15f2f1985d 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts
@@ -8,13 +8,11 @@ import { ResolverEvent } from '../../../../../common/types';
interface ServerReturnedResolverData {
readonly type: 'serverReturnedResolverData';
- readonly payload: {
- readonly data: {
- readonly result: {
- readonly search_results: readonly ResolverEvent[];
- };
- };
- };
+ readonly payload: ResolverEvent[];
}
-export type DataAction = ServerReturnedResolverData;
+interface ServerFailedToReturnResolverData {
+ readonly type: 'serverFailedToReturnResolverData';
+}
+
+export type DataAction = ServerReturnedResolverData | ServerFailedToReturnResolverData;
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts
index f01136fe20ebf..f95ecc63d2a66 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts
@@ -8,7 +8,7 @@ import { Store, createStore } from 'redux';
import { DataAction } from './action';
import { dataReducer } from './reducer';
import { DataState } from '../../types';
-import { LegacyEndpointEvent } from '../../../../../common/types';
+import { LegacyEndpointEvent, ResolverEvent } from '../../../../../common/types';
import { graphableProcesses, processNodePositionsAndEdgeLineSegments } from './selectors';
import { mockProcessEvent } from '../../models/process_event_test_helpers';
@@ -113,13 +113,7 @@ describe('resolver graph layout', () => {
});
describe('when rendering no nodes', () => {
beforeEach(() => {
- const payload = {
- data: {
- result: {
- search_results: [],
- },
- },
- };
+ const payload: ResolverEvent[] = [];
const action: DataAction = { type: 'serverReturnedResolverData', payload };
store.dispatch(action);
});
@@ -133,13 +127,7 @@ describe('resolver graph layout', () => {
});
describe('when rendering one node', () => {
beforeEach(() => {
- const payload = {
- data: {
- result: {
- search_results: [processA],
- },
- },
- };
+ const payload = [processA];
const action: DataAction = { type: 'serverReturnedResolverData', payload };
store.dispatch(action);
});
@@ -153,13 +141,7 @@ describe('resolver graph layout', () => {
});
describe('when rendering two nodes, one being the parent of the other', () => {
beforeEach(() => {
- const payload = {
- data: {
- result: {
- search_results: [processA, processB],
- },
- },
- };
+ const payload = [processA, processB];
const action: DataAction = { type: 'serverReturnedResolverData', payload };
store.dispatch(action);
});
@@ -173,23 +155,17 @@ describe('resolver graph layout', () => {
});
describe('when rendering two forks, and one fork has an extra long tine', () => {
beforeEach(() => {
- const payload = {
- data: {
- result: {
- search_results: [
- processA,
- processB,
- processC,
- processD,
- processE,
- processF,
- processG,
- processH,
- processI,
- ],
- },
- },
- };
+ const payload = [
+ processA,
+ processB,
+ processC,
+ processD,
+ processE,
+ processF,
+ processG,
+ processH,
+ processI,
+ ];
const action: DataAction = { type: 'serverReturnedResolverData', payload };
store.dispatch(action);
});
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts
index a3184389a794e..fc307002819a9 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts
@@ -11,25 +11,28 @@ function initialState(): DataState {
return {
results: [],
isLoading: false,
+ hasError: false,
};
}
export const dataReducer: Reducer = (state = initialState(), action) => {
if (action.type === 'serverReturnedResolverData') {
- const {
- data: {
- result: { search_results },
- },
- } = action.payload;
return {
...state,
- results: search_results,
+ results: action.payload,
isLoading: false,
+ hasError: false,
};
} else if (action.type === 'appRequestedResolverData') {
return {
...state,
isLoading: true,
+ hasError: false,
+ };
+ } else if (action.type === 'serverFailedToReturnResolverData') {
+ return {
+ ...state,
+ hasError: true,
};
} else {
return state;
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts
index 5dda54d4ed029..59ee4b3b87505 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts
@@ -34,6 +34,10 @@ export function isLoading(state: DataState) {
return state.isLoading;
}
+export function hasError(state: DataState) {
+ return state.hasError;
+}
+
/**
* An isometric projection is a method for representing three dimensional objects in 2 dimensions.
* More information about isometric projections can be found here https://en.wikipedia.org/wiki/Isometric_projection.
@@ -293,7 +297,7 @@ function* levelOrderWithWidths(
metadata.firstChildWidth = width;
} else {
const firstChildWidth = widths.get(siblings[0]);
- const lastChildWidth = widths.get(siblings[0]);
+ const lastChildWidth = widths.get(siblings[siblings.length - 1]);
if (firstChildWidth === undefined || lastChildWidth === undefined) {
/**
* All widths have been precalcluated, so this will not happen.
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts
index 4e57212e5c0c2..c7177c6387e7a 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts
@@ -8,7 +8,7 @@ import { Dispatch, MiddlewareAPI } from 'redux';
import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public';
import { EndpointPluginServices } from '../../../plugin';
import { ResolverState, ResolverAction } from '../types';
-import { ResolverEvent } from '../../../../common/types';
+import { ResolverEvent, ResolverNode } from '../../../../common/types';
import * as event from '../../../../common/models/event';
type MiddlewareFactory = (
@@ -16,18 +16,18 @@ type MiddlewareFactory = (
) => (
api: MiddlewareAPI, S>
) => (next: Dispatch) => (action: ResolverAction) => unknown;
-interface Lifecycle {
- lifecycle: ResolverEvent[];
-}
-type ChildResponse = [Lifecycle];
-function flattenEvents(events: ChildResponse): ResolverEvent[] {
- return events
- .map((child: Lifecycle) => child.lifecycle)
- .reduce(
- (accumulator: ResolverEvent[], value: ResolverEvent[]) => accumulator.concat(value),
- []
- );
+function flattenEvents(children: ResolverNode[], events: ResolverEvent[] = []): ResolverEvent[] {
+ return children.reduce((flattenedEvents, currentNode) => {
+ if (currentNode.lifecycle && currentNode.lifecycle.length > 0) {
+ flattenedEvents.push(...currentNode.lifecycle);
+ }
+ if (currentNode.children && currentNode.children.length > 0) {
+ return flattenEvents(currentNode.children, events);
+ } else {
+ return flattenedEvents;
+ }
+ }, events);
}
export const resolverMiddlewareFactory: MiddlewareFactory = context => {
@@ -39,53 +39,43 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => {
*/
if (context?.services.http && action.payload.selectedEvent) {
api.dispatch({ type: 'appRequestedResolverData' });
- let response = [];
- let lifecycle: ResolverEvent[];
- let childEvents: ResolverEvent[];
- let relatedEvents: ResolverEvent[];
- let children = [];
- const ancestors: ResolverEvent[] = [];
- const maxAncestors = 5;
- if (event.isLegacyEvent(action.payload.selectedEvent)) {
- const uniquePid = action.payload.selectedEvent?.endgame?.unique_pid;
- const legacyEndpointID = action.payload.selectedEvent?.agent?.id;
- [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}`, {
- query: { legacyEndpointID },
- }),
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`, {
- query: { legacyEndpointID },
- }),
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`, {
- query: { legacyEndpointID },
- }),
- ]);
- childEvents = children.length > 0 ? flattenEvents(children) : [];
- } else {
- const uniquePid = action.payload.selectedEvent.process.entity_id;
- const ppid = action.payload.selectedEvent.process.parent?.entity_id;
- async function getAncestors(pid: string | undefined) {
- if (ancestors.length < maxAncestors && pid !== undefined) {
- const parent = await context?.services.http.get(`/api/endpoint/resolver/${pid}`);
- ancestors.push(parent.lifecycle[0]);
- if (parent.lifecycle[0].process?.parent?.entity_id) {
- await getAncestors(parent.lifecycle[0].process.parent.entity_id);
- }
- }
+ try {
+ let lifecycle: ResolverEvent[];
+ let children: ResolverNode[];
+ let ancestors: ResolverNode[];
+ if (event.isLegacyEvent(action.payload.selectedEvent)) {
+ const entityId = action.payload.selectedEvent?.endgame?.unique_pid;
+ const legacyEndpointID = action.payload.selectedEvent?.agent?.id;
+ [{ lifecycle, children, ancestors }] = await Promise.all([
+ context.services.http.get(`/api/endpoint/resolver/${entityId}`, {
+ query: { legacyEndpointID, children: 5, ancestors: 5 },
+ }),
+ ]);
+ } else {
+ const entityId = action.payload.selectedEvent.process.entity_id;
+ [{ lifecycle, children, ancestors }] = await Promise.all([
+ context.services.http.get(`/api/endpoint/resolver/${entityId}`, {
+ query: {
+ children: 5,
+ ancestors: 5,
+ },
+ }),
+ ]);
}
- [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}`),
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`),
- context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`),
- getAncestors(ppid),
- ]);
+ const response: ResolverEvent[] = [
+ ...lifecycle,
+ ...flattenEvents(children),
+ ...flattenEvents(ancestors),
+ ];
+ api.dispatch({
+ type: 'serverReturnedResolverData',
+ payload: response,
+ });
+ } catch (error) {
+ api.dispatch({
+ type: 'serverFailedToReturnResolverData',
+ });
}
- childEvents = children.length > 0 ? flattenEvents(children) : [];
- response = [...lifecycle, ...childEvents, ...relatedEvents, ...ancestors];
- api.dispatch({
- type: 'serverReturnedResolverData',
- payload: { data: { result: { search_results: response } } },
- });
}
}
};
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts
index e8ae3d08e5cb6..7d09d90881da9 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts
@@ -102,6 +102,11 @@ function uiStateSelector(state: ResolverState) {
*/
export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoading);
+/**
+ * Whether or not the resolver encountered an error while fetching data
+ */
+export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError);
+
/**
* Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a
* concern-specific selector. `selector` should return the concern-specific state.
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts
index d370bda0d1842..17aa598720c59 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts
@@ -136,6 +136,7 @@ export type CameraState = {
export interface DataState {
readonly results: readonly ResolverEvent[];
isLoading: boolean;
+ hasError: boolean;
}
export type Vector2 = readonly [number, number];
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx
index 36155ece57a9c..2e7ca65c92dc1 100644
--- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx
+++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx
@@ -8,6 +8,7 @@ import React, { useLayoutEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import styled from 'styled-components';
import { EuiLoadingSpinner } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
import * as selectors from '../store/selectors';
import { EdgeLine } from './edge_line';
import { Panel } from './panel';
@@ -59,6 +60,7 @@ export const Resolver = styled(
const { projectionMatrix, ref, onMouseDown } = useCamera();
const isLoading = useSelector(selectors.isLoading);
+ const hasError = useSelector(selectors.hasError);
const activeDescendantId = useSelector(selectors.uiActiveDescendantId);
useLayoutEffect(() => {
@@ -74,6 +76,16 @@ export const Resolver = styled(
+ ) : hasError ? (
+
) : (
{
}
const serverResponseAction: ResolverAction = {
type: 'serverReturnedResolverData',
- payload: {
- data: {
- result: {
- search_results: events,
- },
- },
- },
+ payload: events,
};
act(() => {
store.dispatch(serverResponseAction);
diff --git a/x-pack/plugins/endpoint/server/routes/alerts/list/lib/index.ts b/x-pack/plugins/endpoint/server/routes/alerts/list/lib/index.ts
index 92bd07a813d26..114251820ce4b 100644
--- a/x-pack/plugins/endpoint/server/routes/alerts/list/lib/index.ts
+++ b/x-pack/plugins/endpoint/server/routes/alerts/list/lib/index.ts
@@ -60,8 +60,6 @@ export const getRequestData = async (
reqData.fromIndex = reqData.pageIndex * reqData.pageSize;
}
- // See: https://github.com/elastic/elasticsearch-js/issues/662
- // and https://github.com/elastic/endpoint-app-team/issues/221
if (
reqData.searchBefore !== undefined &&
reqData.searchBefore[0] === '' &&
diff --git a/x-pack/plugins/endpoint/server/routes/metadata/index.ts b/x-pack/plugins/endpoint/server/routes/metadata/index.ts
index 99dc4ac9f9e33..08950930441df 100644
--- a/x-pack/plugins/endpoint/server/routes/metadata/index.ts
+++ b/x-pack/plugins/endpoint/server/routes/metadata/index.ts
@@ -171,7 +171,6 @@ async function enrichHostMetadata(
try {
/**
* Get agent status by elastic agent id if available or use the host id.
- * https://github.com/elastic/endpoint-app-team/issues/354
*/
if (!elasticAgentId) {
diff --git a/x-pack/plugins/endpoint/server/routes/resolver.ts b/x-pack/plugins/endpoint/server/routes/resolver.ts
index a96d431225b15..3599acacb4f59 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver.ts
@@ -6,20 +6,27 @@
import { IRouter } from 'kibana/server';
import { EndpointAppContext } from '../types';
-import { handleRelatedEvents, validateRelatedEvents } from './resolver/related_events';
-import { handleChildren, validateChildren } from './resolver/children';
-import { handleLifecycle, validateLifecycle } from './resolver/lifecycle';
+import {
+ validateTree,
+ validateEvents,
+ validateChildren,
+ validateAncestry,
+} from '../../common/schema/resolver';
+import { handleEvents } from './resolver/events';
+import { handleChildren } from './resolver/children';
+import { handleAncestry } from './resolver/ancestry';
+import { handleTree } from './resolver/tree';
export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) {
const log = endpointAppContext.logFactory.get('resolver');
router.get(
{
- path: '/api/endpoint/resolver/{id}/related',
- validate: validateRelatedEvents,
+ path: '/api/endpoint/resolver/{id}/events',
+ validate: validateEvents,
options: { authRequired: true },
},
- handleRelatedEvents(log, endpointAppContext)
+ handleEvents(log, endpointAppContext)
);
router.get(
@@ -31,12 +38,21 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp
handleChildren(log, endpointAppContext)
);
+ router.get(
+ {
+ path: '/api/endpoint/resolver/{id}/ancestry',
+ validate: validateAncestry,
+ options: { authRequired: true },
+ },
+ handleAncestry(log, endpointAppContext)
+ );
+
router.get(
{
path: '/api/endpoint/resolver/{id}',
- validate: validateLifecycle,
+ validate: validateTree,
options: { authRequired: true },
},
- handleLifecycle(log, endpointAppContext)
+ handleTree(log, endpointAppContext)
);
}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/ancestry.ts b/x-pack/plugins/endpoint/server/routes/resolver/ancestry.ts
new file mode 100644
index 0000000000000..6648dc5b9e493
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/ancestry.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RequestHandler, Logger } from 'kibana/server';
+import { TypeOf } from '@kbn/config-schema';
+import { validateAncestry } from '../../../common/schema/resolver';
+import { Fetcher } from './utils/fetch';
+import { EndpointAppContext } from '../../types';
+
+export function handleAncestry(
+ log: Logger,
+ endpointAppContext: EndpointAppContext
+): RequestHandler, TypeOf> {
+ return async (context, req, res) => {
+ const {
+ params: { id },
+ query: { ancestors, legacyEndpointID: endpointID },
+ } = req;
+ try {
+ const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
+
+ const client = context.core.elasticsearch.dataClient;
+ const indexPattern = await indexRetriever.getEventIndexPattern(context);
+
+ const fetcher = new Fetcher(client, id, indexPattern, endpointID);
+ const tree = await fetcher.ancestors(ancestors + 1);
+
+ return res.ok({
+ body: tree.render(),
+ });
+ } catch (err) {
+ log.warn(err);
+ return res.internalError({ body: err });
+ }
+ };
+}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/children.ts b/x-pack/plugins/endpoint/server/routes/resolver/children.ts
index c3b19b6c912b6..bb18b29a4b947 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/children.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/children.ts
@@ -4,87 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import _ from 'lodash';
-import { schema } from '@kbn/config-schema';
import { RequestHandler, Logger } from 'kibana/server';
-import { extractEntityID } from './utils/normalize';
-import { getPaginationParams } from './utils/pagination';
-import { LifecycleQuery } from './queries/lifecycle';
-import { ChildrenQuery } from './queries/children';
+import { TypeOf } from '@kbn/config-schema';
+import { validateChildren } from '../../../common/schema/resolver';
+import { Fetcher } from './utils/fetch';
import { EndpointAppContext } from '../../types';
-interface ChildrenQueryParams {
- after?: string;
- limit: number;
- /**
- * legacyEndpointID is optional because there are two different types of identifiers:
- *
- * Legacy
- * A legacy Entity ID is made up of the agent.id and unique_pid fields. The client will need to identify if
- * it's looking at a legacy event and use those fields when making requests to the backend. The
- * request would be /resolver/{id}?legacyEndpointID=and the {id} would be the unique_pid.
- *
- * Elastic Endpoint
- * When interacting with the new form of data the client doesn't need the legacyEndpointID because it's already a
- * part of the entityID in the new type of event. So for the same request the client would just hit resolver/{id}
- * and the {id} would be entityID stored in the event's process.entity_id field.
- */
- legacyEndpointID?: string;
-}
-
-interface ChildrenPathParams {
- id: string;
-}
-
-export const validateChildren = {
- params: schema.object({ id: schema.string() }),
- query: schema.object({
- after: schema.maybe(schema.string()),
- limit: schema.number({ defaultValue: 10, min: 1, max: 100 }),
- legacyEndpointID: schema.maybe(schema.string()),
- }),
-};
-
export function handleChildren(
log: Logger,
endpointAppContext: EndpointAppContext
-): RequestHandler {
+): RequestHandler, TypeOf> {
return async (context, req, res) => {
const {
params: { id },
- query: { limit, after, legacyEndpointID },
+ query: { children, generations, afterChild, legacyEndpointID: endpointID },
} = req;
try {
const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
- const pagination = getPaginationParams(limit, after);
const indexPattern = await indexRetriever.getEventIndexPattern(context);
const client = context.core.elasticsearch.dataClient;
- const childrenQuery = new ChildrenQuery(indexPattern, legacyEndpointID, pagination);
- const lifecycleQuery = new LifecycleQuery(indexPattern, legacyEndpointID);
-
- // Retrieve the related child process events for a given process
- const { total, results: events, nextCursor } = await childrenQuery.search(client, id);
- const childIDs = events.map(extractEntityID);
-
- // Retrieve the lifecycle events for the child processes (e.g. started, terminated etc)
- // this needs to fire after the above since we don't yet have the entity ids until we
- // run the first query
- const { results: lifecycleEvents } = await lifecycleQuery.search(client, ...childIDs);
- // group all of the lifecycle events by the child process id
- const lifecycleGroups = Object.values(_.groupBy(lifecycleEvents, extractEntityID));
- const children = lifecycleGroups.map(group => ({ lifecycle: group }));
+ const fetcher = new Fetcher(client, id, indexPattern, endpointID);
+ const tree = await fetcher.children(children, generations, afterChild);
return res.ok({
- body: {
- children,
- pagination: {
- total,
- next: nextCursor,
- limit,
- },
- },
+ body: tree.render(),
});
} catch (err) {
log.warn(err);
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/events.ts b/x-pack/plugins/endpoint/server/routes/resolver/events.ts
new file mode 100644
index 0000000000000..a70a6e8d097d0
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/events.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { TypeOf } from '@kbn/config-schema';
+import { RequestHandler, Logger } from 'kibana/server';
+import { validateEvents } from '../../../common/schema/resolver';
+import { Fetcher } from './utils/fetch';
+import { EndpointAppContext } from '../../types';
+
+export function handleEvents(
+ log: Logger,
+ endpointAppContext: EndpointAppContext
+): RequestHandler, TypeOf> {
+ return async (context, req, res) => {
+ const {
+ params: { id },
+ query: { events, afterEvent, legacyEndpointID: endpointID },
+ } = req;
+ try {
+ const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
+ const client = context.core.elasticsearch.dataClient;
+ const indexPattern = await indexRetriever.getEventIndexPattern(context);
+
+ const fetcher = new Fetcher(client, id, indexPattern, endpointID);
+ const tree = await fetcher.events(events, afterEvent);
+
+ return res.ok({
+ body: tree.render(),
+ });
+ } catch (err) {
+ log.warn(err);
+ return res.internalError({ body: err });
+ }
+ };
+}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts b/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts
deleted file mode 100644
index 91a4c5d49bc54..0000000000000
--- a/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { schema } from '@kbn/config-schema';
-import { RequestHandler, Logger } from 'kibana/server';
-import { extractParentEntityID } from './utils/normalize';
-import { LifecycleQuery } from './queries/lifecycle';
-import { ResolverEvent } from '../../../common/types';
-import { EndpointAppContext } from '../../types';
-
-interface LifecycleQueryParams {
- ancestors: number;
- /**
- * legacyEndpointID is optional because there are two different types of identifiers:
- *
- * Legacy
- * A legacy Entity ID is made up of the agent.id and unique_pid fields. The client will need to identify if
- * it's looking at a legacy event and use those fields when making requests to the backend. The
- * request would be /resolver/{id}?legacyEndpointID=and the {id} would be the unique_pid.
- *
- * Elastic Endpoint
- * When interacting with the new form of data the client doesn't need the legacyEndpointID because it's already a
- * part of the entityID in the new type of event. So for the same request the client would just hit resolver/{id}
- * and the {id} would be entityID stored in the event's process.entity_id field.
- */
- legacyEndpointID?: string;
-}
-
-interface LifecyclePathParams {
- id: string;
-}
-
-export const validateLifecycle = {
- params: schema.object({ id: schema.string() }),
- query: schema.object({
- ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }),
- legacyEndpointID: schema.maybe(schema.string()),
- }),
-};
-
-function getParentEntityID(results: ResolverEvent[]) {
- return results.length === 0 ? undefined : extractParentEntityID(results[0]);
-}
-
-export function handleLifecycle(
- log: Logger,
- endpointAppContext: EndpointAppContext
-): RequestHandler {
- return async (context, req, res) => {
- const {
- params: { id },
- query: { ancestors, legacyEndpointID },
- } = req;
- try {
- const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
- const ancestorLifecycles = [];
- const client = context.core.elasticsearch.dataClient;
- const indexPattern = await indexRetriever.getEventIndexPattern(context);
- const lifecycleQuery = new LifecycleQuery(indexPattern, legacyEndpointID);
- const { results: processLifecycle } = await lifecycleQuery.search(client, id);
- let nextParentID = getParentEntityID(processLifecycle);
-
- if (nextParentID) {
- for (let i = 0; i < ancestors; i++) {
- const { results: lifecycle } = await lifecycleQuery.search(client, nextParentID);
- nextParentID = getParentEntityID(lifecycle);
-
- if (!nextParentID) {
- break;
- }
-
- ancestorLifecycles.push({
- lifecycle,
- });
- }
- }
-
- return res.ok({
- body: {
- lifecycle: processLifecycle,
- ancestors: ancestorLifecycles,
- pagination: {
- next: nextParentID || null,
- ancestors,
- },
- },
- });
- } catch (err) {
- log.warn(err);
- return res.internalError({ body: err });
- }
- };
-}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts
index 3b3b4b0c9e8ac..eba4e5581c136 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts
@@ -4,8 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { SearchResponse } from 'elasticsearch';
import { IScopedClusterClient } from 'kibana/server';
-import { paginate, paginatedResults, PaginationParams } from '../utils/pagination';
+import { ResolverEvent } from '../../../../common/types';
+import {
+ paginate,
+ paginatedResults,
+ PaginationParams,
+ PaginatedResults,
+} from '../utils/pagination';
import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public';
import { legacyEventIndexPattern } from './legacy_event_index_pattern';
@@ -16,11 +23,13 @@ export abstract class ResolverQuery {
private readonly pagination?: PaginationParams
) {}
- protected paginateBy(field: string, query: JsonObject) {
- if (!this.pagination) {
- return query;
- }
- return paginate(this.pagination, field, query);
+ protected paginateBy(tiebreaker: string, aggregator: string) {
+ return (query: JsonObject) => {
+ if (!this.pagination) {
+ return query;
+ }
+ return paginate(this.pagination, tiebreaker, aggregator, query);
+ };
}
build(...ids: string[]) {
@@ -31,7 +40,11 @@ export abstract class ResolverQuery {
}
async search(client: IScopedClusterClient, ...ids: string[]) {
- return paginatedResults(await client.callAsCurrentUser('search', this.build(...ids)));
+ return this.postSearch(await client.callAsCurrentUser('search', this.build(...ids)));
+ }
+
+ protected postSearch(response: SearchResponse): PaginatedResults {
+ return paginatedResults(response);
}
protected abstract legacyQuery(
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts
index d1d090579f0cd..2a097e87c38b2 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts
@@ -32,15 +32,28 @@ describe('children events query', () => {
term: { 'event.category': 'process' },
},
{
- term: { 'event.type': 'process_start' },
+ term: { 'event.kind': 'event' },
+ },
+ {
+ bool: {
+ should: [
+ {
+ term: { 'event.type': 'process_start' },
+ },
+ {
+ term: { 'event.action': 'fork_event' },
+ },
+ ],
+ },
},
],
},
},
aggs: {
- total: {
- value_count: {
- field: 'endgame.serial_event_id',
+ totals: {
+ terms: {
+ field: 'endgame.unique_ppid',
+ size: 1,
},
},
},
@@ -67,20 +80,14 @@ describe('children events query', () => {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.parent.entity_id': ['baz'] },
- },
- {
- terms: { 'process.parent.entity_id': ['baz'] },
- },
- ],
- },
+ terms: { 'process.parent.entity_id': ['baz'] },
},
{
term: { 'event.category': 'process' },
},
+ {
+ term: { 'event.kind': 'event' },
+ },
{
term: { 'event.type': 'start' },
},
@@ -88,9 +95,10 @@ describe('children events query', () => {
},
},
aggs: {
- total: {
- value_count: {
- field: 'event.id',
+ totals: {
+ terms: {
+ field: 'process.parent.entity_id',
+ size: 1,
},
},
},
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.ts
index 6d084a0cf20e5..690c926d7e6d6 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.ts
@@ -7,8 +7,9 @@ import { ResolverQuery } from './base';
export class ChildrenQuery extends ResolverQuery {
protected legacyQuery(endpointID: string, uniquePIDs: string[], index: string) {
+ const paginator = this.paginateBy('endgame.serial_event_id', 'endgame.unique_ppid');
return {
- body: this.paginateBy('endgame.serial_event_id', {
+ body: paginator({
query: {
bool: {
filter: [
@@ -22,11 +23,19 @@ export class ChildrenQuery extends ResolverQuery {
term: { 'event.category': 'process' },
},
{
- // Corner case, we could only have a process_running or process_terminated
- // so to solve this we'll probably want to either search for all of them and only return one if that's
- // possible in elastic search or in memory pull out a single event to return
- // https://github.com/elastic/endpoint-app-team/issues/168
- term: { 'event.type': 'process_start' },
+ term: { 'event.kind': 'event' },
+ },
+ {
+ bool: {
+ should: [
+ {
+ term: { 'event.type': 'process_start' },
+ },
+ {
+ term: { 'event.action': 'fork_event' },
+ },
+ ],
+ },
},
],
},
@@ -37,31 +46,22 @@ export class ChildrenQuery extends ResolverQuery {
}
protected query(entityIDs: string[], index: string) {
+ const paginator = this.paginateBy('event.id', 'process.parent.entity_id');
return {
- body: this.paginateBy('event.id', {
+ body: paginator({
query: {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.parent.entity_id': entityIDs },
- },
- {
- terms: { 'process.parent.entity_id': entityIDs },
- },
- ],
- },
+ terms: { 'process.parent.entity_id': entityIDs },
},
{
term: { 'event.category': 'process' },
},
{
- // Corner case, we could only have a process_running or process_terminated
- // so to solve this we'll probably want to either search for all of them and only return one if that's
- // possible in elastic search or in memory pull out a single event to return
- // https://github.com/elastic/endpoint-app-team/issues/168
+ term: { 'event.kind': 'event' },
+ },
+ {
term: { 'event.type': 'start' },
},
],
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/events.test.ts
similarity index 75%
rename from x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts
rename to x-pack/plugins/endpoint/server/routes/resolver/queries/events.test.ts
index 26205e9db6e45..78e5ee9226581 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/events.test.ts
@@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { RelatedEventsQuery } from './related_events';
+import { EventsQuery } from './events';
import { fakeEventIndexPattern } from './children.test';
import { legacyEventIndexPattern } from './legacy_event_index_pattern';
@@ -11,7 +11,7 @@ describe('related events query', () => {
it('generates the correct legacy queries', () => {
const timestamp = new Date().getTime();
expect(
- new RelatedEventsQuery(legacyEventIndexPattern, 'awesome-id', {
+ new EventsQuery(legacyEventIndexPattern, 'awesome-id', {
size: 1,
timestamp,
eventID: 'foo',
@@ -27,6 +27,9 @@ describe('related events query', () => {
{
term: { 'agent.id': 'awesome-id' },
},
+ {
+ term: { 'event.kind': 'event' },
+ },
{
bool: {
must_not: {
@@ -38,9 +41,10 @@ describe('related events query', () => {
},
},
aggs: {
- total: {
- value_count: {
- field: 'endgame.serial_event_id',
+ totals: {
+ terms: {
+ field: 'endgame.unique_pid',
+ size: 1,
},
},
},
@@ -56,7 +60,7 @@ describe('related events query', () => {
const timestamp = new Date().getTime();
expect(
- new RelatedEventsQuery(fakeEventIndexPattern, undefined, {
+ new EventsQuery(fakeEventIndexPattern, undefined, {
size: 1,
timestamp,
eventID: 'bar',
@@ -67,16 +71,10 @@ describe('related events query', () => {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.entity_id': ['baz'] },
- },
- {
- terms: { 'process.entity_id': ['baz'] },
- },
- ],
- },
+ terms: { 'process.entity_id': ['baz'] },
+ },
+ {
+ term: { 'event.kind': 'event' },
},
{
bool: {
@@ -89,9 +87,10 @@ describe('related events query', () => {
},
},
aggs: {
- total: {
- value_count: {
- field: 'event.id',
+ totals: {
+ terms: {
+ field: 'process.entity_id',
+ size: 1,
},
},
},
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/events.ts
similarity index 74%
rename from x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.ts
rename to x-pack/plugins/endpoint/server/routes/resolver/queries/events.ts
index cc5afe8face8d..b622cb8a21111 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/events.ts
@@ -6,10 +6,11 @@
import { ResolverQuery } from './base';
import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public';
-export class RelatedEventsQuery extends ResolverQuery {
+export class EventsQuery extends ResolverQuery {
protected legacyQuery(endpointID: string, uniquePIDs: string[], index: string): JsonObject {
+ const paginator = this.paginateBy('endgame.serial_event_id', 'endgame.unique_pid');
return {
- body: this.paginateBy('endgame.serial_event_id', {
+ body: paginator({
query: {
bool: {
filter: [
@@ -19,6 +20,9 @@ export class RelatedEventsQuery extends ResolverQuery {
{
term: { 'agent.id': endpointID },
},
+ {
+ term: { 'event.kind': 'event' },
+ },
{
bool: {
must_not: {
@@ -35,22 +39,17 @@ export class RelatedEventsQuery extends ResolverQuery {
}
protected query(entityIDs: string[], index: string): JsonObject {
+ const paginator = this.paginateBy('event.id', 'process.entity_id');
return {
- body: this.paginateBy('event.id', {
+ body: paginator({
query: {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.entity_id': entityIDs },
- },
- {
- terms: { 'process.entity_id': entityIDs },
- },
- ],
- },
+ terms: { 'process.entity_id': entityIDs },
+ },
+ {
+ term: { 'event.kind': 'event' },
},
{
bool: {
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts
index 9d3c675ecc54c..296135af83b72 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts
@@ -21,12 +21,16 @@ describe('lifecycle query', () => {
{
term: { 'agent.id': 'awesome-id' },
},
+ {
+ term: { 'event.kind': 'event' },
+ },
{
term: { 'event.category': 'process' },
},
],
},
},
+ size: 10000,
sort: [{ '@timestamp': 'asc' }],
},
index: legacyEventIndexPattern,
@@ -40,16 +44,10 @@ describe('lifecycle query', () => {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.entity_id': ['baz'] },
- },
- {
- terms: { 'process.entity_id': ['baz'] },
- },
- ],
- },
+ terms: { 'process.entity_id': ['baz'] },
+ },
+ {
+ term: { 'event.kind': 'event' },
},
{
term: { 'event.category': 'process' },
@@ -57,6 +55,7 @@ describe('lifecycle query', () => {
],
},
},
+ size: 10000,
sort: [{ '@timestamp': 'asc' }],
},
index: fakeEventIndexPattern,
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.ts
index 290c601e0e9d8..e775b0cf9b6d2 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.ts
@@ -6,7 +6,6 @@
import { ResolverQuery } from './base';
import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public';
-// consider limiting the response size to a reasonable value in case we have a bunch of lifecycle events
export class LifecycleQuery extends ResolverQuery {
protected legacyQuery(endpointID: string, uniquePIDs: string[], index: string): JsonObject {
return {
@@ -20,12 +19,16 @@ export class LifecycleQuery extends ResolverQuery {
{
term: { 'agent.id': endpointID },
},
+ {
+ term: { 'event.kind': 'event' },
+ },
{
term: { 'event.category': 'process' },
},
],
},
},
+ size: 10000,
sort: [{ '@timestamp': 'asc' }],
},
index,
@@ -39,16 +42,10 @@ export class LifecycleQuery extends ResolverQuery {
bool: {
filter: [
{
- bool: {
- should: [
- {
- terms: { 'endpoint.process.entity_id': entityIDs },
- },
- {
- terms: { 'process.entity_id': entityIDs },
- },
- ],
- },
+ terms: { 'process.entity_id': entityIDs },
+ },
+ {
+ term: { 'event.kind': 'event' },
},
{
term: { 'event.category': 'process' },
@@ -56,6 +53,7 @@ export class LifecycleQuery extends ResolverQuery {
],
},
},
+ size: 10000,
sort: [{ '@timestamp': 'asc' }],
},
index,
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.test.ts
new file mode 100644
index 0000000000000..17a158aec7cf5
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.test.ts
@@ -0,0 +1,188 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { legacyEventIndexPattern } from './legacy_event_index_pattern';
+import { StatsQuery } from './stats';
+import { fakeEventIndexPattern } from './children.test';
+
+describe('stats query', () => {
+ it('generates the correct legacy queries', () => {
+ expect(new StatsQuery(legacyEventIndexPattern, 'awesome-id').build('5')).toStrictEqual({
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'agent.id': 'awesome-id',
+ },
+ },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'event.kind': 'event',
+ },
+ },
+ {
+ terms: {
+ 'endgame.unique_pid': ['5'],
+ },
+ },
+ {
+ bool: {
+ must_not: {
+ term: {
+ 'event.category': 'process',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'event.kind': 'alert',
+ },
+ },
+ {
+ terms: {
+ 'endgame.data.alert_details.acting_process.unique_pid': ['5'],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ alerts: {
+ filter: {
+ term: {
+ 'event.kind': 'alert',
+ },
+ },
+ aggs: {
+ ids: {
+ terms: {
+ field: 'endgame.data.alert_details.acting_process.unique_pid',
+ },
+ },
+ },
+ },
+ events: {
+ filter: {
+ term: {
+ 'event.kind': 'event',
+ },
+ },
+ aggs: {
+ ids: {
+ terms: {
+ field: 'endgame.unique_pid',
+ },
+ },
+ },
+ },
+ },
+ },
+ index: legacyEventIndexPattern,
+ });
+ });
+
+ it('generates the correct non-legacy queries', () => {
+ expect(new StatsQuery(fakeEventIndexPattern).build('baz')).toStrictEqual({
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ terms: {
+ 'process.entity_id': ['baz'],
+ },
+ },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'event.kind': 'event',
+ },
+ },
+ {
+ bool: {
+ must_not: {
+ term: {
+ 'event.category': 'process',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ term: {
+ 'event.kind': 'alert',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ alerts: {
+ filter: {
+ term: {
+ 'event.kind': 'alert',
+ },
+ },
+ aggs: {
+ ids: {
+ terms: {
+ field: 'process.entity_id',
+ },
+ },
+ },
+ },
+ events: {
+ filter: {
+ term: {
+ 'event.kind': 'event',
+ },
+ },
+ aggs: {
+ ids: {
+ terms: {
+ field: 'process.entity_id',
+ },
+ },
+ },
+ },
+ },
+ },
+ index: fakeEventIndexPattern,
+ });
+ });
+});
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.ts
new file mode 100644
index 0000000000000..7db3ab2b0cb1f
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/stats.ts
@@ -0,0 +1,147 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { SearchResponse } from 'elasticsearch';
+import { ResolverQuery } from './base';
+import { ResolverEvent } from '../../../../common/types';
+import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public';
+import { PaginatedResults } from '../utils/pagination';
+
+export class StatsQuery extends ResolverQuery {
+ protected postSearch(response: SearchResponse): PaginatedResults {
+ const alerts = response.aggregations.alerts.ids.buckets.reduce(
+ (cummulative: any, bucket: any) => ({ ...cummulative, [bucket.key]: bucket.doc_count }),
+ {}
+ );
+ const events = response.aggregations.events.ids.buckets.reduce(
+ (cummulative: any, bucket: any) => ({ ...cummulative, [bucket.key]: bucket.doc_count }),
+ {}
+ );
+ return {
+ totals: {},
+ results: [],
+ extras: {
+ alerts,
+ events,
+ },
+ };
+ }
+
+ protected legacyQuery(endpointID: string, uniquePIDs: string[], index: string): JsonObject {
+ return {
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ term: { 'agent.id': endpointID },
+ },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ filter: [
+ { term: { 'event.kind': 'event' } },
+ { terms: { 'endgame.unique_pid': uniquePIDs } },
+ {
+ bool: {
+ must_not: {
+ term: { 'event.category': 'process' },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ filter: [
+ { term: { 'event.kind': 'alert' } },
+ {
+ terms: {
+ 'endgame.data.alert_details.acting_process.unique_pid': uniquePIDs,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ alerts: {
+ filter: { term: { 'event.kind': 'alert' } },
+ aggs: {
+ ids: { terms: { field: 'endgame.data.alert_details.acting_process.unique_pid' } },
+ },
+ },
+ events: {
+ filter: { term: { 'event.kind': 'event' } },
+ aggs: {
+ ids: { terms: { field: 'endgame.unique_pid' } },
+ },
+ },
+ },
+ },
+ index,
+ };
+ }
+
+ protected query(entityIDs: string[], index: string): JsonObject {
+ return {
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { terms: { 'process.entity_id': entityIDs } },
+ {
+ bool: {
+ should: [
+ {
+ bool: {
+ filter: [
+ { term: { 'event.kind': 'event' } },
+ {
+ bool: {
+ must_not: {
+ term: { 'event.category': 'process' },
+ },
+ },
+ },
+ ],
+ },
+ },
+ { term: { 'event.kind': 'alert' } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ alerts: {
+ filter: { term: { 'event.kind': 'alert' } },
+ aggs: {
+ ids: { terms: { field: 'process.entity_id' } },
+ },
+ },
+ events: {
+ filter: { term: { 'event.kind': 'event' } },
+ aggs: {
+ ids: { terms: { field: 'process.entity_id' } },
+ },
+ },
+ },
+ },
+ index,
+ };
+ }
+}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts b/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts
deleted file mode 100644
index 83e111a1e62e6..0000000000000
--- a/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { schema } from '@kbn/config-schema';
-import { RequestHandler, Logger } from 'kibana/server';
-import { getPaginationParams } from './utils/pagination';
-import { RelatedEventsQuery } from './queries/related_events';
-import { EndpointAppContext } from '../../types';
-
-interface RelatedEventsQueryParams {
- after?: string;
- limit: number;
- /**
- * legacyEndpointID is optional because there are two different types of identifiers:
- *
- * Legacy
- * A legacy Entity ID is made up of the agent.id and unique_pid fields. The client will need to identify if
- * it's looking at a legacy event and use those fields when making requests to the backend. The
- * request would be /resolver/{id}?legacyEndpointID=and the {id} would be the unique_pid.
- *
- * Elastic Endpoint
- * When interacting with the new form of data the client doesn't need the legacyEndpointID because it's already a
- * part of the entityID in the new type of event. So for the same request the client would just hit resolver/{id}
- * and the {id} would be entityID stored in the event's process.entity_id field.
- */
- legacyEndpointID?: string;
-}
-
-interface RelatedEventsPathParams {
- id: string;
-}
-
-export const validateRelatedEvents = {
- params: schema.object({ id: schema.string() }),
- query: schema.object({
- after: schema.maybe(schema.string()),
- limit: schema.number({ defaultValue: 100, min: 1, max: 1000 }),
- legacyEndpointID: schema.maybe(schema.string()),
- }),
-};
-
-export function handleRelatedEvents(
- log: Logger,
- endpointAppContext: EndpointAppContext
-): RequestHandler {
- return async (context, req, res) => {
- const {
- params: { id },
- query: { limit, after, legacyEndpointID },
- } = req;
- try {
- const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
- const pagination = getPaginationParams(limit, after);
-
- const client = context.core.elasticsearch.dataClient;
- const indexPattern = await indexRetriever.getEventIndexPattern(context);
- // Retrieve the related non-process events for a given process
- const relatedEventsQuery = new RelatedEventsQuery(indexPattern, legacyEndpointID, pagination);
- const relatedEvents = await relatedEventsQuery.search(client, id);
-
- const { total, results: events, nextCursor } = relatedEvents;
-
- return res.ok({
- body: {
- events,
- pagination: { total, next: nextCursor, limit },
- },
- });
- } catch (err) {
- log.warn(err);
- return res.internalError({ body: err });
- }
- };
-}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/tree.ts b/x-pack/plugins/endpoint/server/routes/resolver/tree.ts
new file mode 100644
index 0000000000000..25f15586341d5
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/tree.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RequestHandler, Logger } from 'kibana/server';
+import { TypeOf } from '@kbn/config-schema';
+import { validateTree } from '../../../common/schema/resolver';
+import { Fetcher } from './utils/fetch';
+import { Tree } from './utils/tree';
+import { EndpointAppContext } from '../../types';
+
+export function handleTree(
+ log: Logger,
+ endpointAppContext: EndpointAppContext
+): RequestHandler, TypeOf> {
+ return async (context, req, res) => {
+ const {
+ params: { id },
+ query: {
+ children,
+ generations,
+ ancestors,
+ events,
+ afterEvent,
+ afterChild,
+ legacyEndpointID: endpointID,
+ },
+ } = req;
+ try {
+ const client = context.core.elasticsearch.dataClient;
+ const indexRetriever = endpointAppContext.service.getIndexPatternRetriever();
+ const indexPattern = await indexRetriever.getEventIndexPattern(context);
+
+ const fetcher = new Fetcher(client, id, indexPattern, endpointID);
+ const tree = await Tree.merge(
+ fetcher.children(children, generations, afterChild),
+ fetcher.ancestors(ancestors + 1),
+ fetcher.events(events, afterEvent)
+ );
+
+ const enrichedTree = await fetcher.stats(tree);
+
+ return res.ok({
+ body: enrichedTree.render(),
+ });
+ } catch (err) {
+ log.warn(err);
+ return res.internalError({ body: 'Error retrieving tree.' });
+ }
+ };
+}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/fetch.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/fetch.ts
new file mode 100644
index 0000000000000..7315b4ee6c618
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/fetch.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IScopedClusterClient } from 'kibana/server';
+import { entityId, parentEntityId } from '../../../../common/models/event';
+import { getPaginationParams } from './pagination';
+import { Tree } from './tree';
+import { LifecycleQuery } from '../queries/lifecycle';
+import { ChildrenQuery } from '../queries/children';
+import { EventsQuery } from '../queries/events';
+import { StatsQuery } from '../queries/stats';
+
+export class Fetcher {
+ constructor(
+ private readonly client: IScopedClusterClient,
+ private readonly id: string,
+ private readonly indexPattern: string,
+ private readonly endpointID?: string
+ ) {}
+
+ public async ancestors(limit: number): Promise {
+ const tree = new Tree(this.id);
+ await this.doAncestors(tree, this.id, this.id, limit);
+ return tree;
+ }
+
+ public async children(limit: number, generations: number, after?: string): Promise {
+ const tree = new Tree(this.id);
+ await this.doChildren(tree, [this.id], limit, generations, after);
+ return tree;
+ }
+
+ public async events(limit: number, after?: string): Promise {
+ const tree = new Tree(this.id);
+ await this.doEvents(tree, limit, after);
+ return tree;
+ }
+
+ public async stats(tree: Tree): Promise {
+ await this.doStats(tree);
+ return tree;
+ }
+
+ private async doAncestors(tree: Tree, curNode: string, previousNode: string, levels: number) {
+ if (levels === 0) {
+ tree.setNextAncestor(curNode);
+ return;
+ }
+
+ const query = new LifecycleQuery(this.indexPattern, this.endpointID);
+ const { results } = await query.search(this.client, curNode);
+
+ if (results.length === 0) {
+ tree.setNextAncestor(null);
+ return;
+ }
+ tree.addAncestor(previousNode, ...results);
+
+ const next = parentEntityId(results[0]);
+ if (next !== undefined) {
+ await this.doAncestors(tree, next, curNode, levels - 1);
+ }
+ }
+
+ private async doEvents(tree: Tree, limit: number, after?: string) {
+ const query = new EventsQuery(
+ this.indexPattern,
+ this.endpointID,
+ getPaginationParams(limit, after)
+ );
+
+ const { totals, results } = await query.search(this.client, this.id);
+ tree.addEvent(...results);
+ tree.paginateEvents(totals, results);
+ if (results.length === 0) tree.setNextEvent(null);
+ }
+
+ private async doChildren(
+ tree: Tree,
+ ids: string[],
+ limit: number,
+ levels: number,
+ after?: string
+ ) {
+ if (levels === 0 || ids.length === 0) return;
+
+ const childrenQuery = new ChildrenQuery(
+ this.indexPattern,
+ this.endpointID,
+ getPaginationParams(limit, after)
+ );
+ const lifecycleQuery = new LifecycleQuery(this.indexPattern, this.endpointID);
+
+ const { totals, results } = await childrenQuery.search(this.client, ...ids);
+ if (results.length === 0) {
+ tree.markLeafNode(...ids);
+ return;
+ }
+
+ const childIDs = results.map(entityId);
+ const children = (await lifecycleQuery.search(this.client, ...childIDs)).results;
+
+ tree.addChild(...children);
+ tree.paginateChildren(totals, results);
+ tree.markLeafNode(...childIDs);
+
+ await this.doChildren(tree, childIDs, limit * limit, levels - 1);
+ }
+
+ private async doStats(tree: Tree) {
+ const statsQuery = new StatsQuery(this.indexPattern, this.endpointID);
+ const ids = tree.ids();
+ const { extras } = await statsQuery.search(this.client, ...ids);
+ const alerts = extras?.alerts || {};
+ const events = extras?.events || {};
+ ids.forEach(id => {
+ tree.addStats(id, { totalAlerts: alerts[id] || 0, totalEvents: events[id] || 0 });
+ });
+ }
+}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts
deleted file mode 100644
index 6d5ac8efdc1da..0000000000000
--- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { ResolverEvent } from '../../../../common/types';
-import { isLegacyEvent } from '../../../../common/models/event';
-
-export function extractEventID(event: ResolverEvent) {
- if (isLegacyEvent(event)) {
- return String(event.endgame.serial_event_id);
- }
- return event.event.id;
-}
-
-export function extractEntityID(event: ResolverEvent) {
- if (isLegacyEvent(event)) {
- return String(event.endgame.unique_pid);
- }
- return event.process.entity_id;
-}
-
-export function extractParentEntityID(event: ResolverEvent) {
- if (isLegacyEvent(event)) {
- const ppid = event.endgame.unique_ppid;
- return ppid && String(ppid); // if unique_ppid is undefined return undefined
- }
- return event.process.parent?.entity_id;
-}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts
index 5a64f3ff9ddb6..20249b81660bb 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts
@@ -6,7 +6,7 @@
import { SearchResponse } from 'elasticsearch';
import { ResolverEvent } from '../../../../common/types';
-import { extractEventID } from './normalize';
+import { entityId } from '../../../../common/models/event';
import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public';
export interface PaginationParams {
@@ -15,12 +15,19 @@ export interface PaginationParams {
eventID?: string;
}
+export interface PaginatedResults {
+ totals: Record;
+ results: ResolverEvent[];
+ // content holder for any other extra aggregation counts
+ extras?: Record>;
+}
+
interface PaginationCursor {
timestamp: number;
eventID: string;
}
-function urlEncodeCursor(data: PaginationCursor) {
+function urlEncodeCursor(data: PaginationCursor): string {
const value = JSON.stringify(data);
return Buffer.from(value, 'utf8')
.toString('base64')
@@ -56,10 +63,16 @@ export function getPaginationParams(limit: number, after?: string): PaginationPa
return { size: limit };
}
-export function paginate(pagination: PaginationParams, field: string, query: JsonObject) {
+export function paginate(
+ pagination: PaginationParams,
+ tiebreaker: string,
+ aggregator: string,
+ query: JsonObject
+): JsonObject {
const { size, timestamp, eventID } = pagination;
- query.sort = [{ '@timestamp': 'asc' }, { [field]: 'asc' }];
- query.aggs = { total: { value_count: { field } } };
+ query.sort = [{ '@timestamp': 'asc' }, { [tiebreaker]: 'asc' }];
+ query.aggs = query.aggs || {};
+ query.aggs = Object.assign({}, query.aggs, { totals: { terms: { field: aggregator, size } } });
query.size = size;
if (timestamp && eventID) {
query.search_after = [timestamp, eventID] as Array;
@@ -67,25 +80,28 @@ export function paginate(pagination: PaginationParams, field: string, query: Jso
return query;
}
-export function paginatedResults(
- response: SearchResponse
-): { total: number; results: ResolverEvent[]; nextCursor: string | null } {
- const total = response.aggregations?.total?.value || 0;
- if (response.hits.hits.length === 0) {
- return { total, results: [], nextCursor: null };
+export function buildPaginationCursor(total: number, results: ResolverEvent[]): string | null {
+ if (total > results.length && results.length > 0) {
+ const lastResult = results[results.length - 1];
+ const cursor = {
+ timestamp: lastResult['@timestamp'],
+ eventID: entityId(lastResult),
+ };
+ return urlEncodeCursor(cursor);
}
+ return null;
+}
- const results: ResolverEvent[] = [];
- for (const hit of response.hits.hits) {
- results.push(hit._source);
+export function paginatedResults(response: SearchResponse): PaginatedResults {
+ if (response.hits.hits.length === 0) {
+ return { totals: {}, results: [] };
}
- // results will be at least 1 because of length check at the top of the function
- const next = results[results.length - 1];
- const cursor = {
- timestamp: next['@timestamp'],
- eventID: extractEventID(next),
- };
+ const totals = response.aggregations?.totals?.buckets?.reduce(
+ (cummulative: any, bucket: any) => ({ ...cummulative, [bucket.key]: bucket.doc_count }),
+ {}
+ );
- return { total, results, nextCursor: urlEncodeCursor(cursor) };
+ const results = response.hits.hits.map(hit => hit._source);
+ return { totals, results };
}
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/tree.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/tree.ts
new file mode 100644
index 0000000000000..5a55c23b90873
--- /dev/null
+++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/tree.ts
@@ -0,0 +1,230 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import _ from 'lodash';
+import {
+ ResolverEvent,
+ ResolverNode,
+ ResolverNodeStats,
+ ResolverNodePagination,
+} from '../../../../common/types';
+import { entityId, parentEntityId } from '../../../../common/models/event';
+import { buildPaginationCursor } from './pagination';
+
+type ExtractFunction = (event: ResolverEvent) => string | undefined;
+
+function createNode(id: string): ResolverNode {
+ return { id, children: [], pagination: {}, events: [], lifecycle: [] };
+}
+/**
+ * This class aids in constructing a tree of process events. It works in the following way:
+ *
+ * 1. We construct a tree structure starting with the root node for the event we're requesting.
+ * 2. We leverage the ability to pass hashes and arrays by reference to construct a fast cache of
+ * process identifiers that updates the tree structure as we push values into the cache.
+ *
+ * When we query a single level of results for child process events we have a flattened, sorted result
+ * list that we need to add into a constructed tree. We also need to signal in an API response whether
+ * or not there are more child processes events that we have not yet retrieved, and, if so, for what parent
+ * process. So, at the end of our tree construction we have a relational layout of the events with no
+ * pagination information for the given parent nodes. In order to actually construct both the tree and
+ * insert the pagination information we basically do the following:
+ *
+ * 1. Using a terms aggregation query, we return an approximate roll-up of the number of child process
+ * "creation" events, this gives us an estimation of the number of associated children per parent
+ * 2. We feed these child process creation event "unique identifiers" (basically a process.entity_id)
+ * into a second query to get the current state of the process via its "lifecycle" events.
+ * 3. We construct the tree above with the "lifecycle" events.
+ * 4. Using the terms query results, we mark each non-leaf node with the number of expected children, if our
+ * tree has less children than expected, we create a pagination cursor to indicate "we have a truncated set
+ * of values".
+ * 5. We mark each leaf node (the last level of the tree we're constructing) with a "null" for the expected
+ * number of children to indicate "we have not yet attempted to get any children".
+ *
+ * Following this scheme, we use exactly 2 queries per level of children that we return--one for the pagination
+ * and one for the lifecycle events of the processes. The downside to this is that we need to dynamically expand
+ * the number of documents we can retrieve per level due to the exponential fanout of child processes,
+ * what this means is that noisy neighbors for a given level may hide other child process events that occur later
+ * temporally in the same level--so, while a heavily forking process might get shown, maybe the actually malicious
+ * event doesn't show up in the tree at the beginning.
+ */
+
+export class Tree {
+ protected cache: Map;
+ protected root: ResolverNode;
+ protected id: string;
+
+ constructor(id: string) {
+ const root = createNode(id);
+ this.id = id;
+ this.cache = new Map();
+ this.root = root;
+ this.cache.set(id, root);
+ }
+
+ public render(): ResolverNode {
+ return this.root;
+ }
+
+ public ids(): string[] {
+ return [...this.cache.keys()];
+ }
+
+ public static async merge(
+ childrenPromise: Promise,
+ ancestorsPromise: Promise,
+ eventsPromise: Promise
+ ): Promise {
+ const [children, ancestors, events] = await Promise.all([
+ childrenPromise,
+ ancestorsPromise,
+ eventsPromise,
+ ]);
+
+ /*
+ * we only allow for merging when we have partial trees that
+ * represent the same root node
+ */
+ const rootID = children.id;
+ if (rootID !== ancestors.id || rootID !== events.id) {
+ throw new Error('cannot merge trees with different roots');
+ }
+
+ Object.entries(ancestors.cache).forEach(([id, node]) => {
+ if (rootID !== id) {
+ children.cache.set(id, node);
+ }
+ });
+
+ children.root.lifecycle = ancestors.root.lifecycle;
+ children.root.ancestors = ancestors.root.ancestors;
+ children.root.events = events.root.events;
+
+ Object.assign(children.root.pagination, ancestors.root.pagination, events.root.pagination);
+
+ return children;
+ }
+
+ public addEvent(...events: ResolverEvent[]): void {
+ events.forEach(event => {
+ const id = entityId(event);
+
+ this.ensureCache(id);
+ const currentNode = this.cache.get(id);
+ if (currentNode !== undefined) {
+ currentNode.events.push(event);
+ }
+ });
+ }
+
+ public addAncestor(id: string, ...events: ResolverEvent[]): void {
+ events.forEach(event => {
+ const ancestorID = entityId(event);
+ if (this.cache.get(ancestorID) === undefined) {
+ const newParent = createNode(ancestorID);
+ this.cache.set(ancestorID, newParent);
+ if (!this.root.ancestors) {
+ this.root.ancestors = [];
+ }
+ this.root.ancestors.push(newParent);
+ }
+ const currentAncestor = this.cache.get(ancestorID);
+ if (currentAncestor !== undefined) {
+ currentAncestor.lifecycle.push(event);
+ }
+ });
+ }
+
+ public addStats(id: string, stats: ResolverNodeStats): void {
+ this.ensureCache(id);
+ const currentNode = this.cache.get(id);
+ if (currentNode !== undefined) {
+ currentNode.stats = stats;
+ }
+ }
+
+ public setNextAncestor(next: string | null): void {
+ this.root.pagination.nextAncestor = next;
+ }
+
+ public setNextEvent(next: string | null): void {
+ this.root.pagination.nextEvent = next;
+ }
+
+ public setNextAlert(next: string | null): void {
+ this.root.pagination.nextAlert = next;
+ }
+
+ public addChild(...events: ResolverEvent[]): void {
+ events.forEach(event => {
+ const id = entityId(event);
+ const parentID = parentEntityId(event);
+
+ this.ensureCache(parentID);
+ let currentNode = this.cache.get(id);
+
+ if (currentNode === undefined) {
+ currentNode = createNode(id);
+ this.cache.set(id, currentNode);
+ if (parentID !== undefined) {
+ const parentNode = this.cache.get(parentID);
+ if (parentNode !== undefined) {
+ parentNode.children.push(currentNode);
+ }
+ }
+ }
+ currentNode.lifecycle.push(event);
+ });
+ }
+
+ public markLeafNode(...ids: string[]): void {
+ ids.forEach(id => {
+ this.ensureCache(id);
+ const currentNode = this.cache.get(id);
+ if (currentNode !== undefined && !currentNode.pagination.nextChild) {
+ currentNode.pagination.nextChild = null;
+ }
+ });
+ }
+
+ public paginateEvents(totals: Record, events: ResolverEvent[]): void {
+ return this.paginate(entityId, 'nextEvent', totals, events);
+ }
+
+ public paginateChildren(totals: Record, children: ResolverEvent[]): void {
+ return this.paginate(parentEntityId, 'nextChild', totals, children);
+ }
+
+ private paginate(
+ grouper: ExtractFunction,
+ attribute: keyof ResolverNodePagination,
+ totals: Record,
+ records: ResolverEvent[]
+ ): void {
+ const grouped = _.groupBy(records, grouper);
+ Object.entries(totals).forEach(([id, total]) => {
+ if (this.cache.get(id) !== undefined) {
+ if (grouped[id]) {
+ /*
+ * if we have any results, attempt to build a pagination cursor, the function
+ * below hands back a null value if no cursor is necessary because we have
+ * all of the records.
+ */
+ const currentNode = this.cache.get(id);
+ if (currentNode !== undefined) {
+ currentNode.pagination[attribute] = buildPaginationCursor(total, grouped[id]);
+ }
+ }
+ }
+ });
+ }
+
+ private ensureCache(id: string | undefined): void {
+ if (id === undefined || this.cache.get(id) === undefined) {
+ throw new Error('dangling node');
+ }
+ }
+}
diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md
index 38364033cb70b..941dedc3d1093 100644
--- a/x-pack/plugins/event_log/README.md
+++ b/x-pack/plugins/event_log/README.md
@@ -274,10 +274,16 @@ PUT _ilm/policy/event_log_policy
"hot": {
"actions": {
"rollover": {
- "max_size": "5GB",
+ "max_size": "50GB",
"max_age": "30d"
}
}
+ },
+ "delete": {
+ "min_age": "90d",
+ "actions": {
+ "delete": {}
+ }
}
}
}
@@ -285,10 +291,11 @@ PUT _ilm/policy/event_log_policy
```
This means that ILM would "rollover" the current index, say
-`.kibana-event-log-000001` by creating a new index `.kibana-event-log-000002`,
+`.kibana-event-log-8.0.0-000001` by creating a new index `.kibana-event-log-8.0.0-000002`,
which would "inherit" everything from the index template, and then ILM will
set the write index of the the alias to the new index. This would happen
-when the original index grew past 5 GB, or was created more than 30 days ago.
+when the original index grew past 50 GB, or was created more than 30 days ago.
+After rollover, the indices will be removed after 90 days to avoid disks to fill up.
For more relevant information on ILM, see:
[getting started with ILM doc][] and [write index alias behavior][]:
diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json
index f487e9262e50e..0a858969c4f6a 100644
--- a/x-pack/plugins/event_log/generated/mappings.json
+++ b/x-pack/plugins/event_log/generated/mappings.json
@@ -86,6 +86,10 @@
},
"saved_objects": {
"properties": {
+ "rel": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
"namespace": {
"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 9c923fe77d035..57fe90a8e876e 100644
--- a/x-pack/plugins/event_log/generated/schemas.ts
+++ b/x-pack/plugins/event_log/generated/schemas.ts
@@ -65,6 +65,7 @@ export const EventSchema = schema.maybe(
saved_objects: schema.maybe(
schema.arrayOf(
schema.object({
+ rel: ecsString(),
namespace: ecsString(),
id: ecsString(),
type: ecsString(),
diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js
index 8cc2c74b60e57..fd149d132031e 100644
--- a/x-pack/plugins/event_log/scripts/mappings.js
+++ b/x-pack/plugins/event_log/scripts/mappings.js
@@ -24,6 +24,11 @@ exports.EcsKibanaExtensionsMappings = {
saved_objects: {
type: 'nested',
properties: {
+ // relation; currently only supports "primary" or not set
+ rel: {
+ type: 'keyword',
+ ignore_above: 1024,
+ },
// relevant kibana space
namespace: {
type: 'keyword',
@@ -58,6 +63,7 @@ exports.EcsEventLogProperties = [
'user.name',
'kibana.server_uuid',
'kibana.alerting.instance_id',
+ 'kibana.saved_objects.rel',
'kibana.saved_objects.namespace',
'kibana.saved_objects.id',
'kibana.saved_objects.name',
diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts
index f79962a324131..66c16d0ddf383 100644
--- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts
+++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts
@@ -236,6 +236,13 @@ describe('queryEventsBySavedObject', () => {
query: {
bool: {
must: [
+ {
+ term: {
+ 'kibana.saved_objects.rel': {
+ value: 'primary',
+ },
+ },
+ },
{
term: {
'kibana.saved_objects.type': {
@@ -319,6 +326,13 @@ describe('queryEventsBySavedObject', () => {
query: {
bool: {
must: [
+ {
+ term: {
+ 'kibana.saved_objects.rel': {
+ value: 'primary',
+ },
+ },
+ },
{
term: {
'kibana.saved_objects.type': {
@@ -388,6 +402,13 @@ describe('queryEventsBySavedObject', () => {
query: {
bool: {
must: [
+ {
+ term: {
+ 'kibana.saved_objects.rel': {
+ value: 'primary',
+ },
+ },
+ },
{
term: {
'kibana.saved_objects.type': {
diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts
index 47d273b9981e3..c0ff87234c09d 100644
--- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts
+++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts
@@ -7,7 +7,7 @@
import { reject, isUndefined } from 'lodash';
import { SearchResponse, Client } from 'elasticsearch';
import { Logger, ClusterClient } from '../../../../../src/core/server';
-import { IEvent } from '../types';
+import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '../types';
import { FindOptionsType } from '../event_log_client';
export type EsClusterClient = Pick;
@@ -155,6 +155,13 @@ export class ClusterClientAdapter {
query: {
bool: {
must: [
+ {
+ term: {
+ 'kibana.saved_objects.rel': {
+ value: SAVED_OBJECT_REL_PRIMARY,
+ },
+ },
+ },
{
term: {
'kibana.saved_objects.type': {
diff --git a/x-pack/plugins/event_log/server/es/documents.ts b/x-pack/plugins/event_log/server/es/documents.ts
index a6af209d6d3a0..91b3db554964f 100644
--- a/x-pack/plugins/event_log/server/es/documents.ts
+++ b/x-pack/plugins/event_log/server/es/documents.ts
@@ -31,12 +31,18 @@ export function getIlmPolicy() {
hot: {
actions: {
rollover: {
- max_size: '5GB',
+ max_size: '50GB',
max_age: '30d',
// max_docs: 1, // you know, for testing
},
},
},
+ delete: {
+ min_age: '90d',
+ actions: {
+ delete: {},
+ },
+ },
},
},
};
diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts
index 6a745931420c0..2bda194a65d13 100644
--- a/x-pack/plugins/event_log/server/event_logger.test.ts
+++ b/x-pack/plugins/event_log/server/event_logger.test.ts
@@ -150,6 +150,35 @@ describe('EventLogger', () => {
message = await waitForLogMessage(systemLogger);
expect(message).toMatch(/invalid event logged.*action.*undefined.*/);
});
+
+ test('logs warnings when writing invalid events', async () => {
+ service.registerProviderActions('provider', ['action-a']);
+ eventLogger = service.getLogger({});
+
+ eventLogger.logEvent(({ event: { PROVIDER: 'provider' } } as unknown) as IEvent);
+ let message = await waitForLogMessage(systemLogger);
+ expect(message).toMatch(/invalid event logged.*provider.*undefined.*/);
+
+ const event: IEvent = {
+ event: {
+ provider: 'provider',
+ action: 'action-a',
+ },
+ kibana: {
+ saved_objects: [
+ {
+ rel: 'ZZZ-primary',
+ namespace: 'default',
+ type: 'event_log_test',
+ id: '123',
+ },
+ ],
+ },
+ };
+ eventLogger.logEvent(event);
+ message = await waitForLogMessage(systemLogger);
+ expect(message).toMatch(/invalid rel property.*ZZZ-primary.*/);
+ });
});
// return the next logged event; throw if not an event
diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts
index bcfd7bd45a6f5..1a710a6fa4865 100644
--- a/x-pack/plugins/event_log/server/event_logger.ts
+++ b/x-pack/plugins/event_log/server/event_logger.ts
@@ -19,6 +19,7 @@ import {
ECS_VERSION,
EventSchema,
} from './types';
+import { SAVED_OBJECT_REL_PRIMARY } from './types';
type SystemLogger = Plugin['systemLogger'];
@@ -118,6 +119,8 @@ const RequiredEventSchema = schema.object({
action: schema.string({ minLength: 1 }),
});
+const ValidSavedObjectRels = new Set([undefined, SAVED_OBJECT_REL_PRIMARY]);
+
function validateEvent(eventLogService: IEventLogService, event: IEvent): IValidatedEvent {
if (event?.event == null) {
throw new Error(`no "event" property`);
@@ -137,7 +140,17 @@ function validateEvent(eventLogService: IEventLogService, event: IEvent): IValid
}
// could throw an error
- return EventSchema.validate(event);
+ const result = EventSchema.validate(event);
+
+ if (result?.kibana?.saved_objects?.length) {
+ for (const so of result?.kibana?.saved_objects) {
+ if (!ValidSavedObjectRels.has(so.rel)) {
+ throw new Error(`invalid rel property in saved_objects: "${so.rel}"`);
+ }
+ }
+ }
+
+ return result;
}
export const EVENT_LOGGED_PREFIX = `event logged: `;
diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts
index b7fa25cb6eb9c..0612b5319c15b 100644
--- a/x-pack/plugins/event_log/server/index.ts
+++ b/x-pack/plugins/event_log/server/index.ts
@@ -8,6 +8,12 @@ import { PluginInitializerContext } from 'src/core/server';
import { ConfigSchema } from './types';
import { Plugin } from './plugin';
-export { IEventLogService, IEventLogger, IEventLogClientService, IEvent } from './types';
+export {
+ IEventLogService,
+ IEventLogger,
+ IEventLogClientService,
+ IEvent,
+ SAVED_OBJECT_REL_PRIMARY,
+} from './types';
export const config = { schema: ConfigSchema };
export const plugin = (context: PluginInitializerContext) => new Plugin(context);
diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts
index baf53ef447914..58be6707b0373 100644
--- a/x-pack/plugins/event_log/server/types.ts
+++ b/x-pack/plugins/event_log/server/types.ts
@@ -13,6 +13,8 @@ import { IEvent } from '../generated/schemas';
import { FindOptionsType } from './event_log_client';
import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter';
+export const SAVED_OBJECT_REL_PRIMARY = 'primary';
+
export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
logEntries: schema.boolean({ defaultValue: false }),
diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts
index 35ec0bb2bf6ce..fa479b1b06d5e 100644
--- a/x-pack/plugins/graph/public/application.ts
+++ b/x-pack/plugins/graph/public/application.ts
@@ -10,7 +10,7 @@
import angular from 'angular';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import '../../../../webpackShims/ace';
-// required for i18nIdDirective
+// required for i18nIdDirective and `ngSanitize` angular module
import 'angular-sanitize';
// required for ngRoute
import 'angular-route';
diff --git a/x-pack/plugins/graph/server/saved_objects/migrations.ts b/x-pack/plugins/graph/server/saved_objects/migrations.ts
index e77d2ea0fb7c9..beb31d548c670 100644
--- a/x-pack/plugins/graph/server/saved_objects/migrations.ts
+++ b/x-pack/plugins/graph/server/saved_objects/migrations.ts
@@ -8,7 +8,7 @@ import { get } from 'lodash';
import { SavedObjectUnsanitizedDoc } from 'kibana/server';
export const graphMigrations = {
- '7.0.0': (doc: SavedObjectUnsanitizedDoc) => {
+ '7.0.0': (doc: SavedObjectUnsanitizedDoc) => {
// Set new "references" attribute
doc.references = doc.references || [];
// Migrate index pattern
diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx
index d4d53b81109c6..f2ac34ccb752f 100644
--- a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx
+++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx
@@ -13,6 +13,9 @@ import {
EuiText,
EuiFormRow,
EuiButtonEmpty,
+ EuiCheckbox,
+ EuiToolTip,
+ EuiIcon,
EuiFieldSearch,
} from '@elastic/eui';
import { IFieldType } from 'src/plugins/data/public';
@@ -57,6 +60,7 @@ interface Props {
groupBy?: string;
filterQuery?: string;
sourceId?: string;
+ alertOnNoData?: boolean;
};
alertsContext: AlertsContextValue;
setAlertParams(key: string, value: any): void;
@@ -282,6 +286,28 @@ export const Expressions: React.FC = props => {
+
+
+ {i18n.translate('xpack.infra.metrics.alertFlyout.alertOnNoData', {
+ defaultMessage: "Alert me if there's no data",
+ })}{' '}
+
+
+
+ >
+ }
+ checked={alertParams.alertOnNoData}
+ onChange={e => setAlertParams('alertOnNoData', e.target.checked)}
+ />
+
{
+ const gtText = i18n.translate('xpack.infra.metrics.alerting.threshold.gtComparator', {
+ defaultMessage: 'greater than',
+ });
+ const ltText = i18n.translate('xpack.infra.metrics.alerting.threshold.ltComparator', {
+ defaultMessage: 'less than',
+ });
+ const eqText = i18n.translate('xpack.infra.metrics.alerting.threshold.eqComparator', {
+ defaultMessage: 'equal to',
+ });
+
+ switch (comparator) {
+ case Comparator.BETWEEN:
+ return i18n.translate('xpack.infra.metrics.alerting.threshold.betweenComparator', {
+ defaultMessage: 'between',
+ });
+ case Comparator.OUTSIDE_RANGE:
+ return i18n.translate('xpack.infra.metrics.alerting.threshold.outsideRangeComparator', {
+ defaultMessage: 'not between',
+ });
+ case Comparator.GT:
+ return gtText;
+ case Comparator.LT:
+ return ltText;
+ case Comparator.GT_OR_EQ:
+ case Comparator.LT_OR_EQ:
+ if (threshold[0] === currentValue) return eqText;
+ else if (threshold[0] < currentValue) return ltText;
+ return gtText;
+ }
+};
+
+const thresholdToI18n = ([a, b]: number[]) => {
+ if (typeof b === 'undefined') return a;
+ return i18n.translate('xpack.infra.metrics.alerting.threshold.thresholdRange', {
+ defaultMessage: '{a} and {b}',
+ values: { a, b },
+ });
+};
+
+export const buildFiredAlertReason: (alertResult: {
+ metric: string;
+ comparator: Comparator;
+ threshold: number[];
+ currentValue: number;
+}) => string = ({ metric, comparator, threshold, currentValue }) =>
+ i18n.translate('xpack.infra.metrics.alerting.threshold.firedAlertReason', {
+ defaultMessage:
+ '{metric} is {comparator} a threshold of {threshold} (current value is {currentValue})',
+ values: {
+ metric,
+ comparator: comparatorToI18n(comparator, threshold, currentValue),
+ threshold: thresholdToI18n(threshold),
+ currentValue,
+ },
+ });
+
+export const buildNoDataAlertReason: (alertResult: {
+ metric: string;
+ timeSize: number;
+ timeUnit: string;
+}) => string = ({ metric, timeSize, timeUnit }) =>
+ i18n.translate('xpack.infra.metrics.alerting.threshold.noDataAlertReason', {
+ defaultMessage: '{metric} has reported no data over the past {interval}',
+ values: {
+ metric,
+ interval: `${timeSize}${timeUnit}`,
+ },
+ });
+
+export const buildErrorAlertReason = (metric: string) =>
+ i18n.translate('xpack.infra.metrics.alerting.threshold.errorAlertReason', {
+ defaultMessage: 'Elasticsearch failed when attempting to query data for {metric}',
+ values: {
+ metric,
+ },
+ });
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
index 24b6ba2ec378b..0007b8bd719f4 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
@@ -34,6 +34,8 @@ services.callCluster.mockImplementation(async (_: string, { body, index }: any)
}
if (metric === 'test.metric.2') {
return mocks.alternateMetricResponse;
+ } else if (metric === 'test.metric.3') {
+ return mocks.emptyMetricResponse;
}
return mocks.basicMetricResponse;
});
@@ -161,9 +163,9 @@ describe('The metric threshold alert type', () => {
await execute(Comparator.GT, [0.75]);
const { action } = mostRecentAction(instanceID);
expect(action.group).toBe('*');
- expect(action.valueOf.condition0).toBe(1);
- expect(action.thresholdOf.condition0).toStrictEqual([0.75]);
- expect(action.metricOf.condition0).toBe('test.metric.1');
+ expect(action.reason).toContain('current value is 1');
+ expect(action.reason).toContain('threshold of 0.75');
+ expect(action.reason).toContain('test.metric.1');
});
test('fetches the index pattern dynamically', async () => {
await execute(Comparator.LT, [17], 'alternate');
@@ -271,12 +273,14 @@ describe('The metric threshold alert type', () => {
const instanceID = 'test-*';
await execute(Comparator.GT_OR_EQ, [1.0], [3.0]);
const { action } = mostRecentAction(instanceID);
- expect(action.valueOf.condition0).toBe(1);
- expect(action.valueOf.condition1).toBe(3.5);
- expect(action.thresholdOf.condition0).toStrictEqual([1.0]);
- expect(action.thresholdOf.condition1).toStrictEqual([3.0]);
- expect(action.metricOf.condition0).toBe('test.metric.1');
- expect(action.metricOf.condition1).toBe('test.metric.2');
+ const reasons = action.reason.split('\n');
+ expect(reasons.length).toBe(2);
+ expect(reasons[0]).toContain('test.metric.1');
+ expect(reasons[1]).toContain('test.metric.2');
+ expect(reasons[0]).toContain('current value is 1');
+ expect(reasons[1]).toContain('current value is 3.5');
+ expect(reasons[0]).toContain('threshold of 1');
+ expect(reasons[1]).toContain('threshold of 3');
});
});
describe('querying with the count aggregator', () => {
@@ -305,4 +309,32 @@ describe('The metric threshold alert type', () => {
expect(getState(instanceID).alertState).toBe(AlertStates.OK);
});
});
+ describe("querying a metric that hasn't reported data", () => {
+ const instanceID = 'test-*';
+ const execute = (alertOnNoData: boolean) =>
+ executor({
+ services,
+ params: {
+ criteria: [
+ {
+ ...baseCriterion,
+ comparator: Comparator.GT,
+ threshold: 1,
+ metric: 'test.metric.3',
+ },
+ ],
+ alertOnNoData,
+ },
+ });
+ test('sends a No Data alert when configured to do so', async () => {
+ await execute(true);
+ expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id);
+ expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA);
+ });
+ test('does not send a No Data alert when not configured to do so', async () => {
+ await execute(false);
+ expect(mostRecentAction(instanceID)).toBe(undefined);
+ expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA);
+ });
+ });
});
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
index 946f1c14bf593..bd77e5e2daf42 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
@@ -12,6 +12,13 @@ import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler';
import { getAllCompositeData } from '../../../utils/get_all_composite_data';
import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
import { MetricExpressionParams, Comparator, Aggregators, AlertStates } from './types';
+import {
+ buildErrorAlertReason,
+ buildFiredAlertReason,
+ buildNoDataAlertReason,
+ DOCUMENT_COUNT_I18N,
+ stateToAlertMessage,
+} from './messages';
import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server';
import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
import { getDateHistogramOffset } from '../../snapshot/query_helpers';
@@ -51,17 +58,19 @@ const getCurrentValueFromAggregations = (
const getParsedFilterQuery: (
filterQuery: string | undefined
-) => Record = filterQuery => {
+) => Record | Array> = filterQuery => {
if (!filterQuery) return {};
try {
return JSON.parse(filterQuery).bool;
} catch (e) {
- return {
- query_string: {
- query: filterQuery,
- analyze_wildcard: true,
+ return [
+ {
+ query_string: {
+ query: filterQuery,
+ analyze_wildcard: true,
+ },
},
- };
+ ];
}
};
@@ -159,8 +168,12 @@ export const getElasticsearchMetricQuery = (
return {
query: {
bool: {
- filter: [...rangeFilters, ...metricFieldFilters],
- ...parsedFilterQuery,
+ filter: [
+ ...rangeFilters,
+ ...metricFieldFilters,
+ ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []),
+ ],
+ ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}),
},
},
size: 0,
@@ -233,6 +246,7 @@ const getMetric: (
body: searchBody,
index,
});
+
return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) };
} catch (e) {
return { '*': undefined }; // Trigger an Error state
@@ -251,24 +265,14 @@ const comparatorMap = {
[Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
};
-const mapToConditionsLookup = (
- list: any[],
- mapFn: (value: any, index: number, array: any[]) => unknown
-) =>
- list
- .map(mapFn)
- .reduce(
- (result: Record, value, i) => ({ ...result, [`condition${i}`]: value }),
- {}
- );
-
export const createMetricThresholdExecutor = (alertUUID: string) =>
async function({ services, params }: AlertExecutorOptions) {
- const { criteria, groupBy, filterQuery, sourceId } = params as {
+ const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as {
criteria: MetricExpressionParams[];
groupBy: string | undefined;
filterQuery: string | undefined;
sourceId?: string;
+ alertOnNoData: boolean;
};
const alertResults = await Promise.all(
@@ -279,9 +283,11 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
const { threshold, comparator } = criterion;
const comparisonFunction = comparatorMap[comparator];
return mapValues(currentValues, value => ({
+ ...criterion,
+ metric: criterion.metric ?? DOCUMENT_COUNT_I18N,
+ currentValue: value,
shouldFire:
value !== undefined && value !== null && comparisonFunction(value, threshold),
- currentValue: value,
isNoData: value === null,
isError: value === undefined,
}));
@@ -299,23 +305,43 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
// whole alert is in a No Data/Error state
const isNoData = alertResults.some(result => result[group].isNoData);
const isError = alertResults.some(result => result[group].isError);
- if (shouldAlertFire) {
+
+ const nextState = isError
+ ? AlertStates.ERROR
+ : isNoData
+ ? AlertStates.NO_DATA
+ : shouldAlertFire
+ ? AlertStates.ALERT
+ : AlertStates.OK;
+
+ let reason;
+ if (nextState === AlertStates.ALERT) {
+ reason = alertResults.map(result => buildFiredAlertReason(result[group])).join('\n');
+ }
+ if (alertOnNoData) {
+ if (nextState === AlertStates.NO_DATA) {
+ reason = alertResults
+ .filter(result => result[group].isNoData)
+ .map(result => buildNoDataAlertReason(result[group]))
+ .join('\n');
+ } else if (nextState === AlertStates.ERROR) {
+ reason = alertResults
+ .filter(result => result[group].isError)
+ .map(result => buildErrorAlertReason(result[group].metric))
+ .join('\n');
+ }
+ }
+ if (reason) {
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
group,
- valueOf: mapToConditionsLookup(alertResults, result => result[group].currentValue),
- thresholdOf: mapToConditionsLookup(criteria, criterion => criterion.threshold),
- metricOf: mapToConditionsLookup(criteria, criterion => criterion.metric),
+ alertState: stateToAlertMessage[nextState],
+ reason,
});
}
+
// Future use: ability to fetch display current alert state
alertInstance.replaceState({
- alertState: isError
- ? AlertStates.ERROR
- : isNoData
- ? AlertStates.NO_DATA
- : shouldAlertFire
- ? AlertStates.ALERT
- : AlertStates.OK,
+ alertState: nextState,
});
}
};
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts
index 3415ae9873bfb..029491c1168cf 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts
@@ -55,27 +55,18 @@ export async function registerMetricThresholdAlertType(
}
);
- const valueOfActionVariableDescription = i18n.translate(
- 'xpack.infra.metrics.alerting.threshold.alerting.valueOfActionVariableDescription',
+ const alertStateActionVariableDescription = i18n.translate(
+ 'xpack.infra.metrics.alerting.threshold.alerting.alertStateActionVariableDescription',
{
- defaultMessage:
- 'Record of the current value of the watched metric; grouped by condition, i.e valueOf.condition0, valueOf.condition1, etc.',
- }
- );
-
- const thresholdOfActionVariableDescription = i18n.translate(
- 'xpack.infra.metrics.alerting.threshold.alerting.thresholdOfActionVariableDescription',
- {
- defaultMessage:
- 'Record of the alerting threshold; grouped by condition, i.e thresholdOf.condition0, thresholdOf.condition1, etc.',
+ defaultMessage: 'Current state of the alert',
}
);
- const metricOfActionVariableDescription = i18n.translate(
- 'xpack.infra.metrics.alerting.threshold.alerting.metricOfActionVariableDescription',
+ const reasonActionVariableDescription = i18n.translate(
+ 'xpack.infra.metrics.alerting.threshold.alerting.reasonActionVariableDescription',
{
defaultMessage:
- 'Record of the watched metric; grouped by condition, i.e metricOf.condition0, metricOf.condition1, etc.',
+ 'A description of why the alert is in this state, including which metrics have crossed which thresholds',
}
);
@@ -88,6 +79,7 @@ export async function registerMetricThresholdAlertType(
groupBy: schema.maybe(schema.string()),
filterQuery: schema.maybe(schema.string()),
sourceId: schema.string(),
+ alertOnNoData: schema.maybe(schema.boolean()),
}),
},
defaultActionGroupId: FIRED_ACTIONS.id,
@@ -96,9 +88,8 @@ export async function registerMetricThresholdAlertType(
actionVariables: {
context: [
{ name: 'group', description: groupActionVariableDescription },
- { name: 'valueOf', description: valueOfActionVariableDescription },
- { name: 'thresholdOf', description: thresholdOfActionVariableDescription },
- { name: 'metricOf', description: metricOfActionVariableDescription },
+ { name: 'alertState', description: alertStateActionVariableDescription },
+ { name: 'reason', description: reasonActionVariableDescription },
],
},
});
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts
index 66e0a363c8983..fa55f80e472de 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts
@@ -53,6 +53,14 @@ export const alternateMetricResponse = {
},
};
+export const emptyMetricResponse = {
+ aggregations: {
+ aggregatedIntervals: {
+ buckets: [],
+ },
+ },
+};
+
export const basicCompositeResponse = {
aggregations: {
groupings: {
diff --git a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts
index c5067480fb953..9bc1293799d3c 100644
--- a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts
@@ -14,6 +14,7 @@ export const DEFAULT_AGENT_CONFIG = {
status: AgentConfigStatus.Active,
datasources: [],
is_default: true,
+ monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>,
};
export const DEFAULT_AGENT_CONFIGS_PACKAGES = [DefaultPackages.system];
diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts
index a7d4e36d16f2a..bff799798ff6e 100644
--- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts
+++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts
@@ -3,11 +3,12 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { Datasource, NewDatasource, DatasourceInput } from '../types';
+import { Datasource, DatasourceInput } from '../types';
import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource';
describe('Ingest Manager - storedDatasourceToAgentDatasource', () => {
- const mockNewDatasource: NewDatasource = {
+ const mockDatasource: Datasource = {
+ id: 'some-uuid',
name: 'mock-datasource',
description: '',
config_id: '',
@@ -15,11 +16,6 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => {
output_id: '',
namespace: 'default',
inputs: [],
- };
-
- const mockDatasource: Datasource = {
- ...mockNewDatasource,
- id: 'some-uuid',
revision: 1,
};
@@ -107,17 +103,6 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => {
});
});
- it('uses name for id when id is not provided in case of new datasource', () => {
- expect(storedDatasourceToAgentDatasource(mockNewDatasource)).toEqual({
- id: 'mock-datasource',
- name: 'mock-datasource',
- namespace: 'default',
- enabled: true,
- use_output: 'default',
- inputs: [],
- });
- });
-
it('returns agent datasource config with flattened input and package stream', () => {
expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({
id: 'some-uuid',
diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts
index 5deb33ccf10f1..620b663451ea3 100644
--- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts
+++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts
@@ -3,16 +3,16 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { Datasource, NewDatasource, FullAgentConfigDatasource } from '../types';
+import { Datasource, FullAgentConfigDatasource } from '../types';
import { DEFAULT_OUTPUT } from '../constants';
export const storedDatasourceToAgentDatasource = (
- datasource: Datasource | NewDatasource
+ datasource: Datasource
): FullAgentConfigDatasource => {
- const { name, namespace, enabled, package: pkg, inputs } = datasource;
+ const { id, name, namespace, enabled, package: pkg, inputs } = datasource;
const fullDatasource: FullAgentConfigDatasource = {
- id: 'id' in datasource ? datasource.id : name,
+ id: id || name,
name,
namespace,
enabled,
diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts
index cb290e61b17e5..a977a1a66e059 100644
--- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts
+++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts
@@ -11,6 +11,7 @@ describe('Ingest Manager - packageToConfig', () => {
name: 'mock-package',
title: 'Mock package',
version: '0.0.0',
+ latestVersion: '0.0.0',
description: 'description',
type: 'mock',
categories: [],
diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts
index fcd3955f3a32f..e3ca7635fdb40 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts
@@ -60,6 +60,11 @@ export interface AgentEvent {
export interface AgentEventSOAttributes extends AgentEvent, SavedObjectAttributes {}
+type MetadataValue = string | AgentMetadata;
+
+export interface AgentMetadata {
+ [x: string]: MetadataValue;
+}
interface AgentBase {
type: AgentType;
active: boolean;
@@ -72,19 +77,17 @@ interface AgentBase {
config_revision?: number | null;
config_newest_revision?: number;
last_checkin?: string;
+ user_provided_metadata: AgentMetadata;
+ local_metadata: AgentMetadata;
}
export interface Agent extends AgentBase {
id: string;
current_error_events: AgentEvent[];
- user_provided_metadata: Record;
- local_metadata: Record;
access_api_key?: string;
status?: string;
}
export interface AgentSOAttributes extends AgentBase, SavedObjectAttributes {
- user_provided_metadata: string;
- local_metadata: string;
current_error_events?: string;
}
diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
index 2372caee512af..96121251b133e 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
@@ -3,8 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
-import { SavedObjectAttributes } from 'src/core/public';
import {
Datasource,
DatasourcePackage,
@@ -23,9 +21,10 @@ export interface NewAgentConfig {
namespace?: string;
description?: string;
is_default?: boolean;
+ monitoring_enabled?: Array<'logs' | 'metrics'>;
}
-export interface AgentConfig extends NewAgentConfig, SavedObjectAttributes {
+export interface AgentConfig extends NewAgentConfig {
id: string;
status: AgentConfigStatus;
datasources: string[] | Datasource[];
@@ -60,4 +59,12 @@ export interface FullAgentConfig {
};
datasources: FullAgentConfigDatasource[];
revision?: number;
+ settings?: {
+ monitoring: {
+ use_output?: string;
+ enabled: boolean;
+ metrics: boolean;
+ logs: boolean;
+ };
+ };
}
diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts
index 885e0a9316d79..ca61a93d9be93 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts
@@ -17,22 +17,29 @@ export interface DatasourceConfigRecordEntry {
export type DatasourceConfigRecord = Record;
-export interface DatasourceInputStream {
+export interface NewDatasourceInputStream {
id: string;
enabled: boolean;
dataset: string;
processors?: string[];
config?: DatasourceConfigRecord;
vars?: DatasourceConfigRecord;
+}
+
+export interface DatasourceInputStream extends NewDatasourceInputStream {
agent_stream?: any;
}
-export interface DatasourceInput {
+export interface NewDatasourceInput {
type: string;
enabled: boolean;
processors?: string[];
config?: DatasourceConfigRecord;
vars?: DatasourceConfigRecord;
+ streams: NewDatasourceInputStream[];
+}
+
+export interface DatasourceInput extends Omit {
streams: DatasourceInputStream[];
}
@@ -44,10 +51,11 @@ export interface NewDatasource {
enabled: boolean;
package?: DatasourcePackage;
output_id: string;
- inputs: DatasourceInput[];
+ inputs: NewDatasourceInput[];
}
-export type Datasource = NewDatasource & {
+export interface Datasource extends Omit {
id: string;
+ inputs: DatasourceInput[];
revision: number;
-};
+}
diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts
index fb6bf235d1e26..f8779a879a049 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts
@@ -97,6 +97,7 @@ export interface RegistryStream {
description?: string;
enabled?: boolean;
vars?: RegistryVarsEntry[];
+ template?: string;
}
export type RequirementVersion = string;
@@ -203,6 +204,8 @@ export interface RegistryVarsEntry {
// internal until we need them
interface PackageAdditions {
title: string;
+ latestVersion: string;
+ installedVersion?: string;
assets: AssetsGroupedByServiceByType;
}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx
new file mode 100644
index 0000000000000..1e7a14e350229
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import {
+ EuiButtonEmpty,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiFlyoutFooter,
+ EuiLink,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+interface Props {
+ onClose: () => void;
+}
+
+export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ forumLink: (
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
index 0f3ddee29fa44..5a06a9a879441 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
@@ -3,35 +3,45 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import React from 'react';
+import React, { useState } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiText } from '@elastic/eui';
+import { EuiText, EuiLink } from '@elastic/eui';
+import { AlphaFlyout } from './alpha_flyout';
const Message = styled(EuiText).attrs(props => ({
color: 'subdued',
textAlign: 'center',
+ size: 's',
}))`
padding: ${props => props.theme.eui.paddingSizes.m};
`;
-export const AlphaMessaging: React.FC<{}> = () => (
-
-
-
-
+export const AlphaMessaging: React.FC<{}> = () => {
+ const [isAlphaFlyoutOpen, setIsAlphaFlyoutOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {' – '}
-
- {' – '}
-
-
-
-
-);
+ />{' '}
+ setIsAlphaFlyoutOpen(true)}>
+ View more details.
+
+
+
+ {isAlphaFlyoutOpen && setIsAlphaFlyoutOpen(false)} />}
+ >
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx
index 4429b9d8e6b82..1c9bd9107515d 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx
@@ -92,7 +92,6 @@ function useSuggestions(fieldPrefix: string, search: string) {
const res = (await data.indexPatterns.getFieldsForWildcard({
pattern: INDEX_NAME,
})) as IFieldType[];
-
if (!data || !data.autocomplete) {
throw new Error('Missing data plugin');
}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx
index 92146e9ee5679..9863463e68a01 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx
@@ -209,7 +209,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => {
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts
index 0d19ecd0cb735..e2fc190e158f9 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts
@@ -5,12 +5,18 @@
*/
import { sendRequest, useRequest } from './use_request';
import { datasourceRouteService } from '../../services';
-import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types';
+import {
+ CreateDatasourceRequest,
+ CreateDatasourceResponse,
+ UpdateDatasourceRequest,
+ UpdateDatasourceResponse,
+} from '../../types';
import {
DeleteDatasourcesRequest,
DeleteDatasourcesResponse,
GetDatasourcesRequest,
GetDatasourcesResponse,
+ GetOneDatasourceResponse,
} from '../../../../../common/types/rest_spec';
export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => {
@@ -21,6 +27,17 @@ export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => {
});
};
+export const sendUpdateDatasource = (
+ datasourceId: string,
+ body: UpdateDatasourceRequest['body']
+) => {
+ return sendRequest({
+ path: datasourceRouteService.getUpdatePath(datasourceId),
+ method: 'put',
+ body: JSON.stringify(body),
+ });
+};
+
export const sendDeleteDatasource = (body: DeleteDatasourcesRequest['body']) => {
return sendRequest({
path: datasourceRouteService.getDeletePath(),
@@ -36,3 +53,10 @@ export function useGetDatasources(query: GetDatasourcesRequest['query']) {
query,
});
}
+
+export const sendGetOneDatasource = (datasourceId: string) => {
+ return sendRequest({
+ path: datasourceRouteService.getInfoPath(datasourceId),
+ method: 'get',
+ });
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
index 10245e73520f7..4a9cfe02b74ac 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
@@ -96,12 +96,28 @@ export const DefaultLayout: React.FunctionComponent = ({ section, childre
- setIsSettingsFlyoutOpen(true)}>
-
-
+
+
+
+
+
+
+
+ setIsSettingsFlyoutOpen(true)}>
+
+
+
+
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx
index 0d53ca34a1fef..92c44d86e47c6 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx
@@ -18,6 +18,7 @@ import {
EuiText,
EuiComboBox,
EuiIconTip,
+ EuiCheckboxGroup,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
@@ -30,7 +31,7 @@ interface ValidationResults {
const StyledEuiAccordion = styled(EuiAccordion)`
.ingest-active-button {
- color: ${props => props.theme.eui.euiColorPrimary}};
+ color: ${props => props.theme.eui.euiColorPrimary};
}
`;
@@ -244,6 +245,68 @@ export const AgentConfigForm: React.FunctionComponent = ({
)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ acc[key] = true;
+ return acc;
+ },
+ { logs: false, metrics: false }
+ )}
+ onChange={id => {
+ if (id !== 'logs' && id !== 'metrics') {
+ return;
+ }
+
+ const hasLogs =
+ agentConfig.monitoring_enabled && agentConfig.monitoring_enabled.indexOf(id) >= 0;
+
+ const previousValues = agentConfig.monitoring_enabled || [];
+ updateAgentConfig({
+ monitoring_enabled: hasLogs
+ ? previousValues.filter(type => type !== id)
+ : [...previousValues, id],
+ });
+ }}
+ />
+
+
);
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx
index 39d882f7fdf65..f1e3fea6a0742 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx
@@ -39,17 +39,29 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{
-
+ {from === 'edit' ? (
+
+ ) : (
+
+ )}
- {from === 'config' ? (
+ {from === 'edit' ? (
+
+ ) : from === 'config' ? (
- {agentConfig && from === 'config' ? (
+ {agentConfig && (from === 'config' || from === 'edit') ? (
{
const updatePackageInfo = (updatedPackageInfo: PackageInfo | undefined) => {
if (updatedPackageInfo) {
setPackageInfo(updatedPackageInfo);
+ setFormState('VALID');
} else {
setFormState('INVALID');
setPackageInfo(undefined);
@@ -152,9 +153,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
const cancelUrl = from === 'config' ? CONFIG_URL : PACKAGE_URL;
// Save datasource
- const [formState, setFormState] = useState<
- 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED'
- >('INVALID');
+ const [formState, setFormState] = useState('INVALID');
const saveDatasource = async () => {
setFormState('LOADING');
const result = await sendCreateDatasource(datasource);
@@ -174,6 +173,23 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
const { error } = await saveDatasource();
if (!error) {
history.push(`${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}`);
+ notifications.toasts.addSuccess({
+ title: i18n.translate('xpack.ingestManager.createDatasource.addedNotificationTitle', {
+ defaultMessage: `Successfully added '{datasourceName}'`,
+ values: {
+ datasourceName: datasource.name,
+ },
+ }),
+ text:
+ agentCount && agentConfig
+ ? i18n.translate('xpack.ingestManager.createDatasource.addedNotificationMessage', {
+ defaultMessage: `Fleet will deploy updates to all agents that use the '{agentConfigName}' configuration`,
+ values: {
+ agentConfigName: agentConfig.name,
+ },
+ })
+ : undefined,
+ });
} else {
notifications.toasts.addError(error, {
title: 'Error',
@@ -229,6 +245,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
packageInfo={packageInfo}
datasource={datasource}
updateDatasource={updateDatasource}
+ validationResults={validationResults!}
/>
) : null,
},
@@ -240,7 +257,6 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
children:
agentConfig && packageInfo ? (
) => void;
validationResults: DatasourceValidationResults;
submitAttempted: boolean;
-}> = ({
- agentConfig,
- packageInfo,
- datasource,
- updateDatasource,
- validationResults,
- submitAttempted,
-}) => {
- // Form show/hide states
-
+}> = ({ packageInfo, datasource, updateDatasource, validationResults, submitAttempted }) => {
const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
- // Update datasource's package and config info
- useEffect(() => {
- const dsPackage = datasource.package;
- const currentPkgKey = dsPackage ? `${dsPackage.name}-${dsPackage.version}` : '';
- const pkgKey = `${packageInfo.name}-${packageInfo.version}`;
-
- // If package has changed, create shell datasource with input&stream values based on package info
- if (currentPkgKey !== pkgKey) {
- // Existing datasources on the agent config using the package name, retrieve highest number appended to datasource name
- const dsPackageNamePattern = new RegExp(`${packageInfo.name}-(\\d+)`);
- const dsWithMatchingNames = (agentConfig.datasources as Datasource[])
- .filter(ds => Boolean(ds.name.match(dsPackageNamePattern)))
- .map(ds => parseInt(ds.name.match(dsPackageNamePattern)![1], 10))
- .sort();
-
- updateDatasource({
- name: `${packageInfo.name}-${
- dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1
- }`,
- package: {
- name: packageInfo.name,
- title: packageInfo.title,
- version: packageInfo.version,
- },
- inputs: packageToConfigDatasourceInputs(packageInfo),
- });
- }
-
- // If agent config has changed, update datasource's config ID and namespace
- if (datasource.config_id !== agentConfig.id) {
- updateDatasource({
- config_id: agentConfig.id,
- namespace: agentConfig.namespace,
- });
- }
- }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]);
-
- // Step B, configure inputs (and their streams)
+ // Configure inputs (and their streams)
// Assume packages only export one datasource for now
const renderConfigureInputs = () =>
packageInfo.datasources &&
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx
index 792389381eaf0..c4d602c2c2081 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx
@@ -17,13 +17,16 @@ import {
} from '@elastic/eui';
import { AgentConfig, PackageInfo, Datasource, NewDatasource } from '../../../types';
import { packageToConfigDatasourceInputs } from '../../../services';
+import { Loading } from '../../../components';
+import { DatasourceValidationResults } from './services';
export const StepDefineDatasource: React.FunctionComponent<{
agentConfig: AgentConfig;
packageInfo: PackageInfo;
datasource: NewDatasource;
updateDatasource: (fields: Partial) => void;
-}> = ({ agentConfig, packageInfo, datasource, updateDatasource }) => {
+ validationResults: DatasourceValidationResults;
+}> = ({ agentConfig, packageInfo, datasource, updateDatasource, validationResults }) => {
// Form show/hide states
const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState(false);
@@ -64,11 +67,13 @@ export const StepDefineDatasource: React.FunctionComponent<{
}
}, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]);
- return (
+ return validationResults ? (
<>
}
+ isInvalid={!!validationResults.description}
+ error={validationResults.description}
>
) : null}
>
+ ) : (
+
);
};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts
index 85cc758fc4c46..10b30a5696d83 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts
@@ -4,4 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export type CreateDatasourceFrom = 'package' | 'config';
+export type CreateDatasourceFrom = 'package' | 'config' | 'edit';
+export type DatasourceFormState = 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx
index 1eee9f6b0c346..a0418c5f256c4 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx
@@ -19,7 +19,7 @@ import {
import { AgentConfig, Datasource } from '../../../../../types';
import { TableRowActions } from '../../../components/table_row_actions';
import { DangerEuiContextMenuItem } from '../../../components/danger_eui_context_menu_item';
-import { useCapabilities } from '../../../../../hooks';
+import { useCapabilities, useLink } from '../../../../../hooks';
import { useAgentConfigLink } from '../../hooks/use_details_uri';
import { DatasourceDeleteProvider } from '../../../components/datasource_delete_provider';
import { useConfigRefresh } from '../../hooks/use_config';
@@ -56,6 +56,7 @@ export const DatasourcesTable: React.FunctionComponent = ({
}) => {
const hasWriteCapabilities = useCapabilities().write;
const addDatasourceLink = useAgentConfigLink('add-datasource', { configId: config.id });
+ const editDatasourceLink = useLink(`/configs/${config.id}/edit-datasource`);
const refreshConfig = useConfigRefresh();
// With the datasources provided on input, generate the list of datasources
@@ -201,22 +202,21 @@ export const DatasourcesTable: React.FunctionComponent = ({
{}}
+ // key="datasourceView"
+ // >
+ //
+ // ,
{}}
- key="datasourceView"
- >
-
- ,
- // FIXME: implement Edit datasource action
- {}}
+ href={`${editDatasourceLink}/${datasource.id}`}
key="datasourceEdit"
>
= ({
/>
,
// FIXME: implement Copy datasource action
- {}} key="datasourceCopy">
-
- ,
+ // {}} key="datasourceCopy">
+ //
+ // ,
{deleteDatasourcePrompt => {
return (
@@ -256,7 +256,7 @@ export const DatasourcesTable: React.FunctionComponent = ({
],
},
],
- [config, hasWriteCapabilities, refreshConfig]
+ [config, editDatasourceLink, hasWriteCapabilities, refreshConfig]
);
return (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx
new file mode 100644
index 0000000000000..d4c39f21a1ea6
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx
@@ -0,0 +1,323 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useState, useEffect } from 'react';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiButtonEmpty,
+ EuiButton,
+ EuiSteps,
+ EuiBottomBar,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+} from '@elastic/eui';
+import { AGENT_CONFIG_DETAILS_PATH } from '../../../constants';
+import { AgentConfig, PackageInfo, NewDatasource } from '../../../types';
+import {
+ useLink,
+ useCore,
+ useConfig,
+ sendUpdateDatasource,
+ sendGetAgentStatus,
+ sendGetOneAgentConfig,
+ sendGetOneDatasource,
+ sendGetPackageInfoByKey,
+} from '../../../hooks';
+import { Loading, Error } from '../../../components';
+import {
+ CreateDatasourcePageLayout,
+ ConfirmCreateDatasourceModal,
+} from '../create_datasource_page/components';
+import {
+ DatasourceValidationResults,
+ validateDatasource,
+ validationHasErrors,
+} from '../create_datasource_page/services';
+import { DatasourceFormState, CreateDatasourceFrom } from '../create_datasource_page/types';
+import { StepConfigureDatasource } from '../create_datasource_page/step_configure_datasource';
+import { StepDefineDatasource } from '../create_datasource_page/step_define_datasource';
+
+export const EditDatasourcePage: React.FunctionComponent = () => {
+ const { notifications } = useCore();
+ const {
+ fleet: { enabled: isFleetEnabled },
+ } = useConfig();
+ const {
+ params: { configId, datasourceId },
+ } = useRouteMatch();
+ const history = useHistory();
+
+ // Agent config, package info, and datasource states
+ const [isLoadingData, setIsLoadingData] = useState(true);
+ const [loadingError, setLoadingError] = useState();
+ const [agentConfig, setAgentConfig] = useState();
+ const [packageInfo, setPackageInfo] = useState();
+ const [datasource, setDatasource] = useState({
+ name: '',
+ description: '',
+ config_id: '',
+ enabled: true,
+ output_id: '',
+ inputs: [],
+ });
+
+ // Retrieve agent config, package, and datasource info
+ useEffect(() => {
+ const getData = async () => {
+ setIsLoadingData(true);
+ setLoadingError(undefined);
+ try {
+ const [{ data: agentConfigData }, { data: datasourceData }] = await Promise.all([
+ sendGetOneAgentConfig(configId),
+ sendGetOneDatasource(datasourceId),
+ ]);
+ if (agentConfigData?.item) {
+ setAgentConfig(agentConfigData.item);
+ }
+ if (datasourceData?.item) {
+ const { id, revision, inputs, ...restOfDatasource } = datasourceData.item;
+ // Remove `agent_stream` from all stream info, we assign this after saving
+ const newDatasource = {
+ ...restOfDatasource,
+ inputs: inputs.map(input => {
+ const { streams, ...restOfInput } = input;
+ return {
+ ...restOfInput,
+ streams: streams.map(stream => {
+ const { agent_stream, ...restOfStream } = stream;
+ return restOfStream;
+ }),
+ };
+ }),
+ };
+ setDatasource(newDatasource);
+ if (datasourceData.item.package) {
+ const { data: packageData } = await sendGetPackageInfoByKey(
+ `${datasourceData.item.package.name}-${datasourceData.item.package.version}`
+ );
+ if (packageData?.response) {
+ setPackageInfo(packageData.response);
+ setValidationResults(validateDatasource(newDatasource, packageData.response));
+ setFormState('VALID');
+ }
+ }
+ }
+ } catch (e) {
+ setLoadingError(e);
+ }
+ setIsLoadingData(false);
+ };
+ getData();
+ }, [configId, datasourceId]);
+
+ // Retrieve agent count
+ const [agentCount, setAgentCount] = useState(0);
+ useEffect(() => {
+ const getAgentCount = async () => {
+ const { data } = await sendGetAgentStatus({ configId });
+ if (data?.results.total) {
+ setAgentCount(data.results.total);
+ }
+ };
+
+ if (isFleetEnabled) {
+ getAgentCount();
+ }
+ }, [configId, isFleetEnabled]);
+
+ // Datasource validation state
+ const [validationResults, setValidationResults] = useState();
+ const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
+
+ // Update datasource method
+ const updateDatasource = (updatedFields: Partial) => {
+ const newDatasource = {
+ ...datasource,
+ ...updatedFields,
+ };
+ setDatasource(newDatasource);
+
+ // eslint-disable-next-line no-console
+ console.debug('Datasource updated', newDatasource);
+ const newValidationResults = updateDatasourceValidation(newDatasource);
+ const hasValidationErrors = newValidationResults
+ ? validationHasErrors(newValidationResults)
+ : false;
+ if (!hasValidationErrors) {
+ setFormState('VALID');
+ }
+ };
+
+ const updateDatasourceValidation = (newDatasource?: NewDatasource) => {
+ if (packageInfo) {
+ const newValidationResult = validateDatasource(newDatasource || datasource, packageInfo);
+ setValidationResults(newValidationResult);
+ // eslint-disable-next-line no-console
+ console.debug('Datasource validation results', newValidationResult);
+
+ return newValidationResult;
+ }
+ };
+
+ // Cancel url
+ const CONFIG_URL = useLink(`${AGENT_CONFIG_DETAILS_PATH}${configId}`);
+ const cancelUrl = CONFIG_URL;
+
+ // Save datasource
+ const [formState, setFormState] = useState('INVALID');
+ const saveDatasource = async () => {
+ setFormState('LOADING');
+ const result = await sendUpdateDatasource(datasourceId, datasource);
+ setFormState('SUBMITTED');
+ return result;
+ };
+
+ const onSubmit = async () => {
+ if (formState === 'VALID' && hasErrors) {
+ setFormState('INVALID');
+ return;
+ }
+ if (agentCount !== 0 && formState !== 'CONFIRM') {
+ setFormState('CONFIRM');
+ return;
+ }
+ const { error } = await saveDatasource();
+ if (!error) {
+ history.push(`${AGENT_CONFIG_DETAILS_PATH}${configId}`);
+ notifications.toasts.addSuccess({
+ title: i18n.translate('xpack.ingestManager.editDatasource.updatedNotificationTitle', {
+ defaultMessage: `Successfully updated '{datasourceName}'`,
+ values: {
+ datasourceName: datasource.name,
+ },
+ }),
+ text:
+ agentCount && agentConfig
+ ? i18n.translate('xpack.ingestManager.editDatasource.updatedNotificationMessage', {
+ defaultMessage: `Fleet will deploy updates to all agents that use the '{agentConfigName}' configuration`,
+ values: {
+ agentConfigName: agentConfig.name,
+ },
+ })
+ : undefined,
+ });
+ } else {
+ notifications.toasts.addError(error, {
+ title: 'Error',
+ });
+ setFormState('VALID');
+ }
+ };
+
+ const layoutProps = {
+ from: 'edit' as CreateDatasourceFrom,
+ cancelUrl,
+ agentConfig,
+ packageInfo,
+ };
+
+ return (
+
+ {isLoadingData ? (
+
+ ) : loadingError || !agentConfig || !packageInfo ? (
+
+ }
+ error={
+ loadingError ||
+ i18n.translate('xpack.ingestManager.editDatasource.errorLoadingDataMessage', {
+ defaultMessage: 'There was an error loading this data source information',
+ })
+ }
+ />
+ ) : (
+ <>
+ {formState === 'CONFIRM' && (
+ setFormState('VALID')}
+ />
+ )}
+
+ ),
+ },
+ {
+ title: i18n.translate(
+ 'xpack.ingestManager.editDatasource.stepConfgiureDatasourceTitle',
+ {
+ defaultMessage: 'Select the data you want to collect',
+ }
+ ),
+ children: (
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx
index 71ada155373bf..ef88aa5d17f1e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx
@@ -8,10 +8,14 @@ import { HashRouter as Router, Switch, Route } from 'react-router-dom';
import { AgentConfigListPage } from './list_page';
import { AgentConfigDetailsPage } from './details_page';
import { CreateDatasourcePage } from './create_datasource_page';
+import { EditDatasourcePage } from './edit_datasource_page';
export const AgentConfigApp: React.FunctionComponent = () => (
+
+
+
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
index 1fe116ef36090..9f582e7e2fbe6 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
@@ -34,6 +34,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos
description: '',
namespace: '',
is_default: undefined,
+ monitoring_enabled: ['logs', 'metrics'],
});
const [isLoading, setIsLoading] = useState(false);
const [withSysMonitoring, setWithSysMonitoring] = useState(true);
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
index 1ea162252c741..3dcc19bc4a5ae 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
@@ -36,13 +36,11 @@ import {
useConfig,
useUrlParams,
} from '../../../hooks';
-import { AgentConfigDeleteProvider } from '../components';
import { CreateAgentConfigFlyout } from './components';
import { SearchBar } from '../../../components/search_bar';
import { LinkedAgentCount } from '../components';
import { useAgentConfigLink } from '../details_page/hooks/use_details_uri';
import { TableRowActions } from '../components/table_row_actions';
-import { DangerEuiContextMenuItem } from '../components/danger_eui_context_menu_item';
const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({
overflow: 'hidden',
@@ -108,30 +106,12 @@ const ConfigRowActions = memo<{ config: AgentConfig; onDelete: () => void }>(
defaultMessage="Create data source"
/>
,
-
-
-
- ,
-
-
- {deleteAgentConfigsPrompt => {
- return (
- deleteAgentConfigsPrompt([config.id], onDelete)}
- >
-
-
- );
- }}
- ,
+ //
+ //
+ // ,
]}
/>
);
@@ -156,7 +136,6 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => {
: urlParams.kuery ?? ''
);
const { pagination, pageSizeOptions, setPagination } = usePagination();
- const [selectedAgentConfigs, setSelectedAgentConfigs] = useState([]);
const history = useHistory();
const isCreateAgentConfigFlyoutOpen = 'create' in urlParams;
const setIsCreateAgentConfigFlyoutOpen = useCallback(
@@ -321,34 +300,6 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => {
/>
) : null}
- {selectedAgentConfigs.length ? (
-
-
- {deleteAgentConfigsPrompt => (
- {
- deleteAgentConfigsPrompt(
- selectedAgentConfigs.map(agentConfig => agentConfig.id),
- () => {
- sendRequest();
- setSelectedAgentConfigs([]);
- }
- );
- }}
- >
-
-
- )}
-
-
- ) : null}
= () => {
items={agentConfigData ? agentConfigData.items : []}
itemId="id"
columns={columns}
- isSelectable={true}
- selection={{
- selectable: (agentConfig: AgentConfig) => !agentConfig.is_default,
- onSelectionChange: (newSelectedAgentConfigs: AgentConfig[]) => {
- setSelectedAgentConfigs(newSelectedAgentConfigs);
- },
- }}
+ isSelectable={false}
pagination={{
pageIndex: pagination.currentPage - 1,
pageSize: pagination.pageSize,
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx
new file mode 100644
index 0000000000000..64223efefaab8
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { EuiIcon } from '@elastic/eui';
+import React from 'react';
+import styled from 'styled-components';
+
+export const StyledAlert = styled(EuiIcon)`
+ color: ${props => props.theme.eui.euiColorWarning};
+ padding: 0 5px;
+`;
+
+export const UpdateIcon = () => ;
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx
index 8ad081cbbabe4..ab7e87b3ad06c 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx
@@ -30,9 +30,15 @@ export function PackageCard({
showInstalledBadge,
status,
icons,
+ ...restProps
}: PackageCardProps) {
const { toDetailView } = useLinks();
- const url = toDetailView({ name, version });
+ let urlVersion = version;
+ // if this is an installed package, link to the version installed
+ if ('savedObject' in restProps) {
+ urlVersion = restProps.savedObject.attributes.version || version;
+ }
+ const url = toDetailView({ name, version: urlVersion });
return (
;
+type InstallPackageProps = Pick & {
+ fromUpdate?: boolean;
+};
+type SetPackageInstallStatusProps = Pick & PackageInstallItem;
function usePackageInstall({ notifications }: { notifications: NotificationsStart }) {
+ const { toDetailView } = useLinks();
const [packages, setPackage] = useState({});
const setPackageInstallStatus = useCallback(
- ({ name, status }: { name: PackageInfo['name']; status: InstallStatus }) => {
+ ({ name, status, version }: SetPackageInstallStatusProps) => {
+ const packageProps: PackageInstallItem = {
+ status,
+ version,
+ };
setPackage((prev: PackagesInstall) => ({
...prev,
- [name]: { status },
+ [name]: packageProps,
}));
},
[]
);
+ const getPackageInstallStatus = useCallback(
+ (pkg: string): PackageInstallItem => {
+ return packages[pkg];
+ },
+ [packages]
+ );
+
const installPackage = useCallback(
- async ({ name, version, title }: InstallPackageProps) => {
- setPackageInstallStatus({ name, status: InstallStatus.installing });
+ async ({ name, version, title, fromUpdate = false }: InstallPackageProps) => {
+ const currStatus = getPackageInstallStatus(name);
+ const newStatus = { ...currStatus, name, status: InstallStatus.installing };
+ setPackageInstallStatus(newStatus);
const pkgkey = `${name}-${version}`;
const res = await sendInstallPackage(pkgkey);
if (res.error) {
- setPackageInstallStatus({ name, status: InstallStatus.notInstalled });
+ if (fromUpdate) {
+ // if there is an error during update, set it back to the previous version
+ // as handling of bad update is not implemented yet
+ setPackageInstallStatus({ ...currStatus, name });
+ } else {
+ setPackageInstallStatus({ name, status: InstallStatus.notInstalled, version });
+ }
notifications.toasts.addWarning({
title: toMountPoint(
{
- return packages[pkg].status;
- },
- [packages]
+ [getPackageInstallStatus, notifications.toasts, setPackageInstallStatus, toDetailView]
);
const uninstallPackage = useCallback(
async ({ name, version, title }: Pick) => {
- setPackageInstallStatus({ name, status: InstallStatus.uninstalling });
+ setPackageInstallStatus({ name, status: InstallStatus.uninstalling, version });
const pkgkey = `${name}-${version}`;
const res = await sendRemovePackage(pkgkey);
if (res.error) {
- setPackageInstallStatus({ name, status: InstallStatus.installed });
+ setPackageInstallStatus({ name, status: InstallStatus.installed, version });
notifications.toasts.addWarning({
title: toMountPoint(
;
export function ContentPanel(props: ContentPanelProps) {
- const { panel, name, version, assets, title, removable } = props;
+ const { panel, name, version, assets, title, removable, latestVersion } = props;
switch (panel) {
case 'settings':
return (
@@ -60,6 +60,7 @@ export function ContentPanel(props: ContentPanelProps) {
assets={assets}
title={title}
removable={removable}
+ latestVersion={latestVersion}
/>
);
case 'data-sources':
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx
index fa3245aec02c5..c82b7ed2297a7 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx
@@ -20,7 +20,7 @@ export const DataSourcesPanel = ({ name, version }: DataSourcesPanelProps) => {
const packageInstallStatus = getPackageInstallStatus(name);
// if they arrive at this page and the package is not installed, send them to overview
// this happens if they arrive with a direct url or they uninstall while on this tab
- if (packageInstallStatus !== InstallStatus.installed)
+ if (packageInstallStatus.status !== InstallStatus.installed)
return (
props.theme.eui.euiSizeM};
`;
-const StyledVersion = styled(Version)`
- font-size: ${props => props.theme.eui.euiFontSizeS};
- color: ${props => props.theme.eui.euiColorDarkShade};
-`;
-
type HeaderProps = PackageInfo & { iconType?: IconType };
export function Header(props: HeaderProps) {
- const { iconType, name, title, version } = props;
+ const { iconType, name, title, version, installedVersion, latestVersion } = props;
const hasWriteCapabilites = useCapabilities().write;
const { toListView } = useLinks();
const ADD_DATASOURCE_URI = useLink(`${EPM_PATH}/${name}-${version}/add-datasource`);
-
+ const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false;
return (
@@ -59,7 +54,11 @@ export function Header(props: HeaderProps) {
{title}
-
+
+
+ {version} {updateAvailable && }
+
+
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx
index 3239d7b90e3c3..1f3eb2cc9362e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx
@@ -32,11 +32,12 @@ export function Detail() {
const packageInfo = response.data?.response;
const title = packageInfo?.title;
const name = packageInfo?.name;
+ const installedVersion = packageInfo?.installedVersion;
const status: InstallStatus = packageInfo?.status as any;
// track install status state
if (name) {
- setPackageInstallStatus({ name, status });
+ setPackageInstallStatus({ name, status, version: installedVersion || null });
}
if (packageInfo) {
setInfo({ ...packageInfo, title: title || '' });
@@ -64,7 +65,6 @@ type LayoutProps = PackageInfo & Pick & Pick
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx
index cbbf1ce53c4ea..cdad67fd87548 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx
@@ -13,19 +13,21 @@ import { ConfirmPackageUninstall } from './confirm_package_uninstall';
import { ConfirmPackageInstall } from './confirm_package_install';
type InstallationButtonProps = Pick & {
- disabled: boolean;
+ disabled?: boolean;
+ isUpdate?: boolean;
};
export function InstallationButton(props: InstallationButtonProps) {
- const { assets, name, title, version, disabled = true } = props;
+ const { assets, name, title, version, disabled = true, isUpdate = false } = props;
const hasWriteCapabilites = useCapabilities().write;
const installPackage = useInstallPackage();
const uninstallPackage = useUninstallPackage();
const getPackageInstallStatus = useGetPackageInstallStatus();
- const installationStatus = getPackageInstallStatus(name);
+ const { status: installationStatus } = getPackageInstallStatus(name);
const isInstalling = installationStatus === InstallStatus.installing;
const isRemoving = installationStatus === InstallStatus.uninstalling;
const isInstalled = installationStatus === InstallStatus.installed;
+ const showUninstallButton = isInstalled || isRemoving;
const [isModalVisible, setModalVisible] = useState(false);
const toggleModal = useCallback(() => {
setModalVisible(!isModalVisible);
@@ -36,6 +38,10 @@ export function InstallationButton(props: InstallationButtonProps) {
toggleModal();
}, [installPackage, name, title, toggleModal, version]);
+ const handleClickUpdate = useCallback(() => {
+ installPackage({ name, version, title, fromUpdate: true });
+ }, [installPackage, name, title, version]);
+
const handleClickUninstall = useCallback(() => {
uninstallPackage({ name, version, title });
toggleModal();
@@ -78,6 +84,15 @@ export function InstallationButton(props: InstallationButtonProps) {
);
+ const updateButton = (
+
+
+
+ );
+
const uninstallButton = (
- {isInstalled || isRemoving ? uninstallButton : installButton}
+ {isUpdate ? updateButton : showUninstallButton ? uninstallButton : installButton}
{isModalVisible && (isInstalled ? uninstallModal : installModal)}
) : null;
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx
index 3589a1a9444e1..4d4dba2a64e5a 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx
@@ -8,11 +8,22 @@ import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
+import styled from 'styled-components';
import { InstallStatus, PackageInfo } from '../../../../types';
import { useGetDatasources } from '../../../../hooks';
import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../constants';
import { useGetPackageInstallStatus } from '../../hooks';
import { InstallationButton } from './installation_button';
+import { UpdateIcon } from '../../components/icons';
+
+const SettingsTitleCell = styled.td`
+ padding-right: ${props => props.theme.eui.spacerSizes.xl};
+ padding-bottom: ${props => props.theme.eui.spacerSizes.m};
+`;
+
+const UpdatesAvailableMsgContainer = styled.span`
+ padding-left: ${props => props.theme.eui.spacerSizes.s};
+`;
const NoteLabel = () => (
(
defaultMessage="Note:"
/>
);
+const UpdatesAvailableMsg = () => (
+
+
+
+
+);
+
export const SettingsPanel = (
- props: Pick
+ props: Pick
) => {
+ const { name, title, removable, latestVersion, version } = props;
const getPackageInstallStatus = useGetPackageInstallStatus();
const { data: datasourcesData } = useGetDatasources({
perPage: 0,
page: 1,
kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.package.name:${props.name}`,
});
- const { name, title, removable } = props;
- const packageInstallStatus = getPackageInstallStatus(name);
+ const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name);
const packageHasDatasources = !!datasourcesData?.total;
+ const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false;
+ const isViewingOldPackage = version < latestVersion;
+ // hide install/remove options if the user has version of the package is installed
+ // and this package is out of date or if they do have a version installed but it's not this one
+ const hideInstallOptions =
+ (installationStatus === InstallStatus.notInstalled && isViewingOldPackage) ||
+ (installationStatus === InstallStatus.installed && installedVersion !== version);
+
+ const isUpdating = installationStatus === InstallStatus.installing && installedVersion;
return (
@@ -43,14 +73,13 @@ export const SettingsPanel = (
- {packageInstallStatus === InstallStatus.notInstalled ||
- packageInstallStatus === InstallStatus.installing ? (
+ {installedVersion !== null && (
-
-
-
+
+
+
+
+
+
+
+
+ {installedVersion}
+
+ {updateAvailable && }
+ |
+
+
+
+
+
+
+
+ {latestVersion}
+
+ |
+
+
+
+ {updateAvailable && (
+
+
+
+ )}
- ) : (
+ )}
+ {!hideInstallOptions && !isUpdating && (
-
-
+
+ {installationStatus === InstallStatus.notInstalled ||
+ installationStatus === InstallStatus.installing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {packageHasDatasources && removable === true && (
+
+
+
+ ),
}}
/>
-
-
-
-
-
-
+
+ )}
+ {removable === false && (
+
+
+
+
+ ),
+ }}
+ />
+
+ )}
)}
-
-
-
-
-
-
-
- {packageHasDatasources && removable === true && (
-
-
-
-
- ),
- }}
- />
-
- )}
- {removable === false && (
-
-
-
-
- ),
- }}
- />
-
- )}
);
};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx
index 05729ccfc1af4..ab168ef1530bd 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx
@@ -37,7 +37,7 @@ export function SideNavLinks({ name, version, active }: NavLinkProps) {
: p.theme.eui.euiFontWeightRegular};
`;
// don't display Data Sources tab if the package is not installed
- if (packageInstallStatus !== InstallStatus.installed && panel === 'data-sources')
+ if (packageInstallStatus.status !== InstallStatus.installed && panel === 'data-sources')
return null;
return (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/helper.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/helper.ts
new file mode 100644
index 0000000000000..508190fef0fc2
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/helper.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AgentMetadata } from '../../../../types';
+
+export function flattenMetadata(metadata: AgentMetadata) {
+ return Object.entries(metadata).reduce((acc, [key, value]) => {
+ if (typeof value === 'string') {
+ acc[key] = value;
+
+ return acc;
+ }
+
+ Object.entries(flattenMetadata(value)).forEach(([flattenedKey, flattenedValue]) => {
+ acc[`${key}.${flattenedKey}`] = flattenedValue;
+ });
+
+ return acc;
+ }, {} as { [k: string]: string });
+}
+export function unflattenMetadata(flattened: { [k: string]: string }) {
+ const metadata: AgentMetadata = {};
+
+ Object.entries(flattened).forEach(([flattenedKey, flattenedValue]) => {
+ const keyParts = flattenedKey.split('.');
+ const lastKey = keyParts.pop();
+
+ if (!lastKey) {
+ throw new Error('Invalid metadata');
+ }
+
+ let metadataPart = metadata;
+ keyParts.forEach(keyPart => {
+ if (!metadataPart[keyPart]) {
+ metadataPart[keyPart] = {};
+ }
+
+ metadataPart = metadataPart[keyPart] as AgentMetadata;
+ });
+ metadataPart[lastKey] = flattenedValue;
+ });
+
+ return metadata;
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx
index ee43385e601c2..aa6da36f8fb6c 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx
@@ -17,11 +17,13 @@ import {
} from '@elastic/eui';
import { MetadataForm } from './metadata_form';
import { Agent } from '../../../../types';
+import { flattenMetadata } from './helper';
interface Props {
agent: Agent;
flyout: { hide: () => void };
}
+
export const AgentMetadataFlyout: React.FunctionComponent = ({ agent, flyout }) => {
const mapMetadata = (obj: { [key: string]: string } | undefined) => {
return Object.keys(obj || {}).map(key => ({
@@ -30,8 +32,8 @@ export const AgentMetadataFlyout: React.FunctionComponent = ({ agent, fly
}));
};
- const localItems = mapMetadata(agent.local_metadata);
- const userProvidedItems = mapMetadata(agent.user_provided_metadata);
+ const localItems = mapMetadata(flattenMetadata(agent.local_metadata));
+ const userProvidedItems = mapMetadata(flattenMetadata(agent.user_provided_metadata));
return (
flyout.hide()} size="s" aria-labelledby="flyoutTitle">
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx
index ce28bbdc590b0..af7e8c674db4c 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx
@@ -22,6 +22,7 @@ import { useAgentRefresh } from '../hooks';
import { useInput, sendRequest } from '../../../../hooks';
import { Agent } from '../../../../types';
import { agentRouteService } from '../../../../services';
+import { flattenMetadata, unflattenMetadata } from './helper';
function useAddMetadataForm(agent: Agent, done: () => void) {
const refreshAgent = useAgentRefresh();
@@ -66,15 +67,17 @@ function useAddMetadataForm(agent: Agent, done: () => void) {
isLoading: true,
});
+ const metadata = unflattenMetadata({
+ ...flattenMetadata(agent.user_provided_metadata),
+ [keyInput.value]: valueInput.value,
+ });
+
try {
const { error } = await sendRequest({
path: agentRouteService.getUpdatePath(agent.id),
method: 'put',
body: JSON.stringify({
- user_provided_metadata: {
- ...agent.user_provided_metadata,
- [keyInput.value]: valueInput.value,
- },
+ user_provided_metadata: metadata,
}),
});
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx
index 23fe18b82468c..05264c157434e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx
@@ -238,7 +238,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const columns = [
{
- field: 'local_metadata.host',
+ field: 'local_metadata.host.hostname',
name: i18n.translate('xpack.ingestManager.agentList.hostColumnTitle', {
defaultMessage: 'Host',
}),
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
index 05d150fd9ae23..70d8e7d6882f8 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
@@ -8,6 +8,7 @@ import styled from 'styled-components';
import {
EuiButton,
EuiButtonEmpty,
+ EuiBetaBadge,
EuiPanel,
EuiText,
EuiTitle,
@@ -19,10 +20,11 @@ import {
EuiFlexItem,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
import { WithHeaderLayout } from '../../layouts';
import { useLink, useGetAgentConfigs } from '../../hooks';
import { AgentEnrollmentFlyout } from '../fleet/agent_list_page/components';
-import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from '../../constants';
+import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH, DATA_STREAM_PATH } from '../../constants';
const OverviewPanel = styled(EuiPanel).attrs(props => ({
paddingSize: 'm',
@@ -57,6 +59,11 @@ const OverviewStats = styled(EuiDescriptionList).attrs(props => ({
}
`;
+const AlphaBadge = styled(EuiBetaBadge)`
+ vertical-align: top;
+ margin-left: ${props => props.theme.eui.paddingSizes.s};
+`;
+
export const IngestManagerOverview: React.FunctionComponent = () => {
// Agent enrollment flyout state
const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false);
@@ -79,6 +86,19 @@ export const IngestManagerOverview: React.FunctionComponent = () => {
id="xpack.ingestManager.overviewPageTitle"
defaultMessage="Ingest Manager"
/>
+
@@ -213,7 +233,7 @@ export const IngestManagerOverview: React.FunctionComponent = () => {
/>
-
+
+ (agentConfig: GetAgentConfigsResponseItem) =>
listAgents(soClient, {
showInactive: true,
perPage: 0,
diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts
index 56d6053a1451b..8f07e3ed1de02 100644
--- a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts
@@ -7,7 +7,7 @@ import { TypeOf } from '@kbn/config-schema';
import Boom from 'boom';
import { RequestHandler } from 'src/core/server';
import { appContextService, datasourceService } from '../../services';
-import { ensureInstalledPackage } from '../../services/epm/packages';
+import { ensureInstalledPackage, getPackageInfo } from '../../services/epm/packages';
import {
GetDatasourcesRequestSchema,
GetOneDatasourceRequestSchema,
@@ -85,12 +85,13 @@ export const createDatasourceHandler: RequestHandler<
pkgName: request.body.package.name,
callCluster,
});
-
+ const pkgInfo = await getPackageInfo({
+ savedObjectsClient: soClient,
+ pkgName: request.body.package.name,
+ pkgVersion: request.body.package.version,
+ });
newData.inputs = (await datasourceService.assignPackageStream(
- {
- pkgName: request.body.package.name,
- pkgVersion: request.body.package.version,
- },
+ pkgInfo,
request.body.inputs
)) as TypeOf['inputs'];
}
@@ -127,13 +128,14 @@ export const updateDatasourceHandler: RequestHandler<
const pkg = newData.package || datasource.package;
const inputs = newData.inputs || datasource.inputs;
if (pkg && (newData.inputs || newData.package)) {
- newData.inputs = (await datasourceService.assignPackageStream(
- {
- pkgName: pkg.name,
- pkgVersion: pkg.version,
- },
- inputs
- )) as TypeOf['inputs'];
+ const pkgInfo = await getPackageInfo({
+ savedObjectsClient: soClient,
+ pkgName: pkg.name,
+ pkgVersion: pkg.version,
+ });
+ newData.inputs = (await datasourceService.assignPackageStream(pkgInfo, inputs)) as TypeOf<
+ typeof CreateDatasourceRequestSchema.body
+ >['inputs'];
}
const updatedDatasource = await datasourceService.update(
diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts
index edc9a0a268161..5ee7ee7733220 100644
--- a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts
@@ -5,13 +5,14 @@
*/
import { IRouter } from 'src/core/server';
import { PLUGIN_ID, FLEET_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants';
+import { IngestManagerConfigType } from '../../../common';
import {
getFleetSetupHandler,
createFleetSetupHandler,
ingestManagerSetupHandler,
} from './handlers';
-export const registerRoutes = (router: IRouter) => {
+export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) => {
// Ingest manager setup
router.post(
{
@@ -23,6 +24,11 @@ export const registerRoutes = (router: IRouter) => {
},
ingestManagerSetupHandler
);
+
+ if (!config.fleet.enabled) {
+ return;
+ }
+
// Get Fleet setup
router.get(
{
diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts
index 882258e859555..89d8b9e173ffe 100644
--- a/x-pack/plugins/ingest_manager/server/saved_objects.ts
+++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts
@@ -56,8 +56,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
enrolled_at: { type: 'date' },
access_api_key_id: { type: 'keyword' },
version: { type: 'keyword' },
- user_provided_metadata: { type: 'text' },
- local_metadata: { type: 'text' },
+ user_provided_metadata: { type: 'flattened' },
+ local_metadata: { type: 'flattened' },
config_id: { type: 'keyword' },
last_updated: { type: 'date' },
last_checkin: { type: 'date' },
@@ -128,6 +128,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
updated_on: { type: 'keyword' },
updated_by: { type: 'keyword' },
revision: { type: 'integer' },
+ monitoring_enabled: { type: 'keyword' },
},
},
},
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts
new file mode 100644
index 0000000000000..17758f6e3d7f1
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts
@@ -0,0 +1,134 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { savedObjectsClientMock } from 'src/core/server/mocks';
+import { agentConfigService } from './agent_config';
+import { Output } from '../types';
+
+function getSavedObjectMock(configAttributes: any) {
+ const mock = savedObjectsClientMock.create();
+
+ mock.get.mockImplementation(async (type: string, id: string) => {
+ return {
+ type,
+ id,
+ references: [],
+ attributes: configAttributes,
+ };
+ });
+
+ return mock;
+}
+
+jest.mock('./output', () => {
+ return {
+ outputService: {
+ getDefaultOutputId: () => 'test-id',
+ get: (): Output => {
+ return {
+ id: 'test-id',
+ is_default: true,
+ name: 'default',
+ // @ts-ignore
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ };
+ },
+ },
+ };
+});
+
+describe('agent config', () => {
+ describe('getFullConfig', () => {
+ it('should return a config without monitoring if not monitoring is not enabled', async () => {
+ const soClient = getSavedObjectMock({
+ revision: 1,
+ });
+ const config = await agentConfigService.getFullConfig(soClient, 'config');
+
+ expect(config).toMatchObject({
+ id: 'config',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ datasources: [],
+ revision: 1,
+ settings: {
+ monitoring: {
+ enabled: false,
+ logs: false,
+ metrics: false,
+ },
+ },
+ });
+ });
+
+ it('should return a config with monitoring if monitoring is enabled for logs', async () => {
+ const soClient = getSavedObjectMock({
+ revision: 1,
+ monitoring_enabled: ['logs'],
+ });
+ const config = await agentConfigService.getFullConfig(soClient, 'config');
+
+ expect(config).toMatchObject({
+ id: 'config',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ datasources: [],
+ revision: 1,
+ settings: {
+ monitoring: {
+ use_output: 'default',
+ enabled: true,
+ logs: true,
+ metrics: false,
+ },
+ },
+ });
+ });
+
+ it('should return a config with monitoring if monitoring is enabled for metrics', async () => {
+ const soClient = getSavedObjectMock({
+ revision: 1,
+ monitoring_enabled: ['metrics'],
+ });
+ const config = await agentConfigService.getFullConfig(soClient, 'config');
+
+ expect(config).toMatchObject({
+ id: 'config',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ datasources: [],
+ revision: 1,
+ settings: {
+ monitoring: {
+ use_output: 'default',
+ enabled: true,
+ logs: false,
+ metrics: true,
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
index 75bbfc21293c2..7ab6ef1920c18 100644
--- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
@@ -301,28 +301,49 @@ class AgentConfigService {
if (!config) {
return null;
}
+ const defaultOutput = await outputService.get(
+ soClient,
+ await outputService.getDefaultOutputId(soClient)
+ );
const agentConfig: FullAgentConfig = {
id: config.id,
outputs: {
// TEMPORARY as we only support a default output
- ...[
- await outputService.get(soClient, await outputService.getDefaultOutputId(soClient)),
- ].reduce((outputs, { config: outputConfig, name, type, hosts, ca_sha256, api_key }) => {
- outputs[name] = {
- type,
- hosts,
- ca_sha256,
- api_key,
- ...outputConfig,
- };
- return outputs;
- }, {} as FullAgentConfig['outputs']),
+ ...[defaultOutput].reduce(
+ (outputs, { config: outputConfig, name, type, hosts, ca_sha256, api_key }) => {
+ outputs[name] = {
+ type,
+ hosts,
+ ca_sha256,
+ api_key,
+ ...outputConfig,
+ };
+ return outputs;
+ },
+ {} as FullAgentConfig['outputs']
+ ),
},
datasources: (config.datasources as Datasource[])
.filter(datasource => datasource.enabled)
.map(ds => storedDatasourceToAgentDatasource(ds)),
revision: config.revision,
+ ...(config.monitoring_enabled && config.monitoring_enabled.length > 0
+ ? {
+ settings: {
+ monitoring: {
+ use_output: defaultOutput.name,
+ enabled: true,
+ logs: config.monitoring_enabled.indexOf('logs') >= 0,
+ metrics: config.monitoring_enabled.indexOf('metrics') >= 0,
+ },
+ },
+ }
+ : {
+ settings: {
+ monitoring: { enabled: false, logs: false, metrics: false },
+ },
+ }),
};
return agentConfig;
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
index c96a81ed9b758..9b1565e7d74aa 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
@@ -11,6 +11,7 @@ import {
AgentAction,
AgentSOAttributes,
AgentEventSOAttributes,
+ AgentMetadata,
} from '../../types';
import { agentConfigService } from '../agent_config';
@@ -28,7 +29,7 @@ export async function agentCheckin(
const updateData: {
last_checkin: string;
default_api_key?: string;
- local_metadata?: string;
+ local_metadata?: AgentMetadata;
current_error_events?: string;
} = {
last_checkin: new Date().toISOString(),
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts
index 175b92b75aca0..43fd5a3ce0ac9 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts
@@ -103,7 +103,7 @@ export async function updateAgent(
}
) {
await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, {
- user_provided_metadata: JSON.stringify(data.userProvidedMetatada),
+ user_provided_metadata: data.userProvidedMetatada,
});
}
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
index a34d2e03e9b3d..81afa70ecb818 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
@@ -32,8 +32,8 @@ export async function enroll(
config_id: configId,
type,
enrolled_at: enrolledAt,
- user_provided_metadata: JSON.stringify(metadata?.userProvided ?? {}),
- local_metadata: JSON.stringify(metadata?.local ?? {}),
+ user_provided_metadata: metadata?.userProvided ?? {},
+ local_metadata: metadata?.local ?? {},
current_error_events: undefined,
access_api_key_id: undefined,
last_checkin: undefined,
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
index b182662e0fb4e..11beba1cd7e43 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
@@ -19,8 +19,8 @@ export function savedObjectToAgent(so: SavedObject): Agent {
current_error_events: so.attributes.current_error_events
? JSON.parse(so.attributes.current_error_events)
: [],
- local_metadata: JSON.parse(so.attributes.local_metadata),
- user_provided_metadata: JSON.parse(so.attributes.user_provided_metadata),
+ local_metadata: so.attributes.local_metadata,
+ user_provided_metadata: so.attributes.user_provided_metadata,
access_api_key: undefined,
status: undefined,
};
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/status.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/status.test.ts
index b6de083cbe0cb..8140b1e6de470 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/status.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/status.test.ts
@@ -18,8 +18,8 @@ describe('Agent status service', () => {
type: AGENT_TYPE_PERMANENT,
attributes: {
active: false,
- local_metadata: '{}',
- user_provided_metadata: '{}',
+ local_metadata: {},
+ user_provided_metadata: {},
},
} as SavedObject);
const status = await getAgentStatusById(mockSavedObjectsClient, 'id');
@@ -33,8 +33,8 @@ describe('Agent status service', () => {
type: AGENT_TYPE_PERMANENT,
attributes: {
active: true,
- local_metadata: '{}',
- user_provided_metadata: '{}',
+ local_metadata: {},
+ user_provided_metadata: {},
},
} as SavedObject);
const status = await getAgentStatusById(mockSavedObjectsClient, 'id');
diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.test.ts b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts
index 4cbbadce7f5bb..3682ae6d1167b 100644
--- a/x-pack/plugins/ingest_manager/server/services/datasource.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts
@@ -5,39 +5,38 @@
*/
import { datasourceService } from './datasource';
+import { PackageInfo } from '../types';
-async function mockedGetAssetsData(_a: any, _b: any, dataset: string) {
- if (dataset === 'dataset1') {
- return [
- {
- buffer: Buffer.from(`
+const TEMPLATE = `
type: log
metricset: ["dataset1"]
paths:
{{#each paths}}
- {{this}}
{{/each}}
-`),
- },
- ];
- }
- return [];
-}
-
-jest.mock('./epm/packages/assets', () => {
- return {
- getAssetsDataForPackageKey: mockedGetAssetsData,
- };
-});
+`;
describe('Datasource service', () => {
describe('assignPackageStream', () => {
- it('should work with cofig variables from the stream', async () => {
+ it('should work with config variables from the stream', async () => {
const inputs = await datasourceService.assignPackageStream(
- {
- pkgName: 'package',
- pkgVersion: '1.0.0',
- },
+ ({
+ datasources: [
+ {
+ inputs: [
+ {
+ type: 'log',
+ streams: [
+ {
+ dataset: 'package.dataset1',
+ template: TEMPLATE,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ } as unknown) as PackageInfo,
[
{
type: 'log',
@@ -85,10 +84,23 @@ describe('Datasource service', () => {
it('should work with config variables at the input level', async () => {
const inputs = await datasourceService.assignPackageStream(
- {
- pkgName: 'package',
- pkgVersion: '1.0.0',
- },
+ ({
+ datasources: [
+ {
+ inputs: [
+ {
+ type: 'log',
+ streams: [
+ {
+ dataset: 'package.dataset1',
+ template: TEMPLATE,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ } as unknown) as PackageInfo,
[
{
type: 'log',
diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts
index 804039cf508ba..0a5ba43e40fba 100644
--- a/x-pack/plugins/ingest_manager/server/services/datasource.ts
+++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts
@@ -4,20 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsClientContract } from 'src/core/server';
-import { safeLoad } from 'js-yaml';
import { AuthenticatedUser } from '../../../security/server';
import {
DeleteDatasourcesResponse,
packageToConfigDatasource,
DatasourceInput,
DatasourceInputStream,
+ PackageInfo,
} from '../../common';
import { DATASOURCE_SAVED_OBJECT_TYPE } from '../constants';
import { NewDatasource, Datasource, ListWithKuery } from '../types';
import { agentConfigService } from './agent_config';
import { getPackageInfo, getInstallation } from './epm/packages';
import { outputService } from './output';
-import { getAssetsDataForPackageKey } from './epm/packages/assets';
import { createStream } from './epm/agent/agent';
const SAVED_OBJECT_TYPE = DATASOURCE_SAVED_OBJECT_TYPE;
@@ -201,20 +200,16 @@ class DatasourceService {
}
public async assignPackageStream(
- pkgInfo: { pkgName: string; pkgVersion: string },
+ pkgInfo: PackageInfo,
inputs: DatasourceInput[]
): Promise {
const inputsPromises = inputs.map(input => _assignPackageStreamToInput(pkgInfo, input));
+
return Promise.all(inputsPromises);
}
}
-const _isAgentStream = (p: string) => !!p.match(/agent\/stream\/stream\.yml/);
-
-async function _assignPackageStreamToInput(
- pkgInfo: { pkgName: string; pkgVersion: string },
- input: DatasourceInput
-) {
+async function _assignPackageStreamToInput(pkgInfo: PackageInfo, input: DatasourceInput) {
const streamsPromises = input.streams.map(stream =>
_assignPackageStreamToStream(pkgInfo, input, stream)
);
@@ -224,7 +219,7 @@ async function _assignPackageStreamToInput(
}
async function _assignPackageStreamToStream(
- pkgInfo: { pkgName: string; pkgVersion: string },
+ pkgInfo: PackageInfo,
input: DatasourceInput,
stream: DatasourceInputStream
) {
@@ -232,27 +227,35 @@ async function _assignPackageStreamToStream(
return { ...stream, agent_stream: undefined };
}
const dataset = getDataset(stream.dataset);
- const assetsData = await getAssetsDataForPackageKey(pkgInfo, _isAgentStream, dataset);
+ const datasource = pkgInfo.datasources?.[0];
+ if (!datasource) {
+ throw new Error('Stream template not found, no datasource');
+ }
- const [pkgStream] = assetsData;
- if (!pkgStream || !pkgStream.buffer) {
- throw new Error(`Stream template not found for dataset ${dataset}`);
+ const inputFromPkg = datasource.inputs.find(pkgInput => pkgInput.type === input.type);
+ if (!inputFromPkg) {
+ throw new Error(`Stream template not found, unable to found input ${input.type}`);
}
- // Populate template variables from input config and stream config
- const data: { [k: string]: string | string[] } = {};
- if (input.vars) {
- for (const key of Object.keys(input.vars)) {
- data[key] = input.vars[key].value;
- }
+ const streamFromPkg = inputFromPkg.streams.find(
+ pkgStream => pkgStream.dataset === stream.dataset
+ );
+ if (!streamFromPkg) {
+ throw new Error(`Stream template not found, unable to found stream ${stream.dataset}`);
}
- if (stream.vars) {
- for (const key of Object.keys(stream.vars)) {
- data[key] = stream.vars[key].value;
- }
+
+ if (!streamFromPkg.template) {
+ throw new Error(`Stream template not found for dataset ${dataset}`);
}
- const yaml = safeLoad(createStream(data, pkgStream.buffer.toString()));
+
+ const yaml = createStream(
+ // Populate template variables from input vars and stream vars
+ Object.assign({}, input.vars, stream.vars),
+ streamFromPkg.template
+ );
+
stream.agent_stream = yaml;
+
return { ...stream };
}
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts
index 21de625532f03..db2e4fe474640 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts
@@ -6,29 +6,61 @@
import { createStream } from './agent';
-test('Test creating a stream from template', () => {
- const streamTemplate = `
-input: log
-paths:
-{{#each paths}}
- - {{this}}
-{{/each}}
-exclude_files: [".gz$"]
-processors:
- - add_locale: ~
- `;
- const vars = {
- paths: ['/usr/local/var/log/nginx/access.log'],
- };
+describe('createStream', () => {
+ it('should work', () => {
+ const streamTemplate = `
+ input: log
+ paths:
+ {{#each paths}}
+ - {{this}}
+ {{/each}}
+ exclude_files: [".gz$"]
+ processors:
+ - add_locale: ~
+ `;
+ const vars = {
+ paths: { value: ['/usr/local/var/log/nginx/access.log'] },
+ };
- const output = createStream(vars, streamTemplate);
+ const output = createStream(vars, streamTemplate);
+ expect(output).toEqual({
+ input: 'log',
+ paths: ['/usr/local/var/log/nginx/access.log'],
+ exclude_files: ['.gz$'],
+ processors: [{ add_locale: null }],
+ });
+ });
- expect(output).toBe(`
-input: log
-paths:
- - /usr/local/var/log/nginx/access.log
-exclude_files: [".gz$"]
-processors:
- - add_locale: ~
- `);
+ it('should support yaml values', () => {
+ const streamTemplate = `
+ input: redis/metrics
+ metricsets: ["key"]
+ test: null
+ {{#if key.patterns}}
+ key.patterns: {{key.patterns}}
+ {{/if}}
+ `;
+ const vars = {
+ 'key.patterns': {
+ type: 'yaml',
+ value: `
+ - limit: 20
+ pattern: '*'
+ `,
+ },
+ };
+
+ const output = createStream(vars, streamTemplate);
+ expect(output).toEqual({
+ input: 'redis/metrics',
+ metricsets: ['key'],
+ test: null,
+ 'key.patterns': [
+ {
+ limit: 20,
+ pattern: '*',
+ },
+ ],
+ });
+ });
});
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts
index 5d9a6d409aa1a..8254c0d8aaa37 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts
@@ -5,12 +5,71 @@
*/
import Handlebars from 'handlebars';
+import { safeLoad } from 'js-yaml';
+import { DatasourceConfigRecord } from '../../../../common';
-interface StreamVars {
- [k: string]: string | string[];
+function isValidKey(key: string) {
+ return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
}
-export function createStream(vars: StreamVars, streamTemplate: string) {
- const template = Handlebars.compile(streamTemplate);
- return template(vars);
+function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) {
+ if (Object.keys(yamlVariables).length === 0 || !yaml) {
+ return yaml;
+ }
+
+ Object.entries(yaml).forEach(([key, value]: [string, any]) => {
+ if (typeof value === 'object') {
+ yaml[key] = replaceVariablesInYaml(yamlVariables, value);
+ }
+ if (typeof value === 'string' && value in yamlVariables) {
+ yaml[key] = yamlVariables[value];
+ }
+ });
+
+ return yaml;
+}
+
+function buildTemplateVariables(variables: DatasourceConfigRecord) {
+ const yamlValues: { [k: string]: any } = {};
+ const vars = Object.entries(variables).reduce((acc, [key, recordEntry]) => {
+ // support variables with . like key.patterns
+ const keyParts = key.split('.');
+ const lastKeyPart = keyParts.pop();
+
+ if (!lastKeyPart || !isValidKey(lastKeyPart)) {
+ throw new Error('Invalid key');
+ }
+
+ let varPart = acc;
+ for (const keyPart of keyParts) {
+ if (!isValidKey(keyPart)) {
+ throw new Error('Invalid key');
+ }
+ if (!varPart[keyPart]) {
+ varPart[keyPart] = {};
+ }
+ varPart = varPart[keyPart];
+ }
+
+ if (recordEntry.type && recordEntry.type === 'yaml') {
+ const yamlKeyPlaceholder = `##${key}##`;
+ varPart[lastKeyPart] = `"${yamlKeyPlaceholder}"`;
+ yamlValues[yamlKeyPlaceholder] = recordEntry.value ? safeLoad(recordEntry.value) : null;
+ } else {
+ varPart[lastKeyPart] = recordEntry.value;
+ }
+ return acc;
+ }, {} as { [k: string]: any });
+
+ return { vars, yamlValues };
+}
+
+export function createStream(variables: DatasourceConfigRecord, streamTemplate: string) {
+ const { vars, yamlValues } = buildTemplateVariables(variables);
+
+ const template = Handlebars.compile(streamTemplate, { noEscape: true });
+ const stream = template(vars);
+ const yamlFromStream = safeLoad(stream, {});
+
+ return replaceVariablesInYaml(yamlValues, yamlFromStream);
}
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts
index 1a73c9581a2de..cacf84381dd88 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts
@@ -93,7 +93,7 @@ test('tests processing text field with multi fields', () => {
const fields: Field[] = safeLoad(textWithMultiFieldsLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(textWithMultiFieldsMapping));
+ expect(mappings).toEqual(textWithMultiFieldsMapping);
});
test('tests processing keyword field with multi fields', () => {
@@ -127,7 +127,7 @@ test('tests processing keyword field with multi fields', () => {
const fields: Field[] = safeLoad(keywordWithMultiFieldsLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(keywordWithMultiFieldsMapping));
+ expect(mappings).toEqual(keywordWithMultiFieldsMapping);
});
test('tests processing keyword field with multi fields with analyzed text field', () => {
@@ -159,7 +159,7 @@ test('tests processing keyword field with multi fields with analyzed text field'
const fields: Field[] = safeLoad(keywordWithAnalyzedMultiFieldsLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(keywordWithAnalyzedMultiFieldsMapping));
+ expect(mappings).toEqual(keywordWithAnalyzedMultiFieldsMapping);
});
test('tests processing object field with no other attributes', () => {
@@ -177,7 +177,7 @@ test('tests processing object field with no other attributes', () => {
const fields: Field[] = safeLoad(objectFieldLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldMapping));
+ expect(mappings).toEqual(objectFieldMapping);
});
test('tests processing object field with enabled set to false', () => {
@@ -197,7 +197,7 @@ test('tests processing object field with enabled set to false', () => {
const fields: Field[] = safeLoad(objectFieldEnabledFalseLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldEnabledFalseMapping));
+ expect(mappings).toEqual(objectFieldEnabledFalseMapping);
});
test('tests processing object field with dynamic set to false', () => {
@@ -217,7 +217,7 @@ test('tests processing object field with dynamic set to false', () => {
const fields: Field[] = safeLoad(objectFieldDynamicFalseLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicFalseMapping));
+ expect(mappings).toEqual(objectFieldDynamicFalseMapping);
});
test('tests processing object field with dynamic set to true', () => {
@@ -237,7 +237,7 @@ test('tests processing object field with dynamic set to true', () => {
const fields: Field[] = safeLoad(objectFieldDynamicTrueLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicTrueMapping));
+ expect(mappings).toEqual(objectFieldDynamicTrueMapping);
});
test('tests processing object field with dynamic set to strict', () => {
@@ -257,5 +257,159 @@ test('tests processing object field with dynamic set to strict', () => {
const fields: Field[] = safeLoad(objectFieldDynamicStrictLiteralYml);
const processedFields = processFields(fields);
const mappings = generateMappings(processedFields);
- expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicStrictMapping));
+ expect(mappings).toEqual(objectFieldDynamicStrictMapping);
+});
+
+test('tests processing object field with property', () => {
+ const objectFieldWithPropertyLiteralYml = `
+- name: a
+ type: object
+- name: a.b
+ type: keyword
+ `;
+ const objectFieldWithPropertyMapping = {
+ properties: {
+ a: {
+ properties: {
+ b: {
+ ignore_above: 1024,
+ type: 'keyword',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(objectFieldWithPropertyLiteralYml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(objectFieldWithPropertyMapping);
+});
+
+test('tests processing object field with property, reverse order', () => {
+ const objectFieldWithPropertyReversedLiteralYml = `
+- name: a.b
+ type: keyword
+- name: a
+ type: object
+ dynamic: false
+ `;
+ const objectFieldWithPropertyReversedMapping = {
+ properties: {
+ a: {
+ dynamic: false,
+ properties: {
+ b: {
+ ignore_above: 1024,
+ type: 'keyword',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(objectFieldWithPropertyReversedLiteralYml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(objectFieldWithPropertyReversedMapping);
+});
+
+test('tests processing nested field with property', () => {
+ const nestedYaml = `
+ - name: a.b
+ type: keyword
+ - name: a
+ type: nested
+ dynamic: false
+ `;
+ const expectedMapping = {
+ properties: {
+ a: {
+ dynamic: false,
+ type: 'nested',
+ properties: {
+ b: {
+ ignore_above: 1024,
+ type: 'keyword',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(nestedYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(expectedMapping);
+});
+
+test('tests processing nested field with property, nested field first', () => {
+ const nestedYaml = `
+ - name: a
+ type: nested
+ include_in_parent: true
+ - name: a.b
+ type: keyword
+ `;
+ const expectedMapping = {
+ properties: {
+ a: {
+ include_in_parent: true,
+ type: 'nested',
+ properties: {
+ b: {
+ ignore_above: 1024,
+ type: 'keyword',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(nestedYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(expectedMapping);
+});
+
+test('tests processing nested leaf field with properties', () => {
+ const nestedYaml = `
+ - name: a
+ type: object
+ dynamic: false
+ - name: a.b
+ type: nested
+ enabled: false
+ `;
+ const expectedMapping = {
+ properties: {
+ a: {
+ dynamic: false,
+ properties: {
+ b: {
+ enabled: false,
+ type: 'nested',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(nestedYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(expectedMapping);
+});
+
+test('tests constant_keyword field type handling', () => {
+ const constantKeywordLiteralYaml = `
+- name: constantKeyword
+ type: constant_keyword
+ `;
+ const constantKeywordMapping = {
+ properties: {
+ constantKeyword: {
+ type: 'constant_keyword',
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(constantKeywordLiteralYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(JSON.stringify(mappings)).toEqual(JSON.stringify(constantKeywordMapping));
});
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts
index 9736f6d1cbd3c..c45c7e706be58 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts
@@ -71,7 +71,14 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings {
switch (type) {
case 'group':
- fieldProps = generateMappings(field.fields!);
+ fieldProps = { ...generateMappings(field.fields!), ...generateDynamicAndEnabled(field) };
+ break;
+ case 'group-nested':
+ fieldProps = {
+ ...generateMappings(field.fields!),
+ ...generateNestedProps(field),
+ type: 'nested',
+ };
break;
case 'integer':
fieldProps.type = 'long';
@@ -95,13 +102,10 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings {
}
break;
case 'object':
- fieldProps.type = 'object';
- if (field.hasOwnProperty('enabled')) {
- fieldProps.enabled = field.enabled;
- }
- if (field.hasOwnProperty('dynamic')) {
- fieldProps.dynamic = field.dynamic;
- }
+ fieldProps = { ...fieldProps, ...generateDynamicAndEnabled(field), type: 'object' };
+ break;
+ case 'nested':
+ fieldProps = { ...fieldProps, ...generateNestedProps(field), type: 'nested' };
break;
case 'array':
// this assumes array fields were validated in an earlier step
@@ -128,6 +132,29 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings {
return { properties: props };
}
+function generateDynamicAndEnabled(field: Field) {
+ const props: Properties = {};
+ if (field.hasOwnProperty('enabled')) {
+ props.enabled = field.enabled;
+ }
+ if (field.hasOwnProperty('dynamic')) {
+ props.dynamic = field.dynamic;
+ }
+ return props;
+}
+
+function generateNestedProps(field: Field) {
+ const props = generateDynamicAndEnabled(field);
+
+ if (field.hasOwnProperty('include_in_parent')) {
+ props.include_in_parent = field.include_in_parent;
+ }
+ if (field.hasOwnProperty('include_in_root')) {
+ props.include_in_root = field.include_in_root;
+ }
+ return props;
+}
+
function generateMultiFields(fields: Fields): MultiFields {
const multiFields: MultiFields = {};
if (fields) {
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts
index e3aef6077dbc3..f0ff4c6125452 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts
@@ -179,4 +179,197 @@ describe('processFields', () => {
JSON.stringify(mixedFieldsExpanded)
);
});
+
+ const objectFieldWithProperty = [
+ {
+ name: 'a',
+ type: 'object',
+ dynamic: true,
+ },
+ {
+ name: 'a.b',
+ type: 'keyword',
+ },
+ ];
+
+ const objectFieldWithPropertyExpanded = [
+ {
+ name: 'a',
+ type: 'group',
+ dynamic: true,
+ fields: [
+ {
+ name: 'b',
+ type: 'keyword',
+ },
+ ],
+ },
+ ];
+ test('correctly handles properties of object type fields', () => {
+ expect(JSON.stringify(processFields(objectFieldWithProperty))).toEqual(
+ JSON.stringify(objectFieldWithPropertyExpanded)
+ );
+ });
+
+ test('correctly handles properties of object type fields where object comes second', () => {
+ const nested = [
+ {
+ name: 'a.b',
+ type: 'keyword',
+ },
+ {
+ name: 'a',
+ type: 'object',
+ dynamic: true,
+ },
+ ];
+
+ const nestedExpanded = [
+ {
+ name: 'a',
+ type: 'group',
+ dynamic: true,
+ fields: [
+ {
+ name: 'b',
+ type: 'keyword',
+ },
+ ],
+ },
+ ];
+ expect(processFields(nested)).toEqual(nestedExpanded);
+ });
+
+ test('correctly handles properties of nested type fields', () => {
+ const nested = [
+ {
+ name: 'a',
+ type: 'nested',
+ dynamic: true,
+ },
+ {
+ name: 'a.b',
+ type: 'keyword',
+ },
+ ];
+
+ const nestedExpanded = [
+ {
+ name: 'a',
+ type: 'group-nested',
+ dynamic: true,
+ fields: [
+ {
+ name: 'b',
+ type: 'keyword',
+ },
+ ],
+ },
+ ];
+ expect(processFields(nested)).toEqual(nestedExpanded);
+ });
+
+ test('correctly handles properties of nested type where nested top level comes second', () => {
+ const nested = [
+ {
+ name: 'a.b',
+ type: 'keyword',
+ },
+ {
+ name: 'a',
+ type: 'nested',
+ dynamic: true,
+ },
+ ];
+
+ const nestedExpanded = [
+ {
+ name: 'a',
+ type: 'group-nested',
+ dynamic: true,
+ fields: [
+ {
+ name: 'b',
+ type: 'keyword',
+ },
+ ],
+ },
+ ];
+ expect(processFields(nested)).toEqual(nestedExpanded);
+ });
+
+ test('ignores redefinitions of an object field', () => {
+ const object = [
+ {
+ name: 'a',
+ type: 'object',
+ dynamic: true,
+ },
+ {
+ name: 'a',
+ type: 'object',
+ dynamic: false,
+ },
+ ];
+
+ const objectExpected = [
+ {
+ name: 'a',
+ type: 'object',
+ // should preserve the field that was parsed first which had dynamic: true
+ dynamic: true,
+ },
+ ];
+ expect(processFields(object)).toEqual(objectExpected);
+ });
+
+ test('ignores redefinitions of a nested field', () => {
+ const nested = [
+ {
+ name: 'a',
+ type: 'nested',
+ dynamic: true,
+ },
+ {
+ name: 'a',
+ type: 'nested',
+ dynamic: false,
+ },
+ ];
+
+ const nestedExpected = [
+ {
+ name: 'a',
+ type: 'nested',
+ // should preserve the field that was parsed first which had dynamic: true
+ dynamic: true,
+ },
+ ];
+ expect(processFields(nested)).toEqual(nestedExpected);
+ });
+
+ test('ignores redefinitions of a nested and object field', () => {
+ const nested = [
+ {
+ name: 'a',
+ type: 'nested',
+ dynamic: true,
+ },
+ {
+ name: 'a',
+ type: 'object',
+ dynamic: false,
+ },
+ ];
+
+ const nestedExpected = [
+ {
+ name: 'a',
+ type: 'nested',
+ // should preserve the field that was parsed first which had dynamic: true
+ dynamic: true,
+ },
+ ];
+ expect(processFields(nested)).toEqual(nestedExpected);
+ });
});
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts
index 9c9843e0454ab..abaf7ab5b0dfc 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts
@@ -28,6 +28,8 @@ export interface Field {
object_type?: string;
scaling_factor?: number;
dynamic?: 'strict' | boolean;
+ include_in_parent?: boolean;
+ include_in_root?: boolean;
// Kibana specific
analyzed?: boolean;
@@ -108,10 +110,54 @@ function dedupFields(fields: Fields): Fields {
return f.name === field.name;
});
if (found) {
- if (found.type === 'group' && field.type === 'group' && found.fields && field.fields) {
- found.fields = dedupFields(found.fields.concat(field.fields));
+ // remove name, type, and fields from `field` variable so we avoid merging them into `found`
+ const { name, type, fields: nestedFields, ...importantFieldProps } = field;
+ /**
+ * There are a couple scenarios this if is trying to account for:
+ * Example 1
+ * - name: a.b
+ * - name: a
+ * In this scenario found will be `group` and field could be either `object` or `nested`
+ * Example 2
+ * - name: a
+ * - name: a.b
+ * In this scenario found could be `object` or `nested` and field will be group
+ */
+ if (
+ // only merge if found is a group and field is object, nested, or group.
+ // Or if found is object, or nested, and field is a group.
+ // This is to avoid merging two objects, or nested, or object with a nested.
+ (found.type === 'group' &&
+ (field.type === 'object' || field.type === 'nested' || field.type === 'group')) ||
+ ((found.type === 'object' || found.type === 'nested') && field.type === 'group')
+ ) {
+ // if the new field has properties let's dedup and concat them with the already existing found variable in
+ // the array
+ if (field.fields) {
+ // if the found type was object or nested it won't have a fields array so let's initialize it
+ if (!found.fields) {
+ found.fields = [];
+ }
+ found.fields = dedupFields(found.fields.concat(field.fields));
+ }
+
+ // if found already had fields or got new ones from the new field coming in we need to assign the right
+ // type to it
+ if (found.fields) {
+ // If this field is supposed to be `nested` and we have fields, we need to preserve the fact that it is
+ // supposed to be `nested` for when the template is actually generated
+ if (found.type === 'nested' || field.type === 'nested') {
+ found.type = 'group-nested';
+ } else {
+ // found was either `group` already or `object` so just set it to `group`
+ found.type = 'group';
+ }
+ }
+ // we need to merge in other properties (like `dynamic`) that might exist
+ Object.assign(found, importantFieldProps);
+ // if `field.type` wasn't group object or nested, then there's a conflict in types, so lets ignore it
} else {
- // only 'group' fields can be merged in this way
+ // only `group`, `object`, and `nested` fields can be merged in this way
// XXX: don't abort on error for now
// see discussion in https://github.com/elastic/kibana/pull/59894
// throw new Error(
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts
index bc1694348b4c2..f1660fbc01591 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts
@@ -150,6 +150,7 @@ describe('creating index patterns from yaml fields', () => {
{ fields: [{ name: 'testField', type: 'text' }], expect: 'string' },
{ fields: [{ name: 'testField', type: 'date' }], expect: 'date' },
{ fields: [{ name: 'testField', type: 'geo_point' }], expect: 'geo_point' },
+ { fields: [{ name: 'testField', type: 'constant_keyword' }], expect: 'string' },
];
tests.forEach(test => {
@@ -191,6 +192,7 @@ describe('creating index patterns from yaml fields', () => {
attr: 'aggregatable',
},
{ fields: [{ name, type: 'keyword' }], expect: true, attr: 'aggregatable' },
+ { fields: [{ name, type: 'constant_keyword' }], expect: true, attr: 'aggregatable' },
{ fields: [{ name, type: 'text', aggregatable: true }], expect: false, attr: 'aggregatable' },
{ fields: [{ name, type: 'text' }], expect: false, attr: 'aggregatable' },
{
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts
index 05e64c6565dc6..ec657820a2225 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts
@@ -47,6 +47,7 @@ const typeMap: TypeMap = {
date: 'date',
ip: 'ip',
boolean: 'boolean',
+ constant_keyword: 'string',
};
export interface IndexPatternField {
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts
index d76584225877c..da8d79a04b97c 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts
@@ -67,9 +67,10 @@ export async function getPackageInfo(options: {
pkgVersion: string;
}): Promise {
const { savedObjectsClient, pkgName, pkgVersion } = options;
- const [item, savedObject, assets] = await Promise.all([
+ const [item, savedObject, latestPackage, assets] = await Promise.all([
Registry.fetchInfo(pkgName, pkgVersion),
getInstallationObject({ savedObjectsClient, pkgName }),
+ Registry.fetchFindLatestPackage(pkgName),
Registry.getArchiveInfo(pkgName, pkgVersion),
] as const);
// adding `as const` due to regression in TS 3.7.2
@@ -79,6 +80,7 @@ export async function getPackageInfo(options: {
// add properties that aren't (or aren't yet) on Registry response
const updated = {
...item,
+ latestVersion: latestPackage.version,
title: item.title || nameAsTitle(item.name),
assets: Registry.groupPathsByService(assets || []),
};
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts
index d49e0e661440f..c67cccd044bf5 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts
@@ -43,6 +43,7 @@ export function createInstallableFrom(
? {
...from,
status: InstallationStatus.installed,
+ installedVersion: savedObject.attributes.version,
savedObject,
}
: {
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
index 06f3decdbbe6f..8f51c4d78305c 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
@@ -106,7 +106,7 @@ export async function installPackage(options: {
try {
await deleteKibanaSavedObjectsAssets(savedObjectsClient, installedPkg.attributes.installed);
} catch (err) {
- // some assets may not exist if deleting during a failed update
+ // log these errors, some assets may not exist if deleted during a failed update
}
}
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts
index ac2c869f3b9e9..befb4722b6504 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts
@@ -121,8 +121,12 @@ export async function deleteKibanaSavedObjectsAssets(
const deletePromises = installedObjects.map(({ id, type }) => {
const assetType = type as AssetType;
if (savedObjectTypes.includes(assetType)) {
- savedObjectsClient.delete(assetType, id);
+ return savedObjectsClient.delete(assetType, id);
}
});
- await Promise.all(deletePromises);
+ try {
+ await Promise.all(deletePromises);
+ } catch (err) {
+ throw new Error('error deleting saved object asset');
+ }
}
diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts
index 206ad76703cf5..390e240841611 100644
--- a/x-pack/plugins/ingest_manager/server/services/setup.ts
+++ b/x-pack/plugins/ingest_manager/server/services/setup.ts
@@ -145,7 +145,7 @@ async function addPackageToConfig(
config.namespace
);
newDatasource.inputs = await datasourceService.assignPackageStream(
- { pkgName: packageToInstall.name, pkgVersion: packageToInstall.version },
+ packageInfo,
newDatasource.inputs
);
diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx
index a7019ebc0a271..27ed1de849987 100644
--- a/x-pack/plugins/ingest_manager/server/types/index.tsx
+++ b/x-pack/plugins/ingest_manager/server/types/index.tsx
@@ -8,6 +8,7 @@ import { ScopedClusterClient } from 'src/core/server';
export {
// Object types
Agent,
+ AgentMetadata,
AgentSOAttributes,
AgentStatus,
AgentType,
diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts
index 040b2eb16289a..59cadf3bd7f74 100644
--- a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts
@@ -11,6 +11,9 @@ const AgentConfigBaseSchema = {
name: schema.string(),
namespace: schema.maybe(schema.string()),
description: schema.maybe(schema.string()),
+ monitoring_enabled: schema.maybe(
+ schema.arrayOf(schema.oneOf([schema.literal('logs'), schema.literal('metrics')]))
+ ),
};
export const NewAgentConfigSchema = schema.object({
diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
index 359c06a6a9ebc..21bbcce68bf36 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
@@ -41,6 +41,10 @@ export const datatableVisualization: Visualization<
},
],
+ getVisualizationTypeId() {
+ return 'lnsDatatable';
+ },
+
getLayerIds(state) {
return state.layers.map(l => l.layerId);
},
@@ -122,8 +126,8 @@ export const datatableVisualization: Visualization<
],
},
previewIcon: chartTableSVG,
- // dont show suggestions for reduced versions or single-line tables
- hide: table.changeType === 'reduced' || !table.isMultiRow,
+ // tables are hidden from suggestion bar, but used for drag & drop and chart switching
+ hide: true,
},
];
},
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx
index 3c61d270b1bcf..c8d8064e60e38 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx
@@ -62,7 +62,25 @@ describe('chart_switch', () => {
id: 'subvisC2',
label: 'C2',
},
+ {
+ icon: 'empty',
+ id: 'subvisC3',
+ label: 'C3',
+ },
],
+ getSuggestions: jest.fn(options => {
+ if (options.subVisualizationId === 'subvisC2') {
+ return [];
+ }
+ return [
+ {
+ score: 1,
+ title: '',
+ state: `suggestion`,
+ previewIcon: 'empty',
+ },
+ ];
+ }),
},
};
}
@@ -313,10 +331,11 @@ describe('chart_switch', () => {
expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined();
});
- it('should not indicate data loss if visualization is not changed', () => {
+ it('should not show a warning when the subvisualization is the same', () => {
const dispatch = jest.fn();
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
+ visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2');
const switchVisualizationType = jest.fn(() => 'therebedragons');
visualizations.visC.switchVisualizationType = switchVisualizationType;
@@ -333,10 +352,10 @@ describe('chart_switch', () => {
/>
);
- expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined();
+ expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).not.toBeDefined();
});
- it('should remove all layers if there is no suggestion', () => {
+ it('should get suggestions when switching subvisualization', () => {
const dispatch = jest.fn();
const visualizations = mockVisualizations();
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
@@ -377,7 +396,7 @@ describe('chart_switch', () => {
const dispatch = jest.fn();
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
- const switchVisualizationType = jest.fn(() => 'therebedragons');
+ const switchVisualizationType = jest.fn(() => 'switched');
visualizations.visC.switchVisualizationType = switchVisualizationType;
@@ -393,12 +412,12 @@ describe('chart_switch', () => {
/>
);
- switchTo('subvisC2', component);
- expect(switchVisualizationType).toHaveBeenCalledWith('subvisC2', 'therebegriffins');
+ switchTo('subvisC3', component);
+ expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', 'suggestion');
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SWITCH_VISUALIZATION',
- initialState: 'therebedragons',
+ initialState: 'switched',
})
);
expect(frame.removeLayers).not.toHaveBeenCalled();
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx
index 1461449f3c1c8..d73f83e75c0e4 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx
@@ -105,7 +105,16 @@ export function ChartSwitch(props: Props) {
const switchVisType =
props.visualizationMap[visualizationId].switchVisualizationType ||
((_type: string, initialState: unknown) => initialState);
- if (props.visualizationId === visualizationId) {
+ const layers = Object.entries(props.framePublicAPI.datasourceLayers);
+ const containsData = layers.some(
+ ([_layerId, datasource]) => datasource.getTableSpec().length > 0
+ );
+ // Always show the active visualization as a valid selection
+ if (
+ props.visualizationId === visualizationId &&
+ props.visualizationState &&
+ newVisualization.getVisualizationTypeId(props.visualizationState) === subVisualizationId
+ ) {
return {
visualizationId,
subVisualizationId,
@@ -116,13 +125,13 @@ export function ChartSwitch(props: Props) {
};
}
- const layers = Object.entries(props.framePublicAPI.datasourceLayers);
- const containsData = layers.some(
- ([_layerId, datasource]) => datasource.getTableSpec().length > 0
+ const topSuggestion = getTopSuggestion(
+ props,
+ visualizationId,
+ newVisualization,
+ subVisualizationId
);
- const topSuggestion = getTopSuggestion(props, visualizationId, newVisualization);
-
let dataLoss: VisualizationSelection['dataLoss'];
if (!containsData) {
@@ -250,7 +259,8 @@ export function ChartSwitch(props: Props) {
function getTopSuggestion(
props: Props,
visualizationId: string,
- newVisualization: Visualization
+ newVisualization: Visualization,
+ subVisualizationId?: string
): Suggestion | undefined {
const suggestions = getSuggestions({
datasourceMap: props.datasourceMap,
@@ -258,6 +268,7 @@ function getTopSuggestion(
visualizationMap: { [visualizationId]: newVisualization },
activeVisualizationId: props.visualizationId,
visualizationState: props.visualizationState,
+ subVisualizationId,
}).filter(suggestion => {
// don't use extended versions of current data table on switching between visualizations
// to avoid confusing the user.
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
index eabcdfa7a24ab..949ae1f43448e 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
@@ -44,6 +44,7 @@ export function getSuggestions({
datasourceStates,
visualizationMap,
activeVisualizationId,
+ subVisualizationId,
visualizationState,
field,
}: {
@@ -57,6 +58,7 @@ export function getSuggestions({
>;
visualizationMap: Record;
activeVisualizationId: string | null;
+ subVisualizationId?: string;
visualizationState: unknown;
field?: unknown;
}): Suggestion[] {
@@ -89,7 +91,8 @@ export function getSuggestions({
table,
visualizationId,
datasourceSuggestion,
- currentVisualizationState
+ currentVisualizationState,
+ subVisualizationId
);
})
)
@@ -108,13 +111,15 @@ function getVisualizationSuggestions(
table: TableSuggestion,
visualizationId: string,
datasourceSuggestion: DatasourceSuggestion & { datasourceId: string },
- currentVisualizationState: unknown
+ currentVisualizationState: unknown,
+ subVisualizationId?: string
) {
return visualization
.getSuggestions({
table,
state: currentVisualizationState,
keptLayerIds: datasourceSuggestion.keptLayerIds,
+ subVisualizationId,
})
.map(({ state, ...visualizationSuggestion }) => ({
...visualizationSuggestion,
diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
index 50cd1ad8bd53a..e684fe8b3b5d6 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx
@@ -28,6 +28,7 @@ export function createMockVisualization(): jest.Mocked {
label: 'TEST',
},
],
+ getVisualizationTypeId: jest.fn(_state => 'empty'),
getDescription: jest.fn(_state => ({ label: '' })),
switchVisualizationType: jest.fn((_, x) => x),
getPersistableState: jest.fn(_state => _state),
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts
deleted file mode 100644
index 5f35ef650a08c..0000000000000
--- a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
-import { getAutoDate } from './auto_date';
-
-describe('auto_date', () => {
- let autoDate: ReturnType;
-
- beforeEach(() => {
- autoDate = getAutoDate({ data: dataPluginMock.createSetupContract() });
- });
-
- it('should do nothing if no time range is provided', () => {
- const result = autoDate.fn(
- {
- type: 'kibana_context',
- },
- {
- aggConfigs: 'canttouchthis',
- },
- // eslint-disable-next-line
- {} as any
- );
-
- expect(result).toEqual('canttouchthis');
- });
-
- it('should not change anything if there are no auto date histograms', () => {
- const aggConfigs = JSON.stringify([
- { type: 'date_histogram', params: { interval: '35h' } },
- { type: 'count' },
- ]);
- const result = autoDate.fn(
- {
- timeRange: {
- from: 'now-10d',
- to: 'now',
- },
- type: 'kibana_context',
- },
- {
- aggConfigs,
- },
- // eslint-disable-next-line
- {} as any
- );
-
- expect(result).toEqual(aggConfigs);
- });
-
- it('should change auto date histograms', () => {
- const aggConfigs = JSON.stringify([
- { type: 'date_histogram', params: { interval: 'auto' } },
- { type: 'count' },
- ]);
- const result = autoDate.fn(
- {
- timeRange: {
- from: 'now-10d',
- to: 'now',
- },
- type: 'kibana_context',
- },
- {
- aggConfigs,
- },
- // eslint-disable-next-line
- {} as any
- );
-
- const interval = JSON.parse(result).find(
- (agg: { type: string }) => agg.type === 'date_histogram'
- ).params.interval;
-
- expect(interval).toBeTruthy();
- expect(typeof interval).toEqual('string');
- expect(interval).not.toEqual('auto');
- });
-});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts
deleted file mode 100644
index 97a46f4a3e176..0000000000000
--- a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { DataPublicPluginSetup } from '../../../../../src/plugins/data/public';
-import {
- ExpressionFunctionDefinition,
- KibanaContext,
-} from '../../../../../src/plugins/expressions/public';
-
-interface LensAutoDateProps {
- aggConfigs: string;
-}
-
-export function getAutoDate(deps: {
- data: DataPublicPluginSetup;
-}): ExpressionFunctionDefinition<
- 'lens_auto_date',
- KibanaContext | null,
- LensAutoDateProps,
- string
-> {
- function autoIntervalFromContext(ctx?: KibanaContext | null) {
- if (!ctx || !ctx.timeRange) {
- return;
- }
-
- return deps.data.search.aggs.calculateAutoTimeExpression(ctx.timeRange);
- }
-
- /**
- * Convert all 'auto' date histograms into a concrete value (e.g. 2h).
- * This allows us to support 'auto' on all date fields, and opens the
- * door to future customizations (e.g. adjusting the level of detail, etc).
- */
- return {
- name: 'lens_auto_date',
- aliases: [],
- help: '',
- inputTypes: ['kibana_context', 'null'],
- args: {
- aggConfigs: {
- types: ['string'],
- default: '""',
- help: '',
- },
- },
- fn(input, args) {
- const interval = autoIntervalFromContext(input);
-
- if (!interval) {
- return args.aggConfigs;
- }
-
- const configs = JSON.parse(args.aggConfigs) as Array<{
- type: string;
- params: { interval: string };
- }>;
-
- const updatedConfigs = configs.map(c => {
- if (c.type !== 'date_histogram' || !c.params || c.params.interval !== 'auto') {
- return c;
- }
-
- return {
- ...c,
- params: {
- ...c.params,
- interval,
- },
- };
- });
-
- return JSON.stringify(updatedConfigs);
- },
- };
-}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
index 074c40759f8d8..9df79aa9e0908 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
@@ -1380,5 +1380,43 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
});
});
+
+ it('does not set the size of the terms aggregation', () => {
+ const dragging = {
+ field: { type: 'string', name: 'mystring', aggregatable: true },
+ indexPatternId: 'foo',
+ };
+ const testState = dragDropState();
+ onDrop({
+ ...defaultProps,
+ dragDropContext: {
+ ...dragDropContext,
+ dragging,
+ },
+ droppedItem: dragging,
+ state: testState,
+ columnId: 'col2',
+ filterOperations: (op: OperationMetadata) => op.isBucketed,
+ layerId: 'myLayer',
+ });
+
+ expect(setState).toBeCalledTimes(1);
+ expect(setState).toHaveBeenCalledWith({
+ ...testState,
+ layers: {
+ myLayer: {
+ ...testState.layers.myLayer,
+ columnOrder: ['col1', 'col2'],
+ columns: {
+ ...testState.layers.myLayer.columns,
+ col2: expect.objectContaining({
+ operationType: 'terms',
+ params: expect.objectContaining({ size: 3 }),
+ }),
+ },
+ },
+ },
+ });
+ });
});
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts
index fe14f472341af..73fd144b9c7f8 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts
@@ -8,7 +8,6 @@ import { CoreSetup } from 'kibana/public';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { getIndexPatternDatasource } from './indexpattern';
import { renameColumns } from './rename_columns';
-import { getAutoDate } from './auto_date';
import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
import {
DataPublicPluginSetup,
@@ -31,10 +30,9 @@ export class IndexPatternDatasource {
setup(
core: CoreSetup,
- { data: dataSetup, expressions, editorFrame }: IndexPatternDatasourceSetupPlugins
+ { expressions, editorFrame }: IndexPatternDatasourceSetupPlugins
) {
expressions.registerFunction(renameColumns);
- expressions.registerFunction(getAutoDate({ data: dataSetup }));
editorFrame.registerDatasource(
core.getStartServices().then(([coreStart, { data }]) =>
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
index e4f3677d0fe88..06635e663361d 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -10,6 +10,7 @@ import { DatasourcePublicAPI, Operation, Datasource } from '../types';
import { coreMock } from 'src/core/public/mocks';
import { IndexPatternPersistedState, IndexPatternPrivateState } from './types';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
+import { Ast } from '@kbn/interpreter/common';
jest.mock('./loader');
jest.mock('../id_generator');
@@ -262,20 +263,7 @@ describe('IndexPattern Data Source', () => {
Object {
"arguments": Object {
"aggConfigs": Array [
- Object {
- "chain": Array [
- Object {
- "arguments": Object {
- "aggConfigs": Array [
- "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]",
- ],
- },
- "function": "lens_auto_date",
- "type": "function",
- },
- ],
- "type": "expression",
- },
+ "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]",
],
"includeFormatHints": Array [
true,
@@ -289,6 +277,9 @@ describe('IndexPattern Data Source', () => {
"partialRows": Array [
false,
],
+ "timeFields": Array [
+ "timestamp",
+ ],
},
"function": "esaggs",
"type": "function",
@@ -307,6 +298,89 @@ describe('IndexPattern Data Source', () => {
}
`);
});
+
+ it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => {
+ const queryPersistedState: IndexPatternPersistedState = {
+ currentIndexPatternId: '1',
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2', 'col3'],
+ columns: {
+ col1: {
+ label: 'Count of records',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'Records',
+ operationType: 'count',
+ },
+ col2: {
+ label: 'Date',
+ dataType: 'date',
+ isBucketed: true,
+ operationType: 'date_histogram',
+ sourceField: 'timestamp',
+ params: {
+ interval: 'auto',
+ },
+ },
+ col3: {
+ label: 'Date 2',
+ dataType: 'date',
+ isBucketed: true,
+ operationType: 'date_histogram',
+ sourceField: 'another_datefield',
+ params: {
+ interval: 'auto',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const state = stateFromPersistedState(queryPersistedState);
+
+ const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
+ expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
+ });
+
+ it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => {
+ const queryPersistedState: IndexPatternPersistedState = {
+ currentIndexPatternId: '1',
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2'],
+ columns: {
+ col1: {
+ label: 'Count of records',
+ dataType: 'date',
+ isBucketed: false,
+ sourceField: 'timefield',
+ operationType: 'cardinality',
+ },
+ col2: {
+ label: 'Date',
+ dataType: 'date',
+ isBucketed: true,
+ operationType: 'date_histogram',
+ sourceField: 'timestamp',
+ params: {
+ interval: 'auto',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const state = stateFromPersistedState(queryPersistedState);
+
+ const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
+ expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']);
+ expect(ast.chain[0].arguments.timeFields).not.toContain('timefield');
+ });
});
describe('#insertLayer', () => {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
index 2008b326a539c..f26fd39a60c0e 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
@@ -184,6 +184,7 @@ describe('IndexPattern Data Source suggestions', () => {
id2: expect.objectContaining({
operationType: 'terms',
sourceField: 'source',
+ params: expect.objectContaining({ size: 5 }),
}),
id3: expect.objectContaining({
operationType: 'count',
@@ -388,6 +389,7 @@ describe('IndexPattern Data Source suggestions', () => {
id1: expect.objectContaining({
operationType: 'terms',
sourceField: 'source',
+ params: expect.objectContaining({ size: 5 }),
}),
id2: expect.objectContaining({
operationType: 'count',
@@ -779,7 +781,7 @@ describe('IndexPattern Data Source suggestions', () => {
expect(suggestions[0].table.columns[0].operation.isBucketed).toBeFalsy();
});
- it('appends a terms column on string field', () => {
+ it('appends a terms column with default size on string field', () => {
const initialState = stateWithNonEmptyTables();
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
name: 'dest',
@@ -800,6 +802,7 @@ describe('IndexPattern Data Source suggestions', () => {
id1: expect.objectContaining({
operationType: 'terms',
sourceField: 'dest',
+ params: expect.objectContaining({ size: 3 }),
}),
},
}),
@@ -1549,6 +1552,62 @@ describe('IndexPattern Data Source suggestions', () => {
expect(suggestions[0].table.columns.length).toBe(1);
expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1');
});
+
+ it('contains a reordering suggestion when there are exactly 2 buckets', () => {
+ const initialState = testInitialState();
+ const state: IndexPatternPrivateState = {
+ indexPatternRefs: [],
+ existingFields: {},
+ currentIndexPatternId: '1',
+ indexPatterns: expectedIndexPatterns,
+ showEmptyFields: true,
+ layers: {
+ first: {
+ ...initialState.layers.first,
+ columns: {
+ id1: {
+ label: 'Date histogram',
+ dataType: 'date',
+ isBucketed: true,
+
+ operationType: 'date_histogram',
+ sourceField: 'field2',
+ params: {
+ interval: 'd',
+ },
+ },
+ id2: {
+ label: 'Top 5',
+ dataType: 'string',
+ isBucketed: true,
+
+ operationType: 'terms',
+ sourceField: 'field1',
+ params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' },
+ },
+ id3: {
+ label: 'Average of field1',
+ dataType: 'number',
+ isBucketed: false,
+
+ operationType: 'avg',
+ sourceField: 'field1',
+ },
+ },
+ columnOrder: ['id1', 'id2', 'id3'],
+ },
+ },
+ };
+
+ const suggestions = getDatasourceSuggestionsFromCurrentState(state);
+ expect(suggestions).toContainEqual(
+ expect.objectContaining({
+ table: expect.objectContaining({
+ changeType: 'reorder',
+ }),
+ })
+ );
+ });
});
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
index 2b3e976a77ea7..487c1bf759fc2 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
@@ -17,6 +17,7 @@ import {
OperationType,
} from './operations';
import { operationDefinitions } from './operations/definitions';
+import { TermsIndexPatternColumn } from './operations/definitions/terms';
import { hasField } from './utils';
import {
IndexPattern,
@@ -232,6 +233,10 @@ function addFieldAsBucketOperation(
[newColumnId]: newColumn,
};
+ if (buckets.length === 0 && operation === 'terms') {
+ (newColumn as TermsIndexPatternColumn).params.size = 5;
+ }
+
const oldDateHistogramIndex = layer.columnOrder.findIndex(
columnId => layer.columns[columnId].operationType === 'date_histogram'
);
@@ -327,6 +332,9 @@ function createNewLayerWithBucketAggregation(
field,
suggestedPriority: undefined,
});
+ if (operation === 'terms') {
+ (column as TermsIndexPatternColumn).params.size = 5;
+ }
return {
indexPatternId: indexPattern.id,
@@ -478,7 +486,7 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId
layerId,
updatedLayer,
label: getNestedTitle([layer.columns[secondBucket], layer.columns[firstBucket]]),
- changeType: 'extended',
+ changeType: 'reorder',
});
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
index 3ab51b5fa3f2b..1308fa3b7ca60 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
@@ -10,6 +10,7 @@ import { IndexPatternColumn } from './indexpattern';
import { operationDefinitionMap } from './operations';
import { IndexPattern, IndexPatternPrivateState } from './types';
import { OriginalColumn } from './rename_columns';
+import { dateHistogramOperation } from './operations/definitions';
function getExpressionForLayer(
indexPattern: IndexPattern,
@@ -68,6 +69,12 @@ function getExpressionForLayer(
return base;
});
+ const allDateHistogramFields = Object.values(columns)
+ .map(column =>
+ column.operationType === dateHistogramOperation.type ? column.sourceField : null
+ )
+ .filter((field): field is string => Boolean(field));
+
return {
type: 'expression',
chain: [
@@ -79,20 +86,8 @@ function getExpressionForLayer(
metricsAtAllLevels: [false],
partialRows: [false],
includeFormatHints: [true],
- aggConfigs: [
- {
- type: 'expression',
- chain: [
- {
- type: 'function',
- function: 'lens_auto_date',
- arguments: {
- aggConfigs: [JSON.stringify(aggs)],
- },
- },
- ],
- },
- ],
+ timeFields: allDateHistogramFields,
+ aggConfigs: [JSON.stringify(aggs)],
},
},
{
diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx
index 73b8019a31eaa..04a1c3865f22d 100644
--- a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx
+++ b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx
@@ -53,6 +53,10 @@ export const metricVisualization: Visualization = {
},
],
+ getVisualizationTypeId() {
+ return 'lnsMetric';
+ },
+
clearLayer(state) {
return {
...state,
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 181f192520d0d..04efc642793b0 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -103,9 +103,16 @@ export interface TableSuggestion {
* * `unchanged` means the table is the same in the currently active configuration
* * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them)
* * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns)
+ * * `reorder` means the table columns have changed order, which change the data as well
* * `layers` means the change is a change to the layer structure, not to the table
*/
-export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'layers';
+export type TableChangeType =
+ | 'initial'
+ | 'unchanged'
+ | 'reduced'
+ | 'extended'
+ | 'reorder'
+ | 'layers';
export interface DatasourceSuggestion {
state: T;
@@ -312,6 +319,10 @@ export interface SuggestionRequest {
* The visualization needs to know which table is being suggested
*/
keptLayerIds: string[];
+ /**
+ * Different suggestions can be generated for each subtype of the visualization
+ */
+ subVisualizationId?: string;
}
/**
@@ -388,6 +399,11 @@ export interface Visualization {
* but can register multiple subtypes
*/
visualizationTypes: VisualizationType[];
+ /**
+ * Return the ID of the current visualization. Used to highlight
+ * the active subtype of the visualization.
+ */
+ getVisualizationTypeId: (state: T) => string;
/**
* If the visualization has subtypes, update the subtype in state.
*/
diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts
index 5dfae097be834..3a280fb045b06 100644
--- a/x-pack/plugins/lens/public/xy_visualization/index.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/index.ts
@@ -53,6 +53,7 @@ export class XyVisualization {
? EUI_CHARTS_THEME_DARK.theme
: EUI_CHARTS_THEME_LIGHT.theme,
timeZone: getTimeZone(core.uiSettings),
+ histogramBarTarget: core.uiSettings.get('histogram:barTarget'),
})
);
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx
index e75e5fe763d6a..8db00aba0e36d 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx
@@ -40,7 +40,7 @@ const createSampleDatatableWithRows = (rows: KibanaDatatableRow[]): KibanaDatata
id: 'c',
name: 'c',
formatHint: { id: 'string' },
- meta: { type: 'date-histogram', aggConfigParams: { interval: '10s' } },
+ meta: { type: 'date-histogram', aggConfigParams: { interval: 'auto' } },
},
{ id: 'd', name: 'ColD', formatHint: { id: 'string' } },
],
@@ -156,6 +156,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -203,6 +204,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -237,15 +239,17 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
+ // real auto interval is 30mins = 1800000
expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(`
Object {
"max": 1546491600000,
"min": 1546405200000,
- "minInterval": 10000,
+ "minInterval": 1728000,
}
`);
});
@@ -271,6 +275,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -279,7 +284,7 @@ describe('xy_expression', () => {
Object {
"max": 1546491600000,
"min": 1546405200000,
- "minInterval": 10000,
+ "minInterval": 1728000,
}
`);
});
@@ -307,6 +312,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -350,6 +356,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -383,6 +390,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -398,6 +406,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -414,6 +423,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -430,6 +440,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -472,6 +483,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -510,6 +522,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -527,6 +540,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -547,6 +561,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -565,6 +580,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="CEST"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -582,6 +598,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -606,6 +623,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -624,6 +642,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -684,6 +703,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -878,6 +898,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -894,6 +915,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -910,6 +932,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -927,6 +950,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
@@ -943,6 +967,7 @@ describe('xy_expression', () => {
args={{ ...args, layers: [{ ...args.layers[0], accessors: ['a'] }] }}
formatFactory={getFormatSpy}
chartTheme={{}}
+ histogramBarTarget={50}
timeZone="UTC"
executeTriggerActions={executeTriggerActions}
/>
@@ -963,6 +988,7 @@ describe('xy_expression', () => {
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
+ histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx
index d6b6de479acfb..85cf5753befd7 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx
@@ -6,6 +6,7 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
+import moment from 'moment';
import {
Chart,
Settings,
@@ -35,8 +36,8 @@ import { XYArgs, SeriesType, visualizationTypes } from './types';
import { VisualizationContainer } from '../visualization_container';
import { isHorizontalChart } from './state_helpers';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
-import { parseInterval } from '../../../../../src/plugins/data/common';
import { getExecuteTriggerActions } from './services';
+import { parseInterval } from '../../../../../src/plugins/data/common';
type InferPropType = T extends React.FunctionComponent ? P : T;
type SeriesSpec = InferPropType &
@@ -58,6 +59,7 @@ type XYChartRenderProps = XYChartProps & {
chartTheme: PartialTheme;
formatFactory: FormatFactory;
timeZone: string;
+ histogramBarTarget: number;
executeTriggerActions: UiActionsStart['executeTriggerActions'];
};
@@ -110,6 +112,7 @@ export const xyChart: ExpressionFunctionDefinition<
export const getXyChartRenderer = (dependencies: {
formatFactory: Promise;
chartTheme: PartialTheme;
+ histogramBarTarget: number;
timeZone: string;
}): ExpressionRenderDefinition => ({
name: 'lens_xy_chart_renderer',
@@ -130,6 +133,7 @@ export const getXyChartRenderer = (dependencies: {
formatFactory={formatFactory}
chartTheme={dependencies.chartTheme}
timeZone={dependencies.timeZone}
+ histogramBarTarget={dependencies.histogramBarTarget}
executeTriggerActions={executeTriggerActions}
/>
,
@@ -169,6 +173,7 @@ export function XYChart({
formatFactory,
timeZone,
chartTheme,
+ histogramBarTarget,
executeTriggerActions,
}: XYChartRenderProps) {
const { legend, layers } = args;
@@ -212,18 +217,26 @@ export function XYChart({
const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle;
- // add minInterval only for single row value as it cannot be determined from dataset
+ function calculateMinInterval() {
+ // add minInterval only for single row value as it cannot be determined from dataset
+ if (data.dateRange && layers.every(layer => data.tables[layer.layerId].rows.length <= 1)) {
+ if (xAxisColumn?.meta?.aggConfigParams?.interval !== 'auto')
+ return parseInterval(xAxisColumn?.meta?.aggConfigParams?.interval)?.asMilliseconds();
- const minInterval = layers.every(layer => data.tables[layer.layerId].rows.length <= 1)
- ? parseInterval(xAxisColumn?.meta?.aggConfigParams?.interval)?.asMilliseconds()
- : undefined;
+ const { fromDate, toDate } = data.dateRange;
+ const duration = moment(toDate).diff(moment(fromDate));
+ const targetMs = duration / histogramBarTarget;
+ return isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1);
+ }
+ return undefined;
+ }
const xDomain =
data.dateRange && layers.every(l => l.xScaleType === 'time')
? {
min: data.dateRange.fromDate.getTime(),
max: data.dateRange.toDate.getTime(),
- minInterval,
+ minInterval: calculateMinInterval(),
}
: undefined;
return (
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
index ddbd9d11b5fad..73ff88e97f479 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
@@ -186,7 +186,7 @@ describe('xy_suggestions', () => {
isMultiRow: true,
columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')],
layerId: 'first',
- changeType: 'unchanged',
+ changeType: 'extended',
label: 'Datasource title',
},
keptLayerIds: [],
@@ -196,6 +196,34 @@ describe('xy_suggestions', () => {
expect(suggestion.title).toEqual('Datasource title');
});
+ test('suggests only stacked bar chart when xy chart is inactive', () => {
+ const [suggestion, ...rest] = getSuggestions({
+ table: {
+ isMultiRow: true,
+ columns: [dateCol('date'), numCol('price')],
+ layerId: 'first',
+ changeType: 'unchanged',
+ label: 'Datasource title',
+ },
+ keptLayerIds: [],
+ });
+
+ expect(rest).toHaveLength(0);
+ expect(suggestion.title).toEqual('Bar chart');
+ expect(suggestion.state).toEqual(
+ expect.objectContaining({
+ layers: [
+ expect.objectContaining({
+ seriesType: 'bar_stacked',
+ xAccessor: 'date',
+ accessors: ['price'],
+ splitAccessor: undefined,
+ }),
+ ],
+ })
+ );
+ });
+
test('hides reduced suggestions if there is a current state', () => {
const [suggestion, ...rest] = getSuggestions({
table: {
@@ -224,7 +252,7 @@ describe('xy_suggestions', () => {
expect(suggestion.hide).toBeTruthy();
});
- test('does not hide reduced suggestions if xy visualization is not active', () => {
+ test('hides reduced suggestions if xy visualization is not active', () => {
const [suggestion, ...rest] = getSuggestions({
table: {
isMultiRow: true,
@@ -236,7 +264,7 @@ describe('xy_suggestions', () => {
});
expect(rest).toHaveLength(0);
- expect(suggestion.hide).toBeFalsy();
+ expect(suggestion.hide).toBeTruthy();
});
test('only makes a seriesType suggestion for unchanged table without split', () => {
@@ -419,6 +447,44 @@ describe('xy_suggestions', () => {
});
});
+ test('changes column mappings when suggestion is reorder', () => {
+ const currentState: XYState = {
+ legend: { isVisible: true, position: 'bottom' },
+ preferredSeriesType: 'bar',
+ layers: [
+ {
+ accessors: ['price'],
+ layerId: 'first',
+ seriesType: 'bar',
+ splitAccessor: 'category',
+ xAccessor: 'product',
+ },
+ ],
+ };
+ const [suggestion, ...rest] = getSuggestions({
+ table: {
+ isMultiRow: true,
+ columns: [strCol('category'), strCol('product'), numCol('price')],
+ layerId: 'first',
+ changeType: 'reorder',
+ },
+ state: currentState,
+ keptLayerIds: [],
+ });
+
+ expect(rest).toHaveLength(0);
+ expect(suggestion.state).toEqual({
+ ...currentState,
+ layers: [
+ {
+ ...currentState.layers[0],
+ xAccessor: 'category',
+ splitAccessor: 'product',
+ },
+ ],
+ });
+ });
+
test('overwrites column to dimension mappings if a date dimension is added', () => {
(generateId as jest.Mock).mockReturnValueOnce('dummyCol');
const currentState: XYState = {
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
index 5e9311bb1e928..abd7640344064 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
@@ -99,11 +99,14 @@ function getBucketMappings(table: TableSuggestion, currentState?: State) {
// reverse the buckets before prioritization to always use the most inner
// bucket of the highest-prioritized group as x value (don't use nested
// buckets as split series)
- const prioritizedBuckets = prioritizeColumns(buckets.reverse());
+ const prioritizedBuckets = prioritizeColumns([...buckets].reverse());
if (!currentLayer || table.changeType === 'initial') {
return prioritizedBuckets;
}
+ if (table.changeType === 'reorder') {
+ return buckets;
+ }
// if existing table is just modified, try to map buckets to the current dimensions
const currentXColumnIndex = prioritizedBuckets.findIndex(
@@ -175,12 +178,24 @@ function getSuggestionsForLayer({
keptLayerIds,
};
- const isSameState = currentState && changeType === 'unchanged';
+ // handles the simplest cases, acting as a chart switcher
+ if (!currentState && changeType === 'unchanged') {
+ return [
+ {
+ ...buildSuggestion(options),
+ title: i18n.translate('xpack.lens.xySuggestions.barChartTitle', {
+ defaultMessage: 'Bar chart',
+ }),
+ },
+ ];
+ }
+ const isSameState = currentState && changeType === 'unchanged';
if (!isSameState) {
return buildSuggestion(options);
}
+ // Suggestions are either changing the data, or changing the way the data is used
const sameStateSuggestions: Array> = [];
// if current state is using the same data, suggest same chart with different presentational configuration
@@ -374,8 +389,11 @@ function buildSuggestion({
return {
title,
score: getScore(yValues, splitBy, changeType),
- // don't advertise chart of same type but with less data
- hide: currentState && changeType === 'reduced',
+ hide:
+ // Only advertise very clear changes when XY chart is not active
+ (!currentState && changeType !== 'unchanged' && changeType !== 'extended') ||
+ // Don't advertise removing dimensions
+ (currentState && changeType === 'reduced'),
state,
previewIcon: getIconForSeries(seriesType),
};
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts
index beccf0dc46eb4..d176905c65120 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts
@@ -27,7 +27,7 @@ function exampleState(): State {
}
describe('xy_visualization', () => {
- describe('getDescription', () => {
+ describe('#getDescription', () => {
function mixedState(...types: SeriesType[]) {
const state = exampleState();
return {
@@ -81,6 +81,45 @@ describe('xy_visualization', () => {
});
});
+ describe('#getVisualizationTypeId', () => {
+ function mixedState(...types: SeriesType[]) {
+ const state = exampleState();
+ return {
+ ...state,
+ layers: types.map((t, i) => ({
+ ...state.layers[0],
+ layerId: `layer_${i}`,
+ seriesType: t,
+ })),
+ };
+ }
+
+ it('should show mixed when each layer is different', () => {
+ expect(xyVisualization.getVisualizationTypeId(mixedState('bar', 'line'))).toEqual('mixed');
+ });
+
+ it('should show the preferredSeriesType if there are no layers', () => {
+ expect(xyVisualization.getVisualizationTypeId(mixedState())).toEqual('bar');
+ });
+
+ it('should combine multiple layers into one type', () => {
+ expect(
+ xyVisualization.getVisualizationTypeId(mixedState('bar_horizontal', 'bar_horizontal'))
+ ).toEqual('bar_horizontal');
+ });
+
+ it('should return the subtype for single layers', () => {
+ expect(xyVisualization.getVisualizationTypeId(mixedState('area'))).toEqual('area');
+ expect(xyVisualization.getVisualizationTypeId(mixedState('line'))).toEqual('line');
+ expect(xyVisualization.getVisualizationTypeId(mixedState('area_stacked'))).toEqual(
+ 'area_stacked'
+ );
+ expect(xyVisualization.getVisualizationTypeId(mixedState('bar_horizontal_stacked'))).toEqual(
+ 'bar_horizontal_stacked'
+ );
+ });
+ });
+
describe('#initialize', () => {
it('loads default state', () => {
const mockFrame = createMockFramePublicAPI();
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx
index c72fa0fec24d7..e91edf9cc0183 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx
@@ -12,7 +12,7 @@ import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { getSuggestions } from './xy_suggestions';
import { LayerContextMenu } from './xy_config_panel';
-import { Visualization, OperationMetadata } from '../types';
+import { Visualization, OperationMetadata, VisualizationType } from '../types';
import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types';
import { toExpression, toPreviewExpression } from './to_expression';
import chartBarStackedSVG from '../assets/chart_bar_stacked.svg';
@@ -24,6 +24,18 @@ const defaultSeriesType = 'bar_stacked';
const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number';
const isBucketed = (op: OperationMetadata) => op.isBucketed;
+function getVisualizationType(state: State): VisualizationType | 'mixed' {
+ if (!state.layers.length) {
+ return (
+ visualizationTypes.find(t => t.id === state.preferredSeriesType) ?? visualizationTypes[0]
+ );
+ }
+ const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType);
+ const seriesTypes = _.unique(state.layers.map(l => l.seriesType));
+
+ return visualizationType && seriesTypes.length === 1 ? visualizationType : 'mixed';
+}
+
function getDescription(state?: State) {
if (!state) {
return {
@@ -34,32 +46,31 @@ function getDescription(state?: State) {
};
}
+ const visualizationType = getVisualizationType(state);
+
if (!state.layers.length) {
- const visualizationType = visualizationTypes.find(v => v.id === state.preferredSeriesType)!;
+ const preferredType = visualizationType as VisualizationType;
return {
- icon: visualizationType.largeIcon || visualizationType.icon,
- label: visualizationType.label,
+ icon: preferredType.largeIcon || preferredType.icon,
+ label: preferredType.label,
};
}
- const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType)!;
- const seriesTypes = _.unique(state.layers.map(l => l.seriesType));
-
return {
icon:
- seriesTypes.length === 1
- ? visualizationType.largeIcon || visualizationType.icon
- : chartMixedSVG,
+ visualizationType === 'mixed'
+ ? chartMixedSVG
+ : visualizationType.largeIcon || visualizationType.icon,
label:
- seriesTypes.length === 1
- ? visualizationType.label
- : isHorizontalChart(state.layers)
- ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', {
- defaultMessage: 'Mixed horizontal bar',
- })
- : i18n.translate('xpack.lens.xyVisualization.mixedLabel', {
- defaultMessage: 'Mixed XY',
- }),
+ visualizationType === 'mixed'
+ ? isHorizontalChart(state.layers)
+ ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', {
+ defaultMessage: 'Mixed horizontal bar',
+ })
+ : i18n.translate('xpack.lens.xyVisualization.mixedLabel', {
+ defaultMessage: 'Mixed XY',
+ })
+ : visualizationType.label,
};
}
@@ -67,6 +78,10 @@ export const xyVisualization: Visualization = {
id: 'lnsXY',
visualizationTypes,
+ getVisualizationTypeId(state) {
+ const type = getVisualizationType(state);
+ return type === 'mixed' ? type : type.id;
+ },
getLayerIds(state) {
return state.layers.map(l => l.layerId);
diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts
index e80308cc9acdb..4cc330d40efd7 100644
--- a/x-pack/plugins/lens/server/migrations.test.ts
+++ b/x-pack/plugins/lens/server/migrations.test.ts
@@ -158,4 +158,124 @@ describe('Lens migrations', () => {
]);
});
});
+
+ describe('7.8.0 auto timestamp', () => {
+ const context = {} as SavedObjectMigrationContext;
+
+ const example = {
+ type: 'lens',
+ attributes: {
+ expression: `kibana
+ | kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]"
+ | lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
+ tables={esaggs
+ index="ff959d40-b880-11e8-a6d9-e546fe2bba5f"
+ metricsAtAllLevels=false
+ partialRows=false
+ includeFormatHints=true
+ aggConfigs={
+ lens_auto_date
+ aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]"
+ }
+ | lens_rename_columns idMap="{\\"col-0-1d9cc16c-1460-41de-88f8-471932ecbc97\\":{\\"label\\":\\"products.created_on\\",\\"dataType\\":\\"date\\",\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"products.created_on\\",\\"isBucketed\\":true,\\"scale\\":\\"interval\\",\\"params\\":{\\"interval\\":\\"auto\\"},\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\"},\\"col-1-66115819-8481-4917-a6dc-8ffb10dd02df\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"suggestedPriority\\":0,\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\"}}"
+ }
+ | lens_xy_chart
+ xTitle="products.created_on"
+ yTitle="Count of records"
+ legend={lens_xy_legendConfig isVisible=true position="right"}
+ layers={lens_xy_layer
+ layerId="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
+ hide=false
+ xAccessor="1d9cc16c-1460-41de-88f8-471932ecbc97"
+ yScaleType="linear"
+ xScaleType="time"
+ isHistogram=true
+ seriesType="bar_stacked"
+ accessors="66115819-8481-4917-a6dc-8ffb10dd02df"
+ columnToLabel="{\\"66115819-8481-4917-a6dc-8ffb10dd02df\\":\\"Count of records\\"}"
+ }
+ `,
+ state: {
+ datasourceStates: {
+ indexpattern: {
+ currentIndexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
+ layers: {
+ 'bd09dc71-a7e2-42d0-83bd-85df8291f03c': {
+ indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
+ columns: {
+ '1d9cc16c-1460-41de-88f8-471932ecbc97': {
+ label: 'products.created_on',
+ dataType: 'date',
+ operationType: 'date_histogram',
+ sourceField: 'products.created_on',
+ isBucketed: true,
+ scale: 'interval',
+ params: { interval: 'auto' },
+ },
+ '66115819-8481-4917-a6dc-8ffb10dd02df': {
+ label: 'Count of records',
+ dataType: 'number',
+ operationType: 'count',
+ suggestedPriority: 0,
+ isBucketed: false,
+ scale: 'ratio',
+ sourceField: 'Records',
+ },
+ },
+ columnOrder: [
+ '1d9cc16c-1460-41de-88f8-471932ecbc97',
+ '66115819-8481-4917-a6dc-8ffb10dd02df',
+ ],
+ },
+ },
+ },
+ },
+ datasourceMetaData: {
+ filterableIndexPatterns: [
+ { id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', title: 'kibana_sample_data_ecommerce' },
+ ],
+ },
+ visualization: {
+ legend: { isVisible: true, position: 'right' },
+ preferredSeriesType: 'bar_stacked',
+ layers: [
+ {
+ layerId: 'bd09dc71-a7e2-42d0-83bd-85df8291f03c',
+ accessors: ['66115819-8481-4917-a6dc-8ffb10dd02df'],
+ position: 'top',
+ seriesType: 'bar_stacked',
+ showGridlines: false,
+ xAccessor: '1d9cc16c-1460-41de-88f8-471932ecbc97',
+ },
+ ],
+ },
+ query: { query: '', language: 'kuery' },
+ filters: [],
+ },
+ title: 'Bar chart',
+ visualizationType: 'lnsXY',
+ },
+ };
+
+ it('should remove the lens_auto_date expression', () => {
+ const result = migrations['7.8.0'](example, context);
+ expect(result.attributes.expression).toContain(`timeFields=\"products.created_on\"`);
+ });
+
+ it('should handle pre-migrated expression', () => {
+ const input = {
+ type: 'lens',
+ attributes: {
+ ...example.attributes,
+ expression: `kibana
+| kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]"
+| lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
+ tables={esaggs index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" timeFields=\"products.created_on\"}
+| lens_xy_chart xTitle="products.created_on" yTitle="Count of records" legend={lens_xy_legendConfig isVisible=true position="right"} layers={}`,
+ },
+ };
+ const result = migrations['7.8.0'](input, context);
+ expect(result).toEqual(input);
+ });
+ });
});
diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts
index 3d238723b7438..583fba1a4a999 100644
--- a/x-pack/plugins/lens/server/migrations.ts
+++ b/x-pack/plugins/lens/server/migrations.ts
@@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { cloneDeep } from 'lodash';
+import { cloneDeep, flow } from 'lodash';
+import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common';
import { SavedObjectMigrationFn } from 'src/core/server';
interface XYLayerPre77 {
@@ -14,7 +15,126 @@ interface XYLayerPre77 {
accessors: string[];
}
-export const migrations: Record = {
+/**
+ * Removes the `lens_auto_date` subexpression from a stored expression
+ * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"}
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => {
+ const expression: string = doc.attributes?.expression;
+ try {
+ const ast = fromExpression(expression);
+ const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => {
+ if (topNode.function !== 'lens_merge_tables') {
+ return topNode;
+ }
+ return {
+ ...topNode,
+ arguments: {
+ ...topNode.arguments,
+ tables: (topNode.arguments.tables as Ast[]).map(middleNode => {
+ return {
+ type: 'expression',
+ chain: middleNode.chain.map(node => {
+ // Check for sub-expression in aggConfigs
+ if (
+ node.function === 'esaggs' &&
+ typeof node.arguments.aggConfigs[0] !== 'string'
+ ) {
+ return {
+ ...node,
+ arguments: {
+ ...node.arguments,
+ aggConfigs: (node.arguments.aggConfigs[0] as Ast).chain[0].arguments
+ .aggConfigs,
+ },
+ };
+ }
+ return node;
+ }),
+ };
+ }),
+ },
+ };
+ });
+
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ expression: toExpression({ ...ast, chain: newChain }),
+ },
+ };
+ } catch (e) {
+ context.log.warning(e.message);
+ return { ...doc };
+ }
+};
+
+/**
+ * Adds missing timeField arguments to esaggs in the Lens expression
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => {
+ const expression: string = doc.attributes?.expression;
+
+ try {
+ const ast = fromExpression(expression);
+ const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => {
+ if (topNode.function !== 'lens_merge_tables') {
+ return topNode;
+ }
+ return {
+ ...topNode,
+ arguments: {
+ ...topNode.arguments,
+ tables: (topNode.arguments.tables as Ast[]).map(middleNode => {
+ return {
+ type: 'expression',
+ chain: middleNode.chain.map(node => {
+ // Skip if there are any timeField arguments already, because that indicates
+ // the fix is already applied
+ if (node.function !== 'esaggs' || node.arguments.timeFields) {
+ return node;
+ }
+ const timeFields: string[] = [];
+ JSON.parse(node.arguments.aggConfigs[0] as string).forEach(
+ (agg: { type: string; params: { field: string } }) => {
+ if (agg.type !== 'date_histogram') {
+ return;
+ }
+ timeFields.push(agg.params.field);
+ }
+ );
+ return {
+ ...node,
+ arguments: {
+ ...node.arguments,
+ timeFields,
+ },
+ };
+ }),
+ };
+ }),
+ },
+ };
+ });
+
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ expression: toExpression({ ...ast, chain: newChain }),
+ },
+ };
+ } catch (e) {
+ context.log.warning(e.message);
+ return { ...doc };
+ }
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const migrations: Record> = {
'7.7.0': doc => {
const newDoc = cloneDeep(doc);
if (newDoc.attributes?.visualizationType === 'lnsXY') {
@@ -34,4 +154,7 @@ export const migrations: Record = {
}
return newDoc;
},
+ // The order of these migrations matter, since the timefield migration relies on the aggConfigs
+ // sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was).
+ '7.8.0': flow(removeLensAutoDate, addTimeFieldToEsaggs),
};
diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts
new file mode 100644
index 0000000000000..dbe31fed66413
--- /dev/null
+++ b/x-pack/plugins/lists/common/constants.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Lists routes
+ */
+export const LIST_URL = `/api/lists`;
+export const LIST_INDEX = `${LIST_URL}/index`;
+export const LIST_ITEM_URL = `${LIST_URL}/items`;
diff --git a/x-pack/legacy/plugins/actions/index.ts b/x-pack/plugins/lists/common/schemas/common/index.ts
similarity index 90%
rename from x-pack/legacy/plugins/actions/index.ts
rename to x-pack/plugins/lists/common/schemas/common/index.ts
index 276d1ea3accea..a05e97ded38ee 100644
--- a/x-pack/legacy/plugins/actions/index.ts
+++ b/x-pack/plugins/lists/common/schemas/common/index.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export * from './server/';
+export * from './schemas';
diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts
new file mode 100644
index 0000000000000..edc037ed7a0b1
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { NonEmptyString } from '../types/non_empty_string';
+
+export const name = t.string;
+export type Name = t.TypeOf;
+export const nameOrUndefined = t.union([name, t.undefined]);
+export type NameOrUndefined = t.TypeOf;
+
+export const description = t.string;
+export type Description = t.TypeOf;
+export const descriptionOrUndefined = t.union([description, t.undefined]);
+export type DescriptionOrUndefined = t.TypeOf;
+
+export const list_id = NonEmptyString;
+export const list_idOrUndefined = t.union([list_id, t.undefined]);
+export type List_idOrUndefined = t.TypeOf;
+
+export const item = t.string;
+export const created_at = t.string; // TODO: Make this into an ISO Date string check
+export const updated_at = t.string; // TODO: Make this into an ISO Date string check
+export const updated_by = t.string;
+export const created_by = t.string;
+export const file = t.object;
+
+export const id = NonEmptyString;
+export type Id = t.TypeOf;
+export const idOrUndefined = t.union([id, t.undefined]);
+export type IdOrUndefined = t.TypeOf;
+
+export const ip = t.string;
+export const ipOrUndefined = t.union([ip, t.undefined]);
+
+export const keyword = t.string;
+export const keywordOrUndefined = t.union([keyword, t.undefined]);
+
+export const value = t.string;
+export const valueOrUndefined = t.union([value, t.undefined]);
+
+export const tie_breaker_id = t.string; // TODO: Use UUID for this instead of a string for validation
+export const _index = t.string;
+
+export const type = t.keyof({ ip: null, keyword: null }); // TODO: Add the other data types here
+
+export const typeOrUndefined = t.union([type, t.undefined]);
+export type Type = t.TypeOf;
+
+export const meta = t.object;
+export type Meta = t.TypeOf;
+export const metaOrUndefined = t.union([meta, t.undefined]);
+export type MetaOrUndefined = t.TypeOf;
+
+export const esDataTypeUnion = t.union([t.type({ ip }), t.type({ keyword })]);
+export type EsDataTypeUnion = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/create_es_bulk_type.ts b/x-pack/plugins/lists/common/schemas/elastic_query/create_es_bulk_type.ts
new file mode 100644
index 0000000000000..4a825382c06e4
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/create_es_bulk_type.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+
+import { _index } from '../common/schemas';
+
+export const createEsBulkTypeSchema = t.exact(
+ t.type({
+ create: t.exact(t.type({ _index })),
+ })
+);
+
+export type CreateEsBulkTypeSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/index.ts b/x-pack/plugins/lists/common/schemas/elastic_query/index.ts
new file mode 100644
index 0000000000000..d70dd09849fa6
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export * from './update_es_list_schema';
+export * from './index_es_list_schema';
+export * from './update_es_list_item_schema';
+export * from './index_es_list_item_schema';
+export * from './create_es_bulk_type';
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_item_schema.ts
new file mode 100644
index 0000000000000..596498b64b771
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_item_schema.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ created_at,
+ created_by,
+ esDataTypeUnion,
+ list_id,
+ metaOrUndefined,
+ tie_breaker_id,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const indexEsListItemSchema = t.intersection([
+ t.exact(
+ t.type({
+ created_at,
+ created_by,
+ list_id,
+ meta: metaOrUndefined,
+ tie_breaker_id,
+ updated_at,
+ updated_by,
+ })
+ ),
+ esDataTypeUnion,
+]);
+
+export type IndexEsListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts
new file mode 100644
index 0000000000000..e0924392628a9
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ created_at,
+ created_by,
+ description,
+ metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const indexEsListSchema = t.exact(
+ t.type({
+ created_at,
+ created_by,
+ description,
+ meta: metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ })
+);
+
+export type IndexEsListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_item_schema.ts
new file mode 100644
index 0000000000000..e4cf46bc39429
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_item_schema.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { esDataTypeUnion, metaOrUndefined, updated_at, updated_by } from '../common/schemas';
+
+export const updateEsListItemSchema = t.intersection([
+ t.exact(
+ t.type({
+ meta: metaOrUndefined,
+ updated_at,
+ updated_by,
+ })
+ ),
+ esDataTypeUnion,
+]);
+
+export type UpdateEsListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_schema.ts
new file mode 100644
index 0000000000000..8f23f3744e563
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_query/update_es_list_schema.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ descriptionOrUndefined,
+ metaOrUndefined,
+ nameOrUndefined,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const updateEsListSchema = t.exact(
+ t.type({
+ description: descriptionOrUndefined,
+ meta: metaOrUndefined,
+ name: nameOrUndefined,
+ updated_at,
+ updated_by,
+ })
+);
+
+export type UpdateEsListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_response/index.ts b/x-pack/plugins/lists/common/schemas/elastic_response/index.ts
new file mode 100644
index 0000000000000..6fbc6ef293064
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_response/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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './search_es_list_item_schema';
+export * from './search_es_list_schema';
diff --git a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_item_schema.ts
new file mode 100644
index 0000000000000..902d3e6a9896e
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_item_schema.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ created_at,
+ created_by,
+ ipOrUndefined,
+ keywordOrUndefined,
+ list_id,
+ metaOrUndefined,
+ tie_breaker_id,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const searchEsListItemSchema = t.exact(
+ t.type({
+ created_at,
+ created_by,
+ ip: ipOrUndefined,
+ keyword: keywordOrUndefined,
+ list_id,
+ meta: metaOrUndefined,
+ tie_breaker_id,
+ updated_at,
+ updated_by,
+ })
+);
+
+export type SearchEsListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts
new file mode 100644
index 0000000000000..00a7c6f321d38
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ created_at,
+ created_by,
+ description,
+ metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const searchEsListSchema = t.exact(
+ t.type({
+ created_at,
+ created_by,
+ description,
+ meta: metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ })
+);
+
+export type SearchEsListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/index.ts b/x-pack/plugins/lists/common/schemas/index.ts
new file mode 100644
index 0000000000000..6a60a6df55691
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './common';
+export * from './request';
+export * from './response';
+export * from './elastic_query';
+export * from './elastic_response';
diff --git a/x-pack/plugins/lists/common/schemas/request/create_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_list_item_schema.ts
new file mode 100644
index 0000000000000..8168e5a9838f2
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/create_list_item_schema.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { idOrUndefined, list_id, metaOrUndefined, value } from '../common/schemas';
+
+export const createListItemSchema = t.exact(
+ t.type({
+ id: idOrUndefined,
+ list_id,
+ meta: metaOrUndefined,
+ value,
+ })
+);
+
+export type CreateListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/create_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_list_schema.test.ts
new file mode 100644
index 0000000000000..ba791a55d17eb
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/create_list_schema.test.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { left } from 'fp-ts/lib/Either';
+import { pipe } from 'fp-ts/lib/pipeable';
+
+import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps';
+
+import { getListRequest } from './mocks/utils';
+import { createListSchema } from './create_list_schema';
+
+describe('create_list_schema', () => {
+ // TODO: Finish the tests for this
+ test('it should validate a typical lists request', () => {
+ const payload = getListRequest();
+ const decoded = createListSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual({
+ description: 'Description of a list item',
+ id: 'some-list-id',
+ name: 'Name of a list item',
+ type: 'ip',
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts
new file mode 100644
index 0000000000000..353a4ecdafa0c
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { description, idOrUndefined, metaOrUndefined, name, type } from '../common/schemas';
+
+export const createListSchema = t.exact(
+ t.type({
+ description,
+ id: idOrUndefined,
+ meta: metaOrUndefined,
+ name,
+ type,
+ })
+);
+
+export type CreateListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/delete_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_list_item_schema.ts
new file mode 100644
index 0000000000000..f4c1fb5c43eb0
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/delete_list_item_schema.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { idOrUndefined, list_idOrUndefined, valueOrUndefined } from '../common/schemas';
+
+export const deleteListItemSchema = t.exact(
+ t.type({
+ id: idOrUndefined,
+ list_id: list_idOrUndefined,
+ value: valueOrUndefined,
+ })
+);
+
+export type DeleteListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts
new file mode 100644
index 0000000000000..fd6aa5b85f81a
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { id } from '../common/schemas';
+
+export const deleteListSchema = t.exact(
+ t.type({
+ id,
+ })
+);
+
+export type DeleteListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts b/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts
new file mode 100644
index 0000000000000..14b201bf8089d
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { list_id } from '../common/schemas';
+
+export const exportListItemQuerySchema = t.exact(
+ t.type({
+ list_id,
+ // TODO: Add file_name here with a default value
+ })
+);
+
+export type ExportListItemQuerySchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts b/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts
new file mode 100644
index 0000000000000..b8467d141bdd8
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { list_idOrUndefined, typeOrUndefined } from '../common/schemas';
+
+export const importListItemQuerySchema = t.exact(
+ t.type({ list_id: list_idOrUndefined, type: typeOrUndefined })
+);
+
+export type ImportListItemQuerySchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts
new file mode 100644
index 0000000000000..0cf01db8617f0
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import { Readable } from 'stream';
+
+import * as t from 'io-ts';
+
+import { file } from '../common/schemas';
+
+export const importListItemSchema = t.exact(
+ t.type({
+ file,
+ })
+);
+
+export interface HapiReadableStream extends Readable {
+ hapi: {
+ filename: string;
+ };
+}
+
+/**
+ * Special interface since we are streaming in a file through a reader
+ */
+export interface ImportListItemSchema {
+ file: HapiReadableStream;
+}
diff --git a/x-pack/plugins/lists/common/schemas/request/index.ts b/x-pack/plugins/lists/common/schemas/request/index.ts
new file mode 100644
index 0000000000000..d332ab1eb1bab
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './create_list_item_schema';
+export * from './create_list_schema';
+export * from './delete_list_item_schema';
+export * from './delete_list_schema';
+export * from './export_list_item_query_schema';
+export * from './import_list_item_schema';
+export * from './patch_list_item_schema';
+export * from './patch_list_schema';
+export * from './read_list_item_schema';
+export * from './read_list_schema';
+export * from './import_list_item_query_schema';
+export * from './update_list_schema';
+export * from './update_list_item_schema';
diff --git a/x-pack/plugins/lists/common/schemas/request/mocks/utils.ts b/x-pack/plugins/lists/common/schemas/request/mocks/utils.ts
new file mode 100644
index 0000000000000..e5d189db8490b
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/mocks/utils.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateListSchema } from '../create_list_schema';
+
+export const getListRequest = (): CreateListSchema => ({
+ description: 'Description of a list item',
+ id: 'some-list-id',
+ meta: undefined,
+ name: 'Name of a list item',
+ type: 'ip',
+});
diff --git a/x-pack/plugins/lists/common/schemas/request/patch_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/patch_list_item_schema.ts
new file mode 100644
index 0000000000000..3e8198a5109b3
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/patch_list_item_schema.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { id, metaOrUndefined, valueOrUndefined } from '../common/schemas';
+
+export const patchListItemSchema = t.exact(
+ t.type({
+ id,
+ meta: metaOrUndefined,
+ value: valueOrUndefined,
+ })
+);
+
+export type PatchListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts
new file mode 100644
index 0000000000000..efcb81fc8be2a
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { descriptionOrUndefined, id, metaOrUndefined, nameOrUndefined } from '../common/schemas';
+
+export const patchListSchema = t.exact(
+ t.type({
+ description: descriptionOrUndefined,
+ id,
+ meta: metaOrUndefined,
+ name: nameOrUndefined,
+ })
+);
+
+export type PatchListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/read_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_list_item_schema.ts
new file mode 100644
index 0000000000000..9ea14a2a21ed8
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/read_list_item_schema.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { idOrUndefined, list_idOrUndefined, valueOrUndefined } from '../common/schemas';
+
+export const readListItemSchema = t.exact(
+ t.type({ id: idOrUndefined, list_id: list_idOrUndefined, value: valueOrUndefined })
+);
+
+export type ReadListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/read_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_list_schema.ts
new file mode 100644
index 0000000000000..8803346709c31
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/read_list_schema.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { id } from '../common/schemas';
+
+export const readListSchema = t.exact(
+ t.type({
+ id,
+ })
+);
+
+export type ReadListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/update_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_list_item_schema.ts
new file mode 100644
index 0000000000000..e1f88bae66e0f
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/update_list_item_schema.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { id, metaOrUndefined, value } from '../common/schemas';
+
+export const updateListItemSchema = t.exact(
+ t.type({
+ id,
+ meta: metaOrUndefined,
+ value,
+ })
+);
+
+export type UpdateListItemSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts
new file mode 100644
index 0000000000000..d51ed60c41b56
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import { description, id, metaOrUndefined, name } from '../common/schemas';
+
+export const updateListSchema = t.exact(
+ t.type({
+ description,
+ id,
+ meta: metaOrUndefined,
+ name,
+ })
+);
+
+export type UpdateListSchema = t.TypeOf;
diff --git a/x-pack/legacy/plugins/uptime/common/graphql/resolver_types.ts b/x-pack/plugins/lists/common/schemas/response/acknowledge_schema.ts
similarity index 57%
rename from x-pack/legacy/plugins/uptime/common/graphql/resolver_types.ts
rename to x-pack/plugins/lists/common/schemas/response/acknowledge_schema.ts
index 22df610d2d516..55aaf587ac06b 100644
--- a/x-pack/legacy/plugins/uptime/common/graphql/resolver_types.ts
+++ b/x-pack/plugins/lists/common/schemas/response/acknowledge_schema.ts
@@ -4,10 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-type UMResolverResult = Promise | T;
+import * as t from 'io-ts';
-export type UMResolver = (
- parent: Parent,
- args: Args,
- context: Context
-) => UMResolverResult;
+export const acknowledgeSchema = t.type({ acknowledged: t.boolean });
+
+export type AcknowledgeSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/response/index.ts b/x-pack/plugins/lists/common/schemas/response/index.ts
new file mode 100644
index 0000000000000..3f11adf58d8d4
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/response/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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './list_item_schema';
+export * from './list_schema';
+export * from './acknowledge_schema';
+export * from './list_item_index_exist_schema';
diff --git a/x-pack/plugins/lists/common/schemas/response/list_item_index_exist_schema.ts b/x-pack/plugins/lists/common/schemas/response/list_item_index_exist_schema.ts
new file mode 100644
index 0000000000000..bf2bf21d2c216
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/response/list_item_index_exist_schema.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+
+export const listItemIndexExistSchema = t.type({
+ list_index: t.boolean,
+ list_item_index: t.boolean,
+});
+
+export type ListItemIndexExistSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/response/list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/list_item_schema.ts
new file mode 100644
index 0000000000000..6c2f2ed9a7095
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/response/list_item_schema.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import {
+ created_at,
+ created_by,
+ id,
+ list_id,
+ metaOrUndefined,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ value,
+} from '../common/schemas';
+
+export const listItemSchema = t.exact(
+ t.type({
+ created_at,
+ created_by,
+ id,
+ list_id,
+ meta: metaOrUndefined,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ value,
+ })
+);
+
+export type ListItemSchema = t.TypeOf;
+
+export const listItemArraySchema = t.array(listItemSchema);
+export type ListItemArraySchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/response/list_schema.ts b/x-pack/plugins/lists/common/schemas/response/list_schema.ts
new file mode 100644
index 0000000000000..cad449766ceb4
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/response/list_schema.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import * as t from 'io-ts';
+
+import {
+ created_at,
+ created_by,
+ description,
+ id,
+ metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+} from '../common/schemas';
+
+export const listSchema = t.exact(
+ t.type({
+ created_at,
+ created_by,
+ description,
+ id,
+ meta: metaOrUndefined,
+ name,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ })
+);
+
+export type ListSchema = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string.ts
new file mode 100644
index 0000000000000..d1e2094bbcad3
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+import { Either } from 'fp-ts/lib/Either';
+
+export type NonEmptyStringC = t.Type;
+
+/**
+ * Types the NonEmptyString as:
+ * - A string that is not empty
+ */
+export const NonEmptyString: NonEmptyStringC = new t.Type(
+ 'NonEmptyString',
+ t.string.is,
+ (input, context): Either => {
+ if (typeof input === 'string' && input.trim() !== '') {
+ return t.success(input);
+ } else {
+ return t.failure(input, context);
+ }
+ },
+ t.identity
+);
diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts
new file mode 100644
index 0000000000000..5e74753a6f0bd
--- /dev/null
+++ b/x-pack/plugins/lists/common/siem_common_deps.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { getPaths, foldLeftRight } from '../../siem/server/utils/build_validation/__mocks__/utils';
+export { exactCheck } from '../../siem/server/utils/build_validation/exact_check';
diff --git a/x-pack/plugins/lists/kibana.json b/x-pack/plugins/lists/kibana.json
new file mode 100644
index 0000000000000..b7aaac6d3fc76
--- /dev/null
+++ b/x-pack/plugins/lists/kibana.json
@@ -0,0 +1,10 @@
+{
+ "configPath": ["xpack", "lists"],
+ "id": "lists",
+ "kibanaVersion": "kibana",
+ "requiredPlugins": [],
+ "optionalPlugins": ["spaces", "security"],
+ "server": true,
+ "ui": false,
+ "version": "8.0.0"
+}
diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts
new file mode 100644
index 0000000000000..3e7995b2ce8d0
--- /dev/null
+++ b/x-pack/plugins/lists/server/config.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { TypeOf, schema } from '@kbn/config-schema';
+
+export const ConfigSchema = schema.object({
+ enabled: schema.boolean({ defaultValue: false }),
+ listIndex: schema.string({ defaultValue: '.lists' }),
+ listItemIndex: schema.string({ defaultValue: '.items' }),
+});
+
+export type ConfigType = TypeOf;
diff --git a/x-pack/plugins/lists/server/create_config.ts b/x-pack/plugins/lists/server/create_config.ts
new file mode 100644
index 0000000000000..3158fabda935f
--- /dev/null
+++ b/x-pack/plugins/lists/server/create_config.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { map } from 'rxjs/operators';
+import { PluginInitializerContext } from 'kibana/server';
+import { Observable } from 'rxjs';
+
+import { ConfigType } from './config';
+
+export const createConfig$ = (
+ context: PluginInitializerContext
+): Observable> => {
+ return context.config.create().pipe(map(config => config));
+};
diff --git a/x-pack/plugins/lists/server/error_with_status_code.ts b/x-pack/plugins/lists/server/error_with_status_code.ts
new file mode 100644
index 0000000000000..f9bbbc4abad27
--- /dev/null
+++ b/x-pack/plugins/lists/server/error_with_status_code.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export class ErrorWithStatusCode extends Error {
+ private readonly statusCode: number;
+
+ constructor(message: string, statusCode: number) {
+ super(message);
+ this.statusCode = statusCode;
+ }
+
+ public getStatusCode = (): number => this.statusCode;
+}
diff --git a/x-pack/plugins/lists/server/get_space_id.test.ts b/x-pack/plugins/lists/server/get_space_id.test.ts
new file mode 100644
index 0000000000000..9c1d11b71984d
--- /dev/null
+++ b/x-pack/plugins/lists/server/get_space_id.test.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { httpServerMock } from 'src/core/server/mocks';
+import { KibanaRequest } from 'src/core/server';
+
+import { spacesServiceMock } from '../../spaces/server/spaces_service/spaces_service.mock';
+
+import { getSpaceId } from './get_space_id';
+
+describe('get_space_id', () => {
+ let request = KibanaRequest.from(httpServerMock.createRawRequest({}));
+ beforeEach(() => {
+ request = KibanaRequest.from(httpServerMock.createRawRequest({}));
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns "default" as the space id given a space id of "default"', () => {
+ const spaces = spacesServiceMock.createSetupContract();
+ const space = getSpaceId({ request, spaces });
+ expect(space).toEqual('default');
+ });
+
+ test('it returns "another-space" as the space id given a space id of "another-space"', () => {
+ const spaces = spacesServiceMock.createSetupContract('another-space');
+ const space = getSpaceId({ request, spaces });
+ expect(space).toEqual('another-space');
+ });
+
+ test('it returns "default" as the space id given a space id of undefined', () => {
+ const space = getSpaceId({ request, spaces: undefined });
+ expect(space).toEqual('default');
+ });
+
+ test('it returns "default" as the space id given a space id of null', () => {
+ const space = getSpaceId({ request, spaces: null });
+ expect(space).toEqual('default');
+ });
+});
diff --git a/x-pack/plugins/lists/server/get_space_id.ts b/x-pack/plugins/lists/server/get_space_id.ts
new file mode 100644
index 0000000000000..f224e37e04467
--- /dev/null
+++ b/x-pack/plugins/lists/server/get_space_id.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { KibanaRequest } from 'kibana/server';
+
+import { SpacesServiceSetup } from '../../spaces/server';
+
+export const getSpaceId = ({
+ spaces,
+ request,
+}: {
+ spaces: SpacesServiceSetup | undefined | null;
+ request: KibanaRequest;
+}): string => spaces?.getSpaceId(request) ?? 'default';
diff --git a/x-pack/plugins/lists/server/get_user.test.ts b/x-pack/plugins/lists/server/get_user.test.ts
new file mode 100644
index 0000000000000..0992e3c361fcf
--- /dev/null
+++ b/x-pack/plugins/lists/server/get_user.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { httpServerMock } from 'src/core/server/mocks';
+import { KibanaRequest } from 'src/core/server';
+
+import { securityMock } from '../../security/server/mocks';
+import { SecurityPluginSetup } from '../../security/server';
+
+import { getUser } from './get_user';
+
+describe('get_user', () => {
+ let request = KibanaRequest.from(httpServerMock.createRawRequest({}));
+ beforeEach(() => {
+ jest.clearAllMocks();
+ request = KibanaRequest.from(httpServerMock.createRawRequest({}));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns "bob" as the user given a security request with "bob"', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'bob' });
+ const user = getUser({ request, security });
+ expect(user).toEqual('bob');
+ });
+
+ test('it returns "alice" as the user given a security request with "alice"', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'alice' });
+ const user = getUser({ request, security });
+ expect(user).toEqual('alice');
+ });
+
+ test('it returns "elastic" as the user given null as the current user', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue(null);
+ const user = getUser({ request, security });
+ expect(user).toEqual('elastic');
+ });
+
+ test('it returns "elastic" as the user given undefined as the current user', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined);
+ const user = getUser({ request, security });
+ expect(user).toEqual('elastic');
+ });
+
+ test('it returns "elastic" as the user given undefined as the plugin', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined);
+ const user = getUser({ request, security: undefined });
+ expect(user).toEqual('elastic');
+ });
+
+ test('it returns "elastic" as the user given null as the plugin', () => {
+ const security: SecurityPluginSetup = securityMock.createSetup();
+ security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined);
+ const user = getUser({ request, security: null });
+ expect(user).toEqual('elastic');
+ });
+});
diff --git a/x-pack/plugins/lists/server/get_user.ts b/x-pack/plugins/lists/server/get_user.ts
new file mode 100644
index 0000000000000..3b59853d0ab62
--- /dev/null
+++ b/x-pack/plugins/lists/server/get_user.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { KibanaRequest } from 'kibana/server';
+
+import { SecurityPluginSetup } from '../../security/server';
+
+export interface GetUserOptions {
+ security: SecurityPluginSetup | null | undefined;
+ request: KibanaRequest;
+}
+
+export const getUser = ({ security, request }: GetUserOptions): string => {
+ if (security != null) {
+ const authenticatedUser = security.authc.getCurrentUser(request);
+ if (authenticatedUser != null) {
+ return authenticatedUser.username;
+ } else {
+ return 'elastic';
+ }
+ } else {
+ return 'elastic';
+ }
+};
diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts
new file mode 100644
index 0000000000000..c1e577aa60195
--- /dev/null
+++ b/x-pack/plugins/lists/server/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PluginInitializerContext } from '../../../../src/core/server';
+
+import { ConfigSchema } from './config';
+import { ListPlugin } from './plugin';
+
+export const config = { schema: ConfigSchema };
+export const plugin = (initializerContext: PluginInitializerContext): ListPlugin =>
+ new ListPlugin(initializerContext);
diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts
new file mode 100644
index 0000000000000..2498c36967a53
--- /dev/null
+++ b/x-pack/plugins/lists/server/plugin.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { first } from 'rxjs/operators';
+import { Logger, PluginInitializerContext } from 'kibana/server';
+import { CoreSetup } from 'src/core/server';
+
+import { SecurityPluginSetup } from '../../security/server';
+import { SpacesServiceSetup } from '../../spaces/server';
+
+import { ConfigType } from './config';
+import { initRoutes } from './routes/init_routes';
+import { ListClient } from './services/lists/client';
+import { ContextProvider, ContextProviderReturn, PluginsSetup } from './types';
+import { createConfig$ } from './create_config';
+import { getSpaceId } from './get_space_id';
+import { getUser } from './get_user';
+
+export class ListPlugin {
+ private readonly logger: Logger;
+ private spaces: SpacesServiceSetup | undefined | null;
+ private config: ConfigType | undefined | null;
+ private security: SecurityPluginSetup | undefined | null;
+
+ constructor(private readonly initializerContext: PluginInitializerContext) {
+ this.logger = this.initializerContext.logger.get();
+ }
+
+ public async setup(core: CoreSetup, plugins: PluginsSetup): Promise {
+ const config = await createConfig$(this.initializerContext)
+ .pipe(first())
+ .toPromise();
+
+ this.logger.error(
+ 'You have activated the lists values feature flag which is NOT currently supported for Elastic Security! You should turn this feature flag off immediately by un-setting "xpack.lists.enabled: true" in kibana.yml and restarting Kibana'
+ );
+ this.spaces = plugins.spaces?.spacesService;
+ this.config = config;
+ this.security = plugins.security;
+
+ core.http.registerRouteHandlerContext('lists', this.createRouteHandlerContext());
+ const router = core.http.createRouter();
+ initRoutes(router);
+ }
+
+ public start(): void {
+ this.logger.debug('Starting plugin');
+ }
+
+ public stop(): void {
+ this.logger.debug('Stopping plugin');
+ }
+
+ private createRouteHandlerContext = (): ContextProvider => {
+ return async (context, request): ContextProviderReturn => {
+ const { spaces, config, security } = this;
+ const {
+ core: {
+ elasticsearch: {
+ dataClient: { callAsCurrentUser },
+ },
+ },
+ } = context;
+ if (config == null) {
+ throw new TypeError('Configuration is required for this plugin to operate');
+ } else {
+ const spaceId = getSpaceId({ request, spaces });
+ const user = getUser({ request, security });
+ return {
+ getListClient: (): ListClient =>
+ new ListClient({
+ callCluster: callAsCurrentUser,
+ config,
+ request,
+ security,
+ spaceId,
+ user,
+ }),
+ };
+ }
+ };
+ };
+}
diff --git a/x-pack/plugins/lists/server/routes/create_list_index_route.ts b/x-pack/plugins/lists/server/routes/create_list_index_route.ts
new file mode 100644
index 0000000000000..1c893fb757c5d
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/create_list_index_route.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { LIST_INDEX } from '../../common/constants';
+import { acknowledgeSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const createListIndexRoute = (router: IRouter): void => {
+ router.post(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_INDEX,
+ validate: false,
+ },
+ async (context, _, response) => {
+ const siemResponse = buildSiemResponse(response);
+
+ try {
+ const lists = getListClient(context);
+ const listIndexExists = await lists.getListIndexExists();
+ const listItemIndexExists = await lists.getListItemIndexExists();
+
+ if (listIndexExists && listItemIndexExists) {
+ return siemResponse.error({
+ body: `index: "${lists.getListIndex()}" and "${lists.getListItemIndex()}" already exists`,
+ statusCode: 409,
+ });
+ } else {
+ const policyExists = await lists.getListPolicyExists();
+ const policyListItemExists = await lists.getListItemPolicyExists();
+
+ if (!policyExists) {
+ await lists.setListPolicy();
+ }
+ if (!policyListItemExists) {
+ await lists.setListItemPolicy();
+ }
+
+ const templateExists = await lists.getListTemplateExists();
+ const templateListItemsExists = await lists.getListItemTemplateExists();
+
+ if (!templateExists) {
+ await lists.setListTemplate();
+ }
+
+ if (!templateListItemsExists) {
+ await lists.setListItemTemplate();
+ }
+
+ if (!listIndexExists) {
+ await lists.createListBootStrapIndex();
+ }
+ if (!listItemIndexExists) {
+ await lists.createListItemBootStrapIndex();
+ }
+
+ const [validated, errors] = validate({ acknowledged: true }, acknowledgeSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/create_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_list_item_route.ts
new file mode 100644
index 0000000000000..68622e98cbc52
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/create_list_item_route.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { createListItemSchema, listItemSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const createListItemRoute = (router: IRouter): void => {
+ router.post(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_ITEM_URL,
+ validate: {
+ body: buildRouteValidation(createListItemSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { id, list_id: listId, value, meta } = request.body;
+ const lists = getListClient(context);
+ const list = await lists.getList({ id: listId });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${listId}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const listItem = await lists.getListItemByValue({ listId, type: list.type, value });
+ if (listItem.length !== 0) {
+ return siemResponse.error({
+ body: `list_id: "${listId}" already contains the given value: ${value}`,
+ statusCode: 409,
+ });
+ } else {
+ const createdListItem = await lists.createListItem({
+ id,
+ listId,
+ meta,
+ type: list.type,
+ value,
+ });
+ const [validated, errors] = validate(createdListItem, listItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/create_list_route.ts b/x-pack/plugins/lists/server/routes/create_list_route.ts
new file mode 100644
index 0000000000000..0f3c404c53cfd
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/create_list_route.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { createListSchema, listSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const createListRoute = (router: IRouter): void => {
+ router.post(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_URL,
+ validate: {
+ body: buildRouteValidation(createListSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { name, description, id, type, meta } = request.body;
+ const lists = getListClient(context);
+ const listExists = await lists.getListIndexExists();
+ if (!listExists) {
+ return siemResponse.error({
+ body: `To create a list, the index must exist first. Index "${lists.getListIndex()}" does not exist`,
+ statusCode: 400,
+ });
+ } else {
+ if (id != null) {
+ const list = await lists.getList({ id });
+ if (list != null) {
+ return siemResponse.error({
+ body: `list id: "${id}" already exists`,
+ statusCode: 409,
+ });
+ }
+ }
+ const list = await lists.createList({ description, id, meta, name, type });
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/delete_list_index_route.ts b/x-pack/plugins/lists/server/routes/delete_list_index_route.ts
new file mode 100644
index 0000000000000..424c3f45aac40
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/delete_list_index_route.ts
@@ -0,0 +1,97 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_INDEX } from '../../common/constants';
+import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { acknowledgeSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+/**
+ * Deletes all of the indexes, template, ilm policies, and aliases. You can check
+ * this by looking at each of these settings from ES after a deletion:
+ *
+ * GET /_template/.lists-default
+ * GET /.lists-default-000001/
+ * GET /_ilm/policy/.lists-default
+ * GET /_alias/.lists-default
+ *
+ * GET /_template/.items-default
+ * GET /.items-default-000001/
+ * GET /_ilm/policy/.items-default
+ * GET /_alias/.items-default
+ *
+ * And ensuring they're all gone
+ */
+export const deleteListIndexRoute = (router: IRouter): void => {
+ router.delete(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_INDEX,
+ validate: false,
+ },
+ async (context, _, response) => {
+ const siemResponse = buildSiemResponse(response);
+
+ try {
+ const lists = getListClient(context);
+ const listIndexExists = await lists.getListIndexExists();
+ const listItemIndexExists = await lists.getListItemIndexExists();
+
+ if (!listIndexExists && !listItemIndexExists) {
+ return siemResponse.error({
+ body: `index: "${lists.getListIndex()}" and "${lists.getListItemIndex()}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ if (listIndexExists) {
+ await lists.deleteListIndex();
+ }
+ if (listItemIndexExists) {
+ await lists.deleteListItemIndex();
+ }
+
+ const listsPolicyExists = await lists.getListPolicyExists();
+ const listItemPolicyExists = await lists.getListItemPolicyExists();
+
+ if (listsPolicyExists) {
+ await lists.deleteListPolicy();
+ }
+ if (listItemPolicyExists) {
+ await lists.deleteListItemPolicy();
+ }
+
+ const listsTemplateExists = await lists.getListTemplateExists();
+ const listItemTemplateExists = await lists.getListItemTemplateExists();
+
+ if (listsTemplateExists) {
+ await lists.deleteListTemplate();
+ }
+ if (listItemTemplateExists) {
+ await lists.deleteListItemTemplate();
+ }
+
+ const [validated, errors] = validate({ acknowledged: true }, acknowledgeSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/delete_list_item_route.ts b/x-pack/plugins/lists/server/routes/delete_list_item_route.ts
new file mode 100644
index 0000000000000..51b4eb9f02cc2
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/delete_list_item_route.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { deleteListItemSchema, listItemArraySchema, listItemSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const deleteListItemRoute = (router: IRouter): void => {
+ router.delete(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_ITEM_URL,
+ validate: {
+ query: buildRouteValidation(deleteListItemSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { id, list_id: listId, value } = request.query;
+ const lists = getListClient(context);
+ if (id != null) {
+ const deleted = await lists.deleteListItem({ id });
+ if (deleted == null) {
+ return siemResponse.error({
+ body: `list item with id: "${id}" item not found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(deleted, listItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } else if (listId != null && value != null) {
+ const list = await lists.getList({ id: listId });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list_id: "${listId}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const deleted = await lists.deleteListItemByValue({ listId, type: list.type, value });
+ if (deleted == null || deleted.length === 0) {
+ return siemResponse.error({
+ body: `list_id: "${listId}" with ${value} was not found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(deleted, listItemArraySchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ }
+ } else {
+ return siemResponse.error({
+ body: `Either "list_id" or "id" needs to be defined in the request`,
+ statusCode: 400,
+ });
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/delete_list_route.ts b/x-pack/plugins/lists/server/routes/delete_list_route.ts
new file mode 100644
index 0000000000000..e89355b7689c5
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/delete_list_route.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { deleteListSchema, listSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const deleteListRoute = (router: IRouter): void => {
+ router.delete(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_URL,
+ validate: {
+ query: buildRouteValidation(deleteListSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const lists = getListClient(context);
+ const { id } = request.query;
+ const deleted = await lists.deleteList({ id });
+ if (deleted == null) {
+ return siemResponse.error({
+ body: `list id: "${id}" was not found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(deleted, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/export_list_item_route.ts b/x-pack/plugins/lists/server/routes/export_list_item_route.ts
new file mode 100644
index 0000000000000..32b99bfc512bf
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/export_list_item_route.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Stream } from 'stream';
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { exportListItemQuerySchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const exportListItemRoute = (router: IRouter): void => {
+ router.post(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: `${LIST_ITEM_URL}/_export`,
+ validate: {
+ query: buildRouteValidation(exportListItemQuerySchema),
+ // TODO: Do we want to add a body here like export_rules_route and allow a size limit?
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { list_id: listId } = request.query;
+ const lists = getListClient(context);
+ const list = await lists.getList({ id: listId });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list_id: ${listId} does not exist`,
+ statusCode: 400,
+ });
+ } else {
+ // TODO: Allow the API to override the name of the file to export
+ const fileName = list.name;
+
+ const stream = new Stream.PassThrough();
+ lists.exportListItemsToStream({ listId, stream, stringToAppend: '\n' });
+ return response.ok({
+ body: stream,
+ headers: {
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
+ 'Content-Type': 'text/plain',
+ },
+ });
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts
new file mode 100644
index 0000000000000..a3b6a520a4ecf
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts
@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import {
+ ImportListItemSchema,
+ importListItemQuerySchema,
+ importListItemSchema,
+ listSchema,
+} from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const importListItemRoute = (router: IRouter): void => {
+ router.post(
+ {
+ options: {
+ body: {
+ output: 'stream',
+ },
+ tags: ['access:lists'],
+ },
+ path: `${LIST_ITEM_URL}/_import`,
+ validate: {
+ body: buildRouteValidation(
+ importListItemSchema
+ ),
+ query: buildRouteValidation(importListItemQuerySchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { list_id: listId, type } = request.query;
+ const lists = getListClient(context);
+ if (listId != null) {
+ const list = await lists.getList({ id: listId });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${listId}" does not exist`,
+ statusCode: 409,
+ });
+ }
+ await lists.importListItemsToStream({
+ listId,
+ meta: undefined,
+ stream: request.body.file,
+ type: list.type,
+ });
+
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ } else if (type != null) {
+ const { filename } = request.body.file.hapi;
+ // TODO: Should we prevent the same file from being uploaded multiple times?
+ const list = await lists.createListIfItDoesNotExist({
+ description: `File uploaded from file system of ${filename}`,
+ id: filename,
+ meta: undefined,
+ name: filename,
+ type,
+ });
+ await lists.importListItemsToStream({
+ listId: list.id,
+ meta: undefined,
+ stream: request.body.file,
+ type: list.type,
+ });
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ } else {
+ return siemResponse.error({
+ body: 'Either type or list_id need to be defined in the query',
+ statusCode: 400,
+ });
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/index.ts b/x-pack/plugins/lists/server/routes/index.ts
new file mode 100644
index 0000000000000..4951cddc56939
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './create_list_index_route';
+export * from './create_list_item_route';
+export * from './create_list_route';
+export * from './delete_list_index_route';
+export * from './delete_list_item_route';
+export * from './delete_list_route';
+export * from './export_list_item_route';
+export * from './import_list_item_route';
+export * from './init_routes';
+export * from './patch_list_item_route';
+export * from './patch_list_route';
+export * from './read_list_index_route';
+export * from './read_list_item_route';
+export * from './read_list_route';
+export * from './utils';
diff --git a/x-pack/plugins/lists/server/routes/init_routes.ts b/x-pack/plugins/lists/server/routes/init_routes.ts
new file mode 100644
index 0000000000000..924dd086ee708
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/init_routes.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { updateListRoute } from './update_list_route';
+import { updateListItemRoute } from './update_list_item_route';
+
+import {
+ createListIndexRoute,
+ createListItemRoute,
+ createListRoute,
+ deleteListIndexRoute,
+ deleteListItemRoute,
+ deleteListRoute,
+ exportListItemRoute,
+ importListItemRoute,
+ patchListItemRoute,
+ patchListRoute,
+ readListIndexRoute,
+ readListItemRoute,
+ readListRoute,
+} from '.';
+
+export const initRoutes = (router: IRouter): void => {
+ // lists
+ createListRoute(router);
+ readListRoute(router);
+ updateListRoute(router);
+ deleteListRoute(router);
+ patchListRoute(router);
+
+ // lists items
+ createListItemRoute(router);
+ readListItemRoute(router);
+ updateListItemRoute(router);
+ deleteListItemRoute(router);
+ patchListItemRoute(router);
+ exportListItemRoute(router);
+ importListItemRoute(router);
+
+ // indexes of lists
+ createListIndexRoute(router);
+ readListIndexRoute(router);
+ deleteListIndexRoute(router);
+};
diff --git a/x-pack/plugins/lists/server/routes/patch_list_item_route.ts b/x-pack/plugins/lists/server/routes/patch_list_item_route.ts
new file mode 100644
index 0000000000000..e18fd0618b133
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/patch_list_item_route.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listItemSchema, patchListItemSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const patchListItemRoute = (router: IRouter): void => {
+ router.patch(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_ITEM_URL,
+ validate: {
+ body: buildRouteValidation(patchListItemSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { value, id, meta } = request.body;
+ const lists = getListClient(context);
+ const listItem = await lists.updateListItem({
+ id,
+ meta,
+ value,
+ });
+ if (listItem == null) {
+ return siemResponse.error({
+ body: `list item id: "${id}" not found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(listItem, listItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/patch_list_route.ts b/x-pack/plugins/lists/server/routes/patch_list_route.ts
new file mode 100644
index 0000000000000..9d3fa4db8ccd0
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/patch_list_route.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listSchema, patchListSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const patchListRoute = (router: IRouter): void => {
+ router.patch(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_URL,
+ validate: {
+ body: buildRouteValidation(patchListSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { name, description, id, meta } = request.body;
+ const lists = getListClient(context);
+ const list = await lists.updateList({ description, id, meta, name });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${id}" found found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/read_list_index_route.ts b/x-pack/plugins/lists/server/routes/read_list_index_route.ts
new file mode 100644
index 0000000000000..248fc72666d70
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/read_list_index_route.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_INDEX } from '../../common/constants';
+import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { listItemIndexExistSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const readListIndexRoute = (router: IRouter): void => {
+ router.get(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_INDEX,
+ validate: false,
+ },
+ async (context, _, response) => {
+ const siemResponse = buildSiemResponse(response);
+
+ try {
+ const lists = getListClient(context);
+ const listIndexExists = await lists.getListIndexExists();
+ const listItemIndexExists = await lists.getListItemIndexExists();
+
+ if (listIndexExists || listItemIndexExists) {
+ const [validated, errors] = validate(
+ { list_index: listIndexExists, lists_item_index: listItemIndexExists },
+ listItemIndexExistSchema
+ );
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ } else if (!listIndexExists && listItemIndexExists) {
+ return siemResponse.error({
+ body: `index ${lists.getListIndex()} does not exist`,
+ statusCode: 404,
+ });
+ } else if (!listItemIndexExists && listIndexExists) {
+ return siemResponse.error({
+ body: `index ${lists.getListItemIndex()} does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ return siemResponse.error({
+ body: `index ${lists.getListIndex()} and index ${lists.getListItemIndex()} does not exist`,
+ statusCode: 404,
+ });
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/read_list_item_route.ts b/x-pack/plugins/lists/server/routes/read_list_item_route.ts
new file mode 100644
index 0000000000000..0a60cba786f04
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/read_list_item_route.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listItemArraySchema, listItemSchema, readListItemSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const readListItemRoute = (router: IRouter): void => {
+ router.get(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_ITEM_URL,
+ validate: {
+ query: buildRouteValidation(readListItemSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { id, list_id: listId, value } = request.query;
+ const lists = getListClient(context);
+ if (id != null) {
+ const listItem = await lists.getListItem({ id });
+ if (listItem == null) {
+ return siemResponse.error({
+ body: `list item id: "${id}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(listItem, listItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } else if (listId != null && value != null) {
+ const list = await lists.getList({ id: listId });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${listId}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const listItem = await lists.getListItemByValue({
+ listId,
+ type: list.type,
+ value,
+ });
+ if (listItem.length === 0) {
+ return siemResponse.error({
+ body: `list_id: "${listId}" item of ${value} does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(listItem, listItemArraySchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ }
+ } else {
+ return siemResponse.error({
+ body: `Either "list_id" or "id" needs to be defined in the request`,
+ statusCode: 400,
+ });
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/read_list_route.ts b/x-pack/plugins/lists/server/routes/read_list_route.ts
new file mode 100644
index 0000000000000..c30eadfca0b65
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/read_list_route.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listSchema, readListSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const readListRoute = (router: IRouter): void => {
+ router.get(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_URL,
+ validate: {
+ query: buildRouteValidation(readListSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { id } = request.query;
+ const lists = getListClient(context);
+ const list = await lists.getList({ id });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${id}" does not exist`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/update_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_list_item_route.ts
new file mode 100644
index 0000000000000..494d57b93b8e4
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/update_list_item_route.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_ITEM_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listItemSchema, updateListItemSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const updateListItemRoute = (router: IRouter): void => {
+ router.put(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_ITEM_URL,
+ validate: {
+ body: buildRouteValidation(updateListItemSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { value, id, meta } = request.body;
+ const lists = getListClient(context);
+ const listItem = await lists.updateListItem({
+ id,
+ meta,
+ value,
+ });
+ if (listItem == null) {
+ return siemResponse.error({
+ body: `list item id: "${id}" not found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(listItem, listItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/update_list_route.ts b/x-pack/plugins/lists/server/routes/update_list_route.ts
new file mode 100644
index 0000000000000..6ace61e46a780
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/update_list_route.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IRouter } from 'kibana/server';
+
+import { LIST_URL } from '../../common/constants';
+import {
+ buildRouteValidation,
+ buildSiemResponse,
+ transformError,
+ validate,
+} from '../siem_server_deps';
+import { listSchema, updateListSchema } from '../../common/schemas';
+
+import { getListClient } from '.';
+
+export const updateListRoute = (router: IRouter): void => {
+ router.put(
+ {
+ options: {
+ tags: ['access:lists'],
+ },
+ path: LIST_URL,
+ validate: {
+ body: buildRouteValidation(updateListSchema),
+ },
+ },
+ async (context, request, response) => {
+ const siemResponse = buildSiemResponse(response);
+ try {
+ const { name, description, id, meta } = request.body;
+ const lists = getListClient(context);
+ const list = await lists.updateList({ description, id, meta, name });
+ if (list == null) {
+ return siemResponse.error({
+ body: `list id: "${id}" found found`,
+ statusCode: 404,
+ });
+ } else {
+ const [validated, errors] = validate(list, listSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
+ }
+ } catch (err) {
+ const error = transformError(err);
+ return siemResponse.error({
+ body: error.message,
+ statusCode: error.statusCode,
+ });
+ }
+ }
+ );
+};
diff --git a/x-pack/plugins/lists/server/routes/utils/get_list_client.ts b/x-pack/plugins/lists/server/routes/utils/get_list_client.ts
new file mode 100644
index 0000000000000..a16163ec0fa3a
--- /dev/null
+++ b/x-pack/plugins/lists/server/routes/utils/get_list_client.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RequestHandlerContext } from 'kibana/server';
+
+import { ListClient } from '../../services/lists/client';
+import { ErrorWithStatusCode } from '../../error_with_status_code';
+
+export const getListClient = (context: RequestHandlerContext): ListClient => {
+ const lists = context.lists?.getListClient();
+ if (lists == null) {
+ throw new ErrorWithStatusCode('Lists is not found as a plugin', 404);
+ } else {
+ return lists;
+ }
+};
diff --git a/x-pack/legacy/plugins/alerting/index.ts b/x-pack/plugins/lists/server/routes/utils/index.ts
similarity index 87%
rename from x-pack/legacy/plugins/alerting/index.ts
rename to x-pack/plugins/lists/server/routes/utils/index.ts
index 0d0a698841269..a601bdfc003c5 100644
--- a/x-pack/legacy/plugins/alerting/index.ts
+++ b/x-pack/plugins/lists/server/routes/utils/index.ts
@@ -3,5 +3,4 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
-export * from './server';
+export * from './get_list_client';
diff --git a/x-pack/plugins/lists/server/scripts/check_env_variables.sh b/x-pack/plugins/lists/server/scripts/check_env_variables.sh
new file mode 100755
index 0000000000000..fb3bbbe0fad18
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/check_env_variables.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+# Add this to the start of any scripts to detect if env variables are set
+
+set -e
+
+if [ -z "${ELASTICSEARCH_USERNAME}" ]; then
+ echo "Set ELASTICSEARCH_USERNAME in your environment"
+ exit 1
+fi
+
+if [ -z "${ELASTICSEARCH_PASSWORD}" ]; then
+ echo "Set ELASTICSEARCH_PASSWORD in your environment"
+ exit 1
+fi
+
+if [ -z "${ELASTICSEARCH_URL}" ]; then
+ echo "Set ELASTICSEARCH_URL in your environment"
+ exit 1
+fi
+
+if [ -z "${KIBANA_URL}" ]; then
+ echo "Set KIBANA_URL in your environment"
+ exit 1
+fi
+
+if [ -z "${TASK_MANAGER_INDEX}" ]; then
+ echo "Set TASK_MANAGER_INDEX in your environment"
+ exit 1
+fi
+
+if [ -z "${KIBANA_INDEX}" ]; then
+ echo "Set KIBANA_INDEX in your environment"
+ exit 1
+fi
diff --git a/x-pack/plugins/lists/server/scripts/delete_all_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_lists.sh
new file mode 100755
index 0000000000000..5b65bb14414c7
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/delete_all_lists.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./delete_all_lists.sh
+# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html
+
+
+# Delete all the main lists that have children items
+curl -s -k \
+ -H "Content-Type: application/json" \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \
+ --data '{
+ "query": {
+ "exists": { "field": "siem_list" }
+ }
+ }' \
+ | jq .
+
+# Delete all the list children items as well
+curl -s -k \
+ -H "Content-Type: application/json" \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \
+ --data '{
+ "query": {
+ "exists": { "field": "siem_list_item" }
+ }
+ }' \
+ | jq .
diff --git a/x-pack/plugins/lists/server/scripts/delete_list.sh b/x-pack/plugins/lists/server/scripts/delete_list.sh
new file mode 100755
index 0000000000000..9934ce61c7107
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/delete_list.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./delete_list_by_list_id.sh ${list_id}
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X DELETE ${KIBANA_URL}${SPACE_URL}/api/lists?id="$1" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/delete_list_index.sh b/x-pack/plugins/lists/server/scripts/delete_list_index.sh
new file mode 100755
index 0000000000000..85f06ffbd6670
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/delete_list_index.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./delete_signal_index.sh
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X DELETE ${KIBANA_URL}${SPACE_URL}/api/lists/index | jq .
diff --git a/x-pack/plugins/lists/server/scripts/delete_list_item_by_id.sh b/x-pack/plugins/lists/server/scripts/delete_list_item_by_id.sh
new file mode 100755
index 0000000000000..ab14d8c8a80ed
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/delete_list_item_by_id.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./delete_list_item_by_id.sh?id={id}
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X DELETE ${KIBANA_URL}${SPACE_URL}/api/lists/items?id=$1 | jq .
diff --git a/x-pack/plugins/lists/server/scripts/delete_list_item_by_value.sh b/x-pack/plugins/lists/server/scripts/delete_list_item_by_value.sh
new file mode 100755
index 0000000000000..6d3213ccb8793
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/delete_list_item_by_value.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./delete_list_item_by_value.sh?list_id=${some_id}&value=${some_ip}
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X DELETE "${KIBANA_URL}${SPACE_URL}/api/lists/items?list_id=$1&value=$2" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/export_list_items.sh b/x-pack/plugins/lists/server/scripts/export_list_items.sh
new file mode 100755
index 0000000000000..ba355854c77cc
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/export_list_items.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a defaults if no argument is specified
+LIST_ID=${1:-ips.txt}
+
+# Example to export
+# ./export_list_items.sh > /tmp/ips.txt
+
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=${LIST_ID}"
diff --git a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh
new file mode 100755
index 0000000000000..5efad01e9a68e
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a defaults if no argument is specified
+FOLDER=${1:-/tmp}
+
+# Example to export
+# ./export_list_items_to_file.sh
+
+# Change current working directory as exports cause Kibana to restart
+pushd ${FOLDER} > /dev/null
+
+curl -s -k -OJ \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=list-ip"
+
+popd > /dev/null
diff --git a/x-pack/plugins/lists/server/scripts/get_list.sh b/x-pack/plugins/lists/server/scripts/get_list.sh
new file mode 100755
index 0000000000000..7f0e4e3062266
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/get_list.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./get_list.sh {list_id}
+curl -s -k \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X GET ${KIBANA_URL}${SPACE_URL}/api/lists?id="$1" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/get_list_item_by_id.sh b/x-pack/plugins/lists/server/scripts/get_list_item_by_id.sh
new file mode 100755
index 0000000000000..31d26e195815f
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/get_list_item_by_id.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./get_list_item_by_id ${id}
+curl -s -k \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items?id=$1" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/get_list_item_by_value.sh b/x-pack/plugins/lists/server/scripts/get_list_item_by_value.sh
new file mode 100755
index 0000000000000..24ca27b0c949d
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/get_list_item_by_value.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./get_list_item_by_value.sh ${list_id} ${value}
+curl -s -k \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items?list_id=$1&value=$2" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/hard_reset.sh b/x-pack/plugins/lists/server/scripts/hard_reset.sh
new file mode 100755
index 0000000000000..861928866369b
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/hard_reset.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# re-create the list and list item indexes
+./delete_list_index.sh
+./post_list_index.sh
diff --git a/x-pack/plugins/lists/server/scripts/import_list_items.sh b/x-pack/plugins/lists/server/scripts/import_list_items.sh
new file mode 100755
index 0000000000000..a39409cd08267
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/import_list_items.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a defaults if no argument is specified
+LIST_ID=${1:-list-ip}
+FILE=${2:-./lists/files/ips.txt}
+
+# ./import_list_items.sh list-ip ./lists/files/ips.txt
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_import?list_id=${LIST_ID}" \
+ --form file=@${FILE} \
+ | jq .;
diff --git a/x-pack/plugins/lists/server/scripts/import_list_items_by_filename.sh b/x-pack/plugins/lists/server/scripts/import_list_items_by_filename.sh
new file mode 100755
index 0000000000000..4ec55cb4c5f7b
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/import_list_items_by_filename.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a defaults if no argument is specified
+TYPE=${1:-ip}
+FILE=${2:-./lists/files/ips.txt}
+
+# Example to import ips from ./lists/files/ips.txt
+# ./import_list_items_by_filename.sh ip ./lists/files/ips.txt
+
+curl -s -k \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_import?type=${TYPE}" \
+ --form file=@${FILE} \
+ | jq .;
diff --git a/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt b/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt
new file mode 100644
index 0000000000000..aee32e3a4bd92
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt
@@ -0,0 +1,2 @@
+kibana
+rock01
diff --git a/x-pack/plugins/lists/server/scripts/lists/files/ips.txt b/x-pack/plugins/lists/server/scripts/lists/files/ips.txt
new file mode 100644
index 0000000000000..cf8ebcacae5a1
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/files/ips.txt
@@ -0,0 +1,9 @@
+127.0.0.1
+127.0.0.2
+127.0.0.3
+127.0.0.4
+127.0.0.5
+127.0.0.6
+127.0.0.7
+127.0.0.8
+127.0.0.9
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_everything.json b/x-pack/plugins/lists/server/scripts/lists/new/list_everything.json
new file mode 100644
index 0000000000000..196b3b149ab82
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_everything.json
@@ -0,0 +1,13 @@
+{
+ "id": "list-ip-everything",
+ "name": "Simple list with an ip",
+ "description": "This list describes bad internet ip",
+ "type": "ip",
+ "meta": {
+ "level_1_meta": {
+ "level_2_meta": {
+ "level_3_key": "some_value_ui"
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip.json
new file mode 100644
index 0000000000000..3e12ef1754f07
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip.json
@@ -0,0 +1,6 @@
+{
+ "id": "list-ip",
+ "name": "Simple list with an ip",
+ "description": "This list describes bad internet ip",
+ "type": "ip"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json
new file mode 100644
index 0000000000000..1516fa5057e50
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json
@@ -0,0 +1,5 @@
+{
+ "id": "hand_inserted_item_id",
+ "list_id": "list-ip",
+ "value": "127.0.0.1"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item_everything.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item_everything.json
new file mode 100644
index 0000000000000..9730c1b7523f1
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item_everything.json
@@ -0,0 +1,12 @@
+{
+ "id": "hand_inserted_item_id_everything",
+ "list_id": "list-ip",
+ "value": "127.0.0.2",
+ "meta": {
+ "level_1_meta": {
+ "level_2_meta": {
+ "level_3_key": "some_value_ui"
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_no_id.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_no_id.json
new file mode 100644
index 0000000000000..4a95a62b67c3e
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_no_id.json
@@ -0,0 +1,5 @@
+{
+ "name": "Simple list with an ip",
+ "description": "This list describes bad internet ip",
+ "type": "ip"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_keyword.json b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword.json
new file mode 100644
index 0000000000000..e8f5fa7e38a06
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword.json
@@ -0,0 +1,6 @@
+{
+ "id": "list-keyword",
+ "name": "Simple list with a keyword",
+ "description": "This list describes bad host names",
+ "type": "keyword"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json
new file mode 100644
index 0000000000000..b736e7b96ad98
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json
@@ -0,0 +1,4 @@
+{
+ "list_id": "list-keyword",
+ "value": "kibana"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/patches/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/patches/list_ip_item.json
new file mode 100644
index 0000000000000..00c3496e71b35
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/patches/list_ip_item.json
@@ -0,0 +1,4 @@
+{
+ "id": "hand_inserted_item_id",
+ "value": "255.255.255.255"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/patches/simplest_updated_name.json b/x-pack/plugins/lists/server/scripts/lists/patches/simplest_updated_name.json
new file mode 100644
index 0000000000000..1a57ab8b6a3b9
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/patches/simplest_updated_name.json
@@ -0,0 +1,4 @@
+{
+ "id": "list-ip",
+ "name": "Changed the name here to something else"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/updates/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/updates/list_ip_item.json
new file mode 100644
index 0000000000000..00c3496e71b35
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/updates/list_ip_item.json
@@ -0,0 +1,4 @@
+{
+ "id": "hand_inserted_item_id",
+ "value": "255.255.255.255"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists/updates/simple_update.json b/x-pack/plugins/lists/server/scripts/lists/updates/simple_update.json
new file mode 100644
index 0000000000000..936a070ede52c
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists/updates/simple_update.json
@@ -0,0 +1,5 @@
+{
+ "id": "list-ip",
+ "name": "Changed the name here to something else",
+ "description": "Some other description here for you"
+}
diff --git a/x-pack/plugins/lists/server/scripts/lists_index_exists.sh b/x-pack/plugins/lists/server/scripts/lists_index_exists.sh
new file mode 100755
index 0000000000000..7dfbd5b1bada5
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/lists_index_exists.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./lists_index_exists.sh
+curl -s -k -f \
+ -H 'Content-Type: application/json' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ ${KIBANA_URL}${SPACE_URL}/api/lists/index | jq .
diff --git a/x-pack/plugins/lists/server/scripts/patch_list.sh b/x-pack/plugins/lists/server/scripts/patch_list.sh
new file mode 100755
index 0000000000000..3a517a52dbd21
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/patch_list.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/patches/simplest_updated_name.json})
+
+# Example: ./patch_list.sh
+# Example: ./patch_list.sh ./lists/patches/simplest_updated_name.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X PATCH ${KIBANA_URL}${SPACE_URL}/api/lists \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/scripts/patch_list_item.sh b/x-pack/plugins/lists/server/scripts/patch_list_item.sh
new file mode 100755
index 0000000000000..406b03dc6499c
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/patch_list_item.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/patches/list_ip_item.json})
+
+# Example: ./patch_list.sh
+# Example: ./patch_list.sh ./lists/patches/list_ip_item.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X PATCH ${KIBANA_URL}${SPACE_URL}/api/lists/items \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/scripts/post_list.sh b/x-pack/plugins/lists/server/scripts/post_list.sh
new file mode 100755
index 0000000000000..6aaffee0bc4b2
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/post_list.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/new/list_ip.json})
+
+# Example: ./post_list.sh
+# Example: ./post_list.sh ./lists/new/list_ip.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST ${KIBANA_URL}${SPACE_URL}/api/lists \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/scripts/post_list_index.sh b/x-pack/plugins/lists/server/scripts/post_list_index.sh
new file mode 100755
index 0000000000000..b7c372d3947e3
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/post_list_index.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Example: ./post_signal_index.sh
+curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST ${KIBANA_URL}${SPACE_URL}/api/lists/index | jq .
diff --git a/x-pack/plugins/lists/server/scripts/post_list_item.sh b/x-pack/plugins/lists/server/scripts/post_list_item.sh
new file mode 100755
index 0000000000000..b55a60420674f
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/post_list_item.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/new/list_ip_item.json})
+
+# Example: ./post_list.sh
+# Example: ./post_list.sh ./lists/new/list_ip_item.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X POST ${KIBANA_URL}${SPACE_URL}/api/lists/items \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/scripts/update_list.sh b/x-pack/plugins/lists/server/scripts/update_list.sh
new file mode 100755
index 0000000000000..4d93544d568a8
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/update_list.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/updates/simple_update.json})
+
+# Example: ./update_list.sh
+# Example: ./update_list.sh ./lists/updates/simple_update.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X PUT ${KIBANA_URL}${SPACE_URL}/api/lists \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/scripts/update_list_item.sh b/x-pack/plugins/lists/server/scripts/update_list_item.sh
new file mode 100755
index 0000000000000..e3153bfd25b19
--- /dev/null
+++ b/x-pack/plugins/lists/server/scripts/update_list_item.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License;
+# you may not use this file except in compliance with the Elastic License.
+#
+
+set -e
+./check_env_variables.sh
+
+# Uses a default if no argument is specified
+LISTS=(${@:-./lists/updates/list_ip_item.json})
+
+# Example: ./patch_list.sh
+# Example: ./patch_list.sh ./lists/updates/list_ip_item.json
+for LIST in "${LISTS[@]}"
+do {
+ [ -e "$LIST" ] || continue
+ curl -s -k \
+ -H 'Content-Type: application/json' \
+ -H 'kbn-xsrf: 123' \
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ -X PATCH ${KIBANA_URL}${SPACE_URL}/api/lists/items \
+ -d @${LIST} \
+ | jq .;
+} &
+done
+
+wait
diff --git a/x-pack/plugins/lists/server/services/items/buffer_lines.test.ts b/x-pack/plugins/lists/server/services/items/buffer_lines.test.ts
new file mode 100644
index 0000000000000..48deb3ee86820
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/buffer_lines.test.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { TestReadable } from '../mocks';
+
+import { BufferLines } from './buffer_lines';
+
+describe('buffer_lines', () => {
+ test('it can read a single line', done => {
+ const input = new TestReadable();
+ input.push('line one\n');
+ input.push(null);
+ const bufferedLine = new BufferLines({ input });
+ let linesToTest: string[] = [];
+ bufferedLine.on('lines', (lines: string[]) => {
+ linesToTest = [...linesToTest, ...lines];
+ });
+ bufferedLine.on('close', () => {
+ expect(linesToTest).toEqual(['line one']);
+ done();
+ });
+ });
+
+ test('it can read two lines', done => {
+ const input = new TestReadable();
+ input.push('line one\n');
+ input.push('line two\n');
+ input.push(null);
+ const bufferedLine = new BufferLines({ input });
+ let linesToTest: string[] = [];
+ bufferedLine.on('lines', (lines: string[]) => {
+ linesToTest = [...linesToTest, ...lines];
+ });
+ bufferedLine.on('close', () => {
+ expect(linesToTest).toEqual(['line one', 'line two']);
+ done();
+ });
+ });
+
+ test('two identical lines are collapsed into just one line without duplicates', done => {
+ const input = new TestReadable();
+ input.push('line one\n');
+ input.push('line one\n');
+ input.push(null);
+ const bufferedLine = new BufferLines({ input });
+ let linesToTest: string[] = [];
+ bufferedLine.on('lines', (lines: string[]) => {
+ linesToTest = [...linesToTest, ...lines];
+ });
+ bufferedLine.on('close', () => {
+ expect(linesToTest).toEqual(['line one']);
+ done();
+ });
+ });
+
+ test('it can close out without writing any lines', done => {
+ const input = new TestReadable();
+ input.push(null);
+ const bufferedLine = new BufferLines({ input });
+ let linesToTest: string[] = [];
+ bufferedLine.on('lines', (lines: string[]) => {
+ linesToTest = [...linesToTest, ...lines];
+ });
+ bufferedLine.on('close', () => {
+ expect(linesToTest).toEqual([]);
+ done();
+ });
+ });
+
+ test('it can read 200 lines', done => {
+ const input = new TestReadable();
+ const bufferedLine = new BufferLines({ input });
+ let linesToTest: string[] = [];
+ const size200: string[] = new Array(200).fill(null).map((_, index) => `${index}\n`);
+ size200.forEach(element => input.push(element));
+ input.push(null);
+ bufferedLine.on('lines', (lines: string[]) => {
+ linesToTest = [...linesToTest, ...lines];
+ });
+ bufferedLine.on('close', () => {
+ expect(linesToTest.length).toEqual(200);
+ done();
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/buffer_lines.ts b/x-pack/plugins/lists/server/services/items/buffer_lines.ts
new file mode 100644
index 0000000000000..fd8fe7077fd58
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/buffer_lines.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import readLine from 'readline';
+import { Readable } from 'stream';
+
+const BUFFER_SIZE = 100;
+
+export class BufferLines extends Readable {
+ private set = new Set();
+ constructor({ input }: { input: NodeJS.ReadableStream }) {
+ super({ encoding: 'utf-8' });
+ const readline = readLine.createInterface({
+ input,
+ });
+
+ readline.on('line', line => {
+ this.push(line);
+ });
+
+ readline.on('close', () => {
+ this.push(null);
+ });
+ }
+
+ public _read(): void {
+ // No operation but this is required to be implemented
+ }
+
+ public push(line: string | null): boolean {
+ if (line == null) {
+ this.emit('lines', Array.from(this.set));
+ this.set.clear();
+ this.emit('close');
+ return true;
+ } else {
+ this.set.add(line);
+ if (this.set.size > BUFFER_SIZE) {
+ this.emit('lines', Array.from(this.set));
+ this.set.clear();
+ return true;
+ } else {
+ return true;
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.test.ts b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts
new file mode 100644
index 0000000000000..abbb270149955
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ITEM_ID,
+ LIST_ITEM_INDEX,
+ getCreateListItemOptionsMock,
+ getIndexESListItemMock,
+ getListItemResponseMock,
+} from '../mocks';
+
+import { createListItem } from './create_list_item';
+
+describe('crete_list_item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list item as expected with the id changed out for the elastic id', async () => {
+ const options = getCreateListItemOptionsMock();
+ const listItem = await createListItem(options);
+ const expected = getListItemResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(listItem).toEqual(expected);
+ });
+
+ test('It calls "callCluster" with body, index, and listIndex', async () => {
+ const options = getCreateListItemOptionsMock();
+ await createListItem(options);
+ const body = getIndexESListItemMock();
+ const expected = {
+ body,
+ id: LIST_ITEM_ID,
+ index: LIST_ITEM_INDEX,
+ };
+ expect(options.callCluster).toBeCalledWith('index', expected);
+ });
+
+ test('It returns an auto-generated id if id is sent in undefined', async () => {
+ const options = getCreateListItemOptionsMock();
+ options.id = undefined;
+ const list = await createListItem(options);
+ const expected = getListItemResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(list).toEqual(expected);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts
new file mode 100644
index 0000000000000..83a118b795192
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import uuid from 'uuid';
+import { CreateDocumentResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import {
+ IdOrUndefined,
+ IndexEsListItemSchema,
+ ListItemSchema,
+ MetaOrUndefined,
+ Type,
+} from '../../../common/schemas';
+import { transformListItemToElasticQuery } from '../utils';
+
+export interface CreateListItemOptions {
+ id: IdOrUndefined;
+ listId: string;
+ type: Type;
+ value: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ user: string;
+ meta: MetaOrUndefined;
+ dateNow?: string;
+ tieBreaker?: string;
+}
+
+export const createListItem = async ({
+ id,
+ listId,
+ type,
+ value,
+ callCluster,
+ listItemIndex,
+ user,
+ meta,
+ dateNow,
+ tieBreaker,
+}: CreateListItemOptions): Promise => {
+ const createdAt = dateNow ?? new Date().toISOString();
+ const tieBreakerId = tieBreaker ?? uuid.v4();
+ const baseBody = {
+ created_at: createdAt,
+ created_by: user,
+ list_id: listId,
+ meta,
+ tie_breaker_id: tieBreakerId,
+ updated_at: createdAt,
+ updated_by: user,
+ };
+ const body: IndexEsListItemSchema = {
+ ...baseBody,
+ ...transformListItemToElasticQuery({ type, value }),
+ };
+
+ const response: CreateDocumentResponse = await callCluster('index', {
+ body,
+ id,
+ index: listItemIndex,
+ });
+
+ return {
+ id: response._id,
+ type,
+ value,
+ ...baseBody,
+ };
+};
diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts
new file mode 100644
index 0000000000000..94cc57b53b4e2
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IndexEsListItemSchema } from '../../../common/schemas';
+import {
+ LIST_ITEM_INDEX,
+ TIE_BREAKERS,
+ VALUE_2,
+ getCreateListItemBulkOptionsMock,
+ getIndexESListItemMock,
+} from '../mocks';
+
+import { createListItemsBulk } from './create_list_items_bulk';
+
+describe('crete_list_item_bulk', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('It calls "callCluster" with body, index, and the bulk items', async () => {
+ const options = getCreateListItemBulkOptionsMock();
+ await createListItemsBulk(options);
+ const firstRecord: IndexEsListItemSchema = getIndexESListItemMock();
+ const secondRecord: IndexEsListItemSchema = getIndexESListItemMock(VALUE_2);
+ [firstRecord.tie_breaker_id, secondRecord.tie_breaker_id] = TIE_BREAKERS;
+ expect(options.callCluster).toBeCalledWith('bulk', {
+ body: [
+ { create: { _index: LIST_ITEM_INDEX } },
+ firstRecord,
+ { create: { _index: LIST_ITEM_INDEX } },
+ secondRecord,
+ ],
+ index: LIST_ITEM_INDEX,
+ });
+ });
+
+ test('It should not call the dataClient when the values are empty', async () => {
+ const options = getCreateListItemBulkOptionsMock();
+ options.value = [];
+ expect(options.callCluster).not.toBeCalled();
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts
new file mode 100644
index 0000000000000..eac294c5f244a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import uuid from 'uuid';
+import { APICaller } from 'kibana/server';
+
+import { transformListItemToElasticQuery } from '../utils';
+import {
+ CreateEsBulkTypeSchema,
+ IndexEsListItemSchema,
+ MetaOrUndefined,
+ Type,
+} from '../../../common/schemas';
+
+export interface CreateListItemsBulkOptions {
+ listId: string;
+ type: Type;
+ value: string[];
+ callCluster: APICaller;
+ listItemIndex: string;
+ user: string;
+ meta: MetaOrUndefined;
+ dateNow?: string;
+ tieBreaker?: string[];
+}
+
+export const createListItemsBulk = async ({
+ listId,
+ type,
+ value,
+ callCluster,
+ listItemIndex,
+ user,
+ meta,
+ dateNow,
+ tieBreaker,
+}: CreateListItemsBulkOptions): Promise => {
+ // It causes errors if you try to add items to bulk that do not exist within ES
+ if (!value.length) {
+ return;
+ }
+ const body = value.reduce>(
+ (accum, singleValue, index) => {
+ const createdAt = dateNow ?? new Date().toISOString();
+ const tieBreakerId =
+ tieBreaker != null && tieBreaker[index] != null ? tieBreaker[index] : uuid.v4();
+ const elasticBody: IndexEsListItemSchema = {
+ created_at: createdAt,
+ created_by: user,
+ list_id: listId,
+ meta,
+ tie_breaker_id: tieBreakerId,
+ updated_at: createdAt,
+ updated_by: user,
+ ...transformListItemToElasticQuery({ type, value: singleValue }),
+ };
+ const createBody: CreateEsBulkTypeSchema = { create: { _index: listItemIndex } };
+ return [...accum, createBody, elasticBody];
+ },
+ []
+ );
+
+ await callCluster('bulk', {
+ body,
+ index: listItemIndex,
+ });
+};
diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts
new file mode 100644
index 0000000000000..00fcefb2c379f
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/delete_list_item.test.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ITEM_ID,
+ LIST_ITEM_INDEX,
+ getDeleteListItemOptionsMock,
+ getListItemResponseMock,
+} from '../mocks';
+
+import { getListItem } from './get_list_item';
+import { deleteListItem } from './delete_list_item';
+
+jest.mock('./get_list_item', () => ({
+ getListItem: jest.fn(),
+}));
+
+describe('delete_list_item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Delete returns a null if "getListItem" returns a null', async () => {
+ ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(null);
+ const options = getDeleteListItemOptionsMock();
+ const deletedListItem = await deleteListItem(options);
+ expect(deletedListItem).toEqual(null);
+ });
+
+ test('Delete returns the same list item if a list item is returned from "getListItem"', async () => {
+ const listItem = getListItemResponseMock();
+ ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(listItem);
+ const options = getDeleteListItemOptionsMock();
+ const deletedListItem = await deleteListItem(options);
+ expect(deletedListItem).toEqual(listItem);
+ });
+
+ test('Delete calls "delete" if a list item is returned from "getListItem"', async () => {
+ const listItem = getListItemResponseMock();
+ ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(listItem);
+ const options = getDeleteListItemOptionsMock();
+ await deleteListItem(options);
+ const deleteQuery = {
+ id: LIST_ITEM_ID,
+ index: LIST_ITEM_INDEX,
+ };
+ expect(options.callCluster).toBeCalledWith('delete', deleteQuery);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.ts
new file mode 100644
index 0000000000000..9992f43387c89
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/delete_list_item.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { APICaller } from 'kibana/server';
+
+import { Id, ListItemSchema } from '../../../common/schemas';
+
+import { getListItem } from '.';
+
+export interface DeleteListItemOptions {
+ id: Id;
+ callCluster: APICaller;
+ listItemIndex: string;
+}
+
+export const deleteListItem = async ({
+ id,
+ callCluster,
+ listItemIndex,
+}: DeleteListItemOptions): Promise => {
+ const listItem = await getListItem({ callCluster, id, listItemIndex });
+ if (listItem == null) {
+ return null;
+ } else {
+ await callCluster('delete', {
+ id,
+ index: listItemIndex,
+ });
+ }
+ return listItem;
+};
diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts
new file mode 100644
index 0000000000000..c7c80638e4c37
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getDeleteListItemByValueOptionsMock, getListItemResponseMock } from '../mocks';
+
+import { getListItemByValues } from './get_list_item_by_values';
+import { deleteListItemByValue } from './delete_list_item_by_value';
+
+jest.mock('./get_list_item_by_values', () => ({
+ getListItemByValues: jest.fn(),
+}));
+
+describe('delete_list_item_by_value', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Delete returns a an empty array if the list items are also empty', async () => {
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([]);
+ const options = getDeleteListItemByValueOptionsMock();
+ const deletedListItem = await deleteListItemByValue(options);
+ expect(deletedListItem).toEqual([]);
+ });
+
+ test('Delete returns the list item if a list item is returned from "getListByValues"', async () => {
+ const listItems = [getListItemResponseMock()];
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce(listItems);
+ const options = getDeleteListItemByValueOptionsMock();
+ const deletedListItem = await deleteListItemByValue(options);
+ expect(deletedListItem).toEqual(listItems);
+ });
+
+ test('Delete calls "deleteByQuery" if a list item is returned from "getListByValues"', async () => {
+ const listItems = [getListItemResponseMock()];
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce(listItems);
+ const options = getDeleteListItemByValueOptionsMock();
+ await deleteListItemByValue(options);
+ const deleteByQuery = {
+ body: {
+ query: {
+ bool: {
+ filter: [{ term: { list_id: 'some-list-id' } }, { terms: { ip: ['127.0.0.1'] } }],
+ },
+ },
+ },
+ index: '.items',
+ };
+ expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts
new file mode 100644
index 0000000000000..ec29f14a0ff64
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { APICaller } from 'kibana/server';
+
+import { ListItemArraySchema, Type } from '../../../common/schemas';
+import { getQueryFilterFromTypeValue } from '../utils';
+
+import { getListItemByValues } from './get_list_item_by_values';
+
+export interface DeleteListItemByValueOptions {
+ listId: string;
+ type: Type;
+ value: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+}
+
+export const deleteListItemByValue = async ({
+ listId,
+ value,
+ type,
+ callCluster,
+ listItemIndex,
+}: DeleteListItemByValueOptions): Promise => {
+ const listItems = await getListItemByValues({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value: [value],
+ });
+ const values = listItems.map(listItem => listItem.value);
+ const filter = getQueryFilterFromTypeValue({
+ listId,
+ type,
+ value: values,
+ });
+ await callCluster('deleteByQuery', {
+ body: {
+ query: {
+ bool: {
+ filter,
+ },
+ },
+ },
+ index: listItemIndex,
+ });
+ return listItems;
+};
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item.test.ts
new file mode 100644
index 0000000000000..31a421c2e31bf
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_INDEX,
+ getCallClusterMock,
+ getListItemResponseMock,
+ getSearchListItemMock,
+} from '../mocks';
+
+import { getListItem } from './get_list_item';
+
+describe('get_list_item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list item as expected if the list item is found', async () => {
+ const data = getSearchListItemMock();
+ const callCluster = getCallClusterMock(data);
+ const list = await getListItem({ callCluster, id: LIST_ID, listItemIndex: LIST_INDEX });
+ const expected = getListItemResponseMock();
+ expect(list).toEqual(expected);
+ });
+
+ test('it returns null if the search is empty', async () => {
+ const data = getSearchListItemMock();
+ data.hits.hits = [];
+ const callCluster = getCallClusterMock(data);
+ const list = await getListItem({ callCluster, id: LIST_ID, listItemIndex: LIST_INDEX });
+ expect(list).toEqual(null);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item.ts b/x-pack/plugins/lists/server/services/items/get_list_item.ts
new file mode 100644
index 0000000000000..83b30d336ccd4
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import { Id, ListItemSchema, SearchEsListItemSchema } from '../../../common/schemas';
+import { deriveTypeFromItem, transformElasticToListItem } from '../utils';
+
+interface GetListItemOptions {
+ id: Id;
+ callCluster: APICaller;
+ listItemIndex: string;
+}
+
+export const getListItem = async ({
+ id,
+ callCluster,
+ listItemIndex,
+}: GetListItemOptions): Promise => {
+ const listItemES: SearchResponse = await callCluster('search', {
+ body: {
+ query: {
+ term: {
+ _id: id,
+ },
+ },
+ },
+ ignoreUnavailable: true,
+ index: listItemIndex,
+ });
+
+ if (listItemES.hits.hits.length) {
+ const type = deriveTypeFromItem({ item: listItemES.hits.hits[0]._source });
+ const listItems = transformElasticToListItem({ response: listItemES, type });
+ return listItems[0];
+ } else {
+ return null;
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.test.ts
new file mode 100644
index 0000000000000..d30b3c795550f
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.test.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListItemByValueOptionsMocks, getListItemResponseMock } from '../mocks';
+
+import { getListItemByValues } from './get_list_item_by_values';
+import { getListItemByValue } from './get_list_item_by_value';
+
+jest.mock('./get_list_item_by_values', () => ({
+ getListItemByValues: jest.fn(),
+}));
+
+describe('get_list_by_value', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Calls get_list_item_by_values with its input', async () => {
+ const listItemMock = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([listItemMock]);
+ const options = getListItemByValueOptionsMocks();
+ const listItem = await getListItemByValue(options);
+ const expected = getListItemResponseMock();
+ expect(listItem).toEqual([expected]);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts
new file mode 100644
index 0000000000000..49bcf12043d7c
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { APICaller } from 'kibana/server';
+
+import { ListItemArraySchema, Type } from '../../../common/schemas';
+
+import { getListItemByValues } from '.';
+
+export interface GetListItemByValueOptions {
+ listId: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ type: Type;
+ value: string;
+}
+
+export const getListItemByValue = async ({
+ listId,
+ callCluster,
+ listItemIndex,
+ type,
+ value,
+}: GetListItemByValueOptions): Promise =>
+ getListItemByValues({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value: [value],
+ });
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts
new file mode 100644
index 0000000000000..7f5fff4dc3147
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_ITEM_INDEX,
+ TYPE,
+ VALUE,
+ VALUE_2,
+ getCallClusterMock,
+ getSearchListItemMock,
+} from '../mocks';
+
+import { getListItemByValues } from './get_list_item_by_values';
+
+describe('get_list_item_by_values', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Returns a an empty array if the ES query is also empty', async () => {
+ const data = getSearchListItemMock();
+ data.hits.hits = [];
+ const callCluster = getCallClusterMock(data);
+ const listItem = await getListItemByValues({
+ callCluster,
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ type: TYPE,
+ value: [VALUE, VALUE_2],
+ });
+
+ expect(listItem).toEqual([]);
+ });
+
+ test('Returns transformed list item if the data exists within ES', async () => {
+ const data = getSearchListItemMock();
+ const callCluster = getCallClusterMock(data);
+ const listItem = await getListItemByValues({
+ callCluster,
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ type: TYPE,
+ value: [VALUE, VALUE_2],
+ });
+
+ expect(listItem).toEqual([
+ {
+ created_at: '2020-04-20T15:25:31.830Z',
+ created_by: 'some user',
+ id: 'some-list-item-id',
+ list_id: 'some-list-id',
+ meta: {},
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ type: 'ip',
+ updated_at: '2020-04-20T15:25:31.830Z',
+ updated_by: 'some user',
+ value: '127.0.0.1',
+ },
+ ]);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts
new file mode 100644
index 0000000000000..29b9b01754027
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import { ListItemArraySchema, SearchEsListItemSchema, Type } from '../../../common/schemas';
+import { getQueryFilterFromTypeValue, transformElasticToListItem } from '../utils';
+
+export interface GetListItemByValuesOptions {
+ listId: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ type: Type;
+ value: string[];
+}
+
+export const getListItemByValues = async ({
+ listId,
+ callCluster,
+ listItemIndex,
+ type,
+ value,
+}: GetListItemByValuesOptions): Promise => {
+ const response: SearchResponse = await callCluster('search', {
+ body: {
+ query: {
+ bool: {
+ filter: getQueryFilterFromTypeValue({ listId, type, value }),
+ },
+ },
+ },
+ ignoreUnavailable: true,
+ index: listItemIndex,
+ size: value.length, // This has a limit on the number which is 10k
+ });
+ return transformElasticToListItem({ response, type });
+};
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_index.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item_index.test.ts
new file mode 100644
index 0000000000000..ffe2eff9f3ca7
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_index.test.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListItemIndex } from './get_list_item_index';
+
+describe('get_list_item_index', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Returns the list item index when there is a space', async () => {
+ const listIndex = getListItemIndex({
+ listsItemsIndexName: 'lists-items-index',
+ spaceId: 'test-space',
+ });
+ expect(listIndex).toEqual('lists-items-index-test-space');
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_index.ts b/x-pack/plugins/lists/server/services/items/get_list_item_index.ts
new file mode 100644
index 0000000000000..4cd93df6d9bf4
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface GetListItemIndexOptions {
+ spaceId: string;
+ listsItemsIndexName: string;
+}
+
+export const getListItemIndex = ({
+ spaceId,
+ listsItemsIndexName,
+}: GetListItemIndexOptions): string => `${listsItemsIndexName}-${spaceId}`;
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_template.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item_template.test.ts
new file mode 100644
index 0000000000000..9c85fa6ff0256
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_template.test.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListItemTemplate } from './get_list_item_template';
+
+jest.mock('./list_item_mappings.json', () => ({
+ listMappings: {},
+}));
+
+describe('get_list_item_template', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list template with the string filled in', async () => {
+ const template = getListItemTemplate('some_index');
+ expect(template).toEqual({
+ index_patterns: ['some_index-*'],
+ mappings: { listMappings: {} },
+ settings: { index: { lifecycle: { name: 'some_index', rollover_alias: 'some_index' } } },
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_template.ts b/x-pack/plugins/lists/server/services/items/get_list_item_template.ts
new file mode 100644
index 0000000000000..95f4a09b40648
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/get_list_item_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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import listsItemsMappings from './list_item_mappings.json';
+
+export const getListItemTemplate = (index: string): Record => {
+ const template = {
+ index_patterns: [`${index}-*`],
+ mappings: listsItemsMappings,
+ settings: {
+ index: {
+ lifecycle: {
+ name: index,
+ rollover_alias: index,
+ },
+ },
+ },
+ };
+ return template;
+};
diff --git a/x-pack/plugins/lists/server/services/items/index.ts b/x-pack/plugins/lists/server/services/items/index.ts
new file mode 100644
index 0000000000000..ee1d83fabca31
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './buffer_lines';
+export * from './create_list_item';
+export * from './create_list_items_bulk';
+export * from './delete_list_item_by_value';
+export * from './get_list_item_by_value';
+export * from './get_list_item';
+export * from './get_list_item_by_values';
+export * from './update_list_item';
+export * from './write_lines_to_bulk_list_items';
+export * from './write_list_items_to_stream';
+export * from './get_list_item_template';
+export * from './delete_list_item';
+export * from './get_list_item_index';
diff --git a/x-pack/plugins/lists/server/services/items/list_item_mappings.json b/x-pack/plugins/lists/server/services/items/list_item_mappings.json
new file mode 100644
index 0000000000000..ca69c26df52b5
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/list_item_mappings.json
@@ -0,0 +1,33 @@
+{
+ "dynamic": "strict",
+ "properties": {
+ "tie_breaker_id": {
+ "type": "keyword"
+ },
+ "list_id": {
+ "type": "keyword"
+ },
+ "ip": {
+ "type": "ip"
+ },
+ "keyword": {
+ "type": "keyword"
+ },
+ "meta": {
+ "enabled": "false",
+ "type": "object"
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "created_by": {
+ "type": "keyword"
+ },
+ "updated_by": {
+ "type": "keyword"
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/services/items/list_item_policy.json b/x-pack/plugins/lists/server/services/items/list_item_policy.json
new file mode 100644
index 0000000000000..a4c84f73e7896
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/list_item_policy.json
@@ -0,0 +1,14 @@
+{
+ "policy": {
+ "phases": {
+ "hot": {
+ "min_age": "0ms",
+ "actions": {
+ "rollover": {
+ "max_size": "50gb"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.test.ts b/x-pack/plugins/lists/server/services/items/update_list_item.test.ts
new file mode 100644
index 0000000000000..4ef4110bc0742
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/update_list_item.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListItemResponseMock, getUpdateListItemOptionsMock } from '../mocks';
+
+import { updateListItem } from './update_list_item';
+import { getListItem } from './get_list_item';
+
+jest.mock('./get_list_item', () => ({
+ getListItem: jest.fn(),
+}));
+
+describe('update_list_item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list item as expected with the id changed out for the elastic id when there is a list item to update', async () => {
+ const list = getListItemResponseMock();
+ ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(list);
+ const options = getUpdateListItemOptionsMock();
+ const updatedList = await updateListItem(options);
+ const expected = getListItemResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(updatedList).toEqual(expected);
+ });
+
+ test('it returns null when there is not a list item to update', async () => {
+ ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(null);
+ const options = getUpdateListItemOptionsMock();
+ const updatedList = await updateListItem(options);
+ expect(updatedList).toEqual(null);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts
new file mode 100644
index 0000000000000..6a71b2a0caf41
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateDocumentResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import {
+ Id,
+ ListItemSchema,
+ MetaOrUndefined,
+ UpdateEsListItemSchema,
+} from '../../../common/schemas';
+import { transformListItemToElasticQuery } from '../utils';
+
+import { getListItem } from './get_list_item';
+
+export interface UpdateListItemOptions {
+ id: Id;
+ value: string | null | undefined;
+ callCluster: APICaller;
+ listItemIndex: string;
+ user: string;
+ meta: MetaOrUndefined;
+ dateNow?: string;
+}
+
+export const updateListItem = async ({
+ id,
+ value,
+ callCluster,
+ listItemIndex,
+ user,
+ meta,
+ dateNow,
+}: UpdateListItemOptions): Promise => {
+ const updatedAt = dateNow ?? new Date().toISOString();
+ const listItem = await getListItem({ callCluster, id, listItemIndex });
+ if (listItem == null) {
+ return null;
+ } else {
+ const doc: UpdateEsListItemSchema = {
+ meta,
+ updated_at: updatedAt,
+ updated_by: user,
+ ...transformListItemToElasticQuery({ type: listItem.type, value: value ?? listItem.value }),
+ };
+
+ const response: CreateDocumentResponse = await callCluster('update', {
+ body: {
+ doc,
+ },
+ id: listItem.id,
+ index: listItemIndex,
+ });
+ return {
+ created_at: listItem.created_at,
+ created_by: listItem.created_by,
+ id: response._id,
+ list_id: listItem.list_id,
+ meta: meta ?? listItem.meta,
+ tie_breaker_id: listItem.tie_breaker_id,
+ type: listItem.type,
+ updated_at: updatedAt,
+ updated_by: listItem.updated_by,
+ value: value ?? listItem.value,
+ };
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.test.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.test.ts
new file mode 100644
index 0000000000000..f064543f1ec93
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.test.ts
@@ -0,0 +1,160 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ getImportListItemsToStreamOptionsMock,
+ getListItemResponseMock,
+ getWriteBufferToItemsOptionsMock,
+} from '../mocks';
+
+import {
+ LinesResult,
+ importListItemsToStream,
+ writeBufferToItems,
+} from './write_lines_to_bulk_list_items';
+
+import { getListItemByValues } from '.';
+
+jest.mock('./get_list_item_by_values', () => ({
+ getListItemByValues: jest.fn(),
+}));
+
+describe('write_lines_to_bulk_list_items', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('importListItemsToStream', () => {
+ test('It imports a set of items to a write buffer by calling "getListItemByValues" with an empty buffer', async () => {
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([]);
+ const options = getImportListItemsToStreamOptionsMock();
+ const promise = importListItemsToStream(options);
+ options.stream.push(null);
+ await promise;
+ expect(getListItemByValues).toBeCalledWith(expect.objectContaining({ value: [] }));
+ });
+
+ test('It imports a set of items to a write buffer by calling "getListItemByValues" with a single value given', async () => {
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([]);
+ const options = getImportListItemsToStreamOptionsMock();
+ const promise = importListItemsToStream(options);
+ options.stream.push('127.0.0.1\n');
+ options.stream.push(null);
+ await promise;
+ expect(getListItemByValues).toBeCalledWith(expect.objectContaining({ value: ['127.0.0.1'] }));
+ });
+
+ test('It imports a set of items to a write buffer by calling "getListItemByValues" with two values given', async () => {
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([]);
+ const options = getImportListItemsToStreamOptionsMock();
+ const promise = importListItemsToStream(options);
+ options.stream.push('127.0.0.1\n');
+ options.stream.push('127.0.0.2\n');
+ options.stream.push(null);
+ await promise;
+ expect(getListItemByValues).toBeCalledWith(
+ expect.objectContaining({ value: ['127.0.0.1', '127.0.0.2'] })
+ );
+ });
+ });
+
+ describe('writeBufferToItems', () => {
+ test('It returns no duplicates and no lines processed when given empty items', async () => {
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([]);
+ const options = getWriteBufferToItemsOptionsMock();
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 0,
+ linesProcessed: 0,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It returns no lines processed when given items but no buffer', async () => {
+ const data = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([data]);
+ const options = getWriteBufferToItemsOptionsMock();
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 0,
+ linesProcessed: 0,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It returns 1 lines processed when given a buffer item that is not a duplicate', async () => {
+ const data = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([data]);
+ const options = getWriteBufferToItemsOptionsMock();
+ options.buffer = ['255.255.255.255'];
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 0,
+ linesProcessed: 1,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It filters a duplicate value out and reports it as a duplicate', async () => {
+ const data = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([data]);
+ const options = getWriteBufferToItemsOptionsMock();
+ options.buffer = [data.value];
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 1,
+ linesProcessed: 0,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It filters a duplicate value out and reports it as a duplicate and processing a second value as not a duplicate', async () => {
+ const data = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([data]);
+ const options = getWriteBufferToItemsOptionsMock();
+ options.buffer = ['255.255.255.255', data.value];
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 1,
+ linesProcessed: 1,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It filters a duplicate value out and reports it as a duplicate and processing two other values', async () => {
+ const data = getListItemResponseMock();
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([data]);
+ const options = getWriteBufferToItemsOptionsMock();
+ options.buffer = ['255.255.255.255', '192.168.0.1', data.value];
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 1,
+ linesProcessed: 2,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+
+ test('It filters two duplicate values out and reports processes a single value', async () => {
+ const dataItem1 = getListItemResponseMock();
+ dataItem1.value = '127.0.0.1';
+ const dataItem2 = getListItemResponseMock();
+ dataItem2.value = '127.0.0.2';
+ ((getListItemByValues as unknown) as jest.Mock).mockResolvedValueOnce([dataItem1, dataItem2]);
+ const options = getWriteBufferToItemsOptionsMock();
+ options.buffer = [dataItem1.value, dataItem2.value, '192.168.0.0.1'];
+ const linesResult = await writeBufferToItems(options);
+ const expected: LinesResult = {
+ duplicatesFound: 2,
+ linesProcessed: 1,
+ };
+ expect(linesResult).toEqual(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts
new file mode 100644
index 0000000000000..542c2bb12d8e5
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Readable } from 'stream';
+
+import { APICaller } from 'kibana/server';
+
+import { MetaOrUndefined, Type } from '../../../common/schemas';
+
+import { BufferLines } from './buffer_lines';
+import { getListItemByValues } from './get_list_item_by_values';
+import { createListItemsBulk } from './create_list_items_bulk';
+
+export interface ImportListItemsToStreamOptions {
+ listId: string;
+ stream: Readable;
+ callCluster: APICaller;
+ listItemIndex: string;
+ type: Type;
+ user: string;
+ meta: MetaOrUndefined;
+}
+
+export const importListItemsToStream = ({
+ listId,
+ stream,
+ callCluster,
+ listItemIndex,
+ type,
+ user,
+ meta,
+}: ImportListItemsToStreamOptions): Promise => {
+ return new Promise(resolve => {
+ const readBuffer = new BufferLines({ input: stream });
+ readBuffer.on('lines', async (lines: string[]) => {
+ await writeBufferToItems({
+ buffer: lines,
+ callCluster,
+ listId,
+ listItemIndex,
+ meta,
+ type,
+ user,
+ });
+ });
+
+ readBuffer.on('close', () => {
+ resolve();
+ });
+ });
+};
+
+export interface WriteBufferToItemsOptions {
+ listId: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ buffer: string[];
+ type: Type;
+ user: string;
+ meta: MetaOrUndefined;
+}
+
+export interface LinesResult {
+ linesProcessed: number;
+ duplicatesFound: number;
+}
+
+export const writeBufferToItems = async ({
+ listId,
+ callCluster,
+ listItemIndex,
+ buffer,
+ type,
+ user,
+ meta,
+}: WriteBufferToItemsOptions): Promise => {
+ const items = await getListItemByValues({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value: buffer,
+ });
+ const duplicatesRemoved = buffer.filter(
+ bufferedValue => !items.some(item => item.value === bufferedValue)
+ );
+ const linesProcessed = duplicatesRemoved.length;
+ const duplicatesFound = buffer.length - duplicatesRemoved.length;
+ await createListItemsBulk({
+ callCluster,
+ listId,
+ listItemIndex,
+ meta,
+ type,
+ user,
+ value: duplicatesRemoved,
+ });
+ return { duplicatesFound, linesProcessed };
+};
diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts
new file mode 100644
index 0000000000000..b08e5fa688b4b
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts
@@ -0,0 +1,289 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_ITEM_INDEX,
+ getCallClusterMock,
+ getExportListItemsToStreamOptionsMock,
+ getResponseOptionsMock,
+ getSearchListItemMock,
+ getWriteNextResponseOptions,
+ getWriteResponseHitsToStreamOptionsMock,
+} from '../mocks';
+
+import {
+ exportListItemsToStream,
+ getResponse,
+ getSearchAfterFromResponse,
+ writeNextResponse,
+ writeResponseHitsToStream,
+} from '.';
+
+describe('write_list_items_to_stream', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('exportListItemsToStream', () => {
+ test('It exports empty list items to the stream as an empty array', done => {
+ const options = getExportListItemsToStreamOptionsMock();
+ const firstResponse = getSearchListItemMock();
+ firstResponse.hits.hits = [];
+ options.callCluster = getCallClusterMock(firstResponse);
+ exportListItemsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.on('finish', () => {
+ expect(chunks).toEqual([]);
+ done();
+ });
+ });
+
+ test('It exports single list item to the stream', done => {
+ const options = getExportListItemsToStreamOptionsMock();
+ exportListItemsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.on('finish', () => {
+ expect(chunks).toEqual(['127.0.0.1']);
+ done();
+ });
+ });
+
+ test('It exports two list items to the stream', done => {
+ const options = getExportListItemsToStreamOptionsMock();
+ const firstResponse = getSearchListItemMock();
+ const secondResponse = getSearchListItemMock();
+ firstResponse.hits.hits = [...firstResponse.hits.hits, ...secondResponse.hits.hits];
+ options.callCluster = getCallClusterMock(firstResponse);
+ exportListItemsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.on('finish', () => {
+ expect(chunks).toEqual(['127.0.0.1', '127.0.0.1']);
+ done();
+ });
+ });
+
+ test('It exports two list items to the stream with two separate calls', done => {
+ const options = getExportListItemsToStreamOptionsMock();
+
+ const firstResponse = getSearchListItemMock();
+ firstResponse.hits.hits[0].sort = ['some-sort-value'];
+
+ const secondResponse = getSearchListItemMock();
+ secondResponse.hits.hits[0]._source.ip = '255.255.255.255';
+
+ options.callCluster = jest
+ .fn()
+ .mockResolvedValueOnce(firstResponse)
+ .mockResolvedValueOnce(secondResponse);
+
+ exportListItemsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.on('finish', () => {
+ expect(chunks).toEqual(['127.0.0.1', '255.255.255.255']);
+ done();
+ });
+ });
+ });
+
+ describe('writeNextResponse', () => {
+ test('It returns an empty searchAfter response when there is no sort defined', async () => {
+ const options = getWriteNextResponseOptions();
+ const searchAfter = await writeNextResponse(options);
+ expect(searchAfter).toEqual(undefined);
+ });
+
+ test('It returns a searchAfter response when there is a sort defined', async () => {
+ const listItem = getSearchListItemMock();
+ listItem.hits.hits[0].sort = ['sort-value-1'];
+ const options = getWriteNextResponseOptions();
+ options.callCluster = getCallClusterMock(listItem);
+ const searchAfter = await writeNextResponse(options);
+ expect(searchAfter).toEqual(['sort-value-1']);
+ });
+
+ test('It returns a searchAfter response of undefined when the response is empty', async () => {
+ const listItem = getSearchListItemMock();
+ listItem.hits.hits = [];
+ const options = getWriteNextResponseOptions();
+ options.callCluster = getCallClusterMock(listItem);
+ const searchAfter = await writeNextResponse(options);
+ expect(searchAfter).toEqual(undefined);
+ });
+ });
+
+ describe('getSearchAfterFromResponse', () => {
+ test('It returns undefined if the hits array is empty', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits = [];
+ const searchAfter = getSearchAfterFromResponse({ response });
+ expect(searchAfter).toEqual(undefined);
+ });
+
+ test('It returns undefined if the hits array does not have a sort', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits[0].sort = undefined;
+ const searchAfter = getSearchAfterFromResponse({ response });
+ expect(searchAfter).toEqual(undefined);
+ });
+
+ test('It returns a sort of a single array if that single item exists', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits[0].sort = ['sort-value-1', 'sort-value-2'];
+ const searchAfter = getSearchAfterFromResponse({ response });
+ expect(searchAfter).toEqual(['sort-value-1', 'sort-value-2']);
+ });
+
+ test('It returns a sort of the last array element of size 2', () => {
+ const response = getSearchListItemMock();
+ const response2 = getSearchListItemMock();
+ response2.hits.hits[0].sort = ['sort-value'];
+ response.hits.hits = [...response.hits.hits, ...response2.hits.hits];
+ const searchAfter = getSearchAfterFromResponse({ response });
+ expect(searchAfter).toEqual(['sort-value']);
+ });
+ });
+
+ describe('getResponse', () => {
+ test('It returns a simple response with the default size of 100', async () => {
+ const options = getResponseOptionsMock();
+ options.searchAfter = ['string 1', 'string 2'];
+ await getResponse(options);
+ const expected = {
+ body: {
+ query: { term: { list_id: LIST_ID } },
+ search_after: ['string 1', 'string 2'],
+ sort: [{ tie_breaker_id: 'asc' }],
+ },
+ ignoreUnavailable: true,
+ index: LIST_ITEM_INDEX,
+ size: 100,
+ };
+ expect(options.callCluster).toBeCalledWith('search', expected);
+ });
+
+ test('It returns a simple response with expected values and size changed', async () => {
+ const options = getResponseOptionsMock();
+ options.searchAfter = ['string 1', 'string 2'];
+ options.size = 33;
+ await getResponse(options);
+ const expected = {
+ body: {
+ query: { term: { list_id: LIST_ID } },
+ search_after: ['string 1', 'string 2'],
+ sort: [{ tie_breaker_id: 'asc' }],
+ },
+ ignoreUnavailable: true,
+ index: LIST_ITEM_INDEX,
+ size: 33,
+ };
+ expect(options.callCluster).toBeCalledWith('search', expected);
+ });
+ });
+
+ describe('writeResponseHitsToStream', () => {
+ test('it will push into the stream the mock response', done => {
+ const options = getWriteResponseHitsToStreamOptionsMock();
+ writeResponseHitsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.end(() => {
+ expect(chunks).toEqual(['127.0.0.1']);
+ done();
+ });
+ });
+
+ test('it will push into the stream an empty mock response', done => {
+ const options = getWriteResponseHitsToStreamOptionsMock();
+ options.response.hits.hits = [];
+ writeResponseHitsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.on('finish', () => {
+ expect(chunks).toEqual([]);
+ done();
+ });
+ options.stream.end();
+ });
+
+ test('it will push into the stream 2 mock responses', done => {
+ const options = getWriteResponseHitsToStreamOptionsMock();
+ const secondResponse = getSearchListItemMock();
+ options.response.hits.hits = [...options.response.hits.hits, ...secondResponse.hits.hits];
+ writeResponseHitsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.end(() => {
+ expect(chunks).toEqual(['127.0.0.1', '127.0.0.1']);
+ done();
+ });
+ });
+
+ test('it will push an additional string given to it such as a new line character', done => {
+ const options = getWriteResponseHitsToStreamOptionsMock();
+ const secondResponse = getSearchListItemMock();
+ options.response.hits.hits = [...options.response.hits.hits, ...secondResponse.hits.hits];
+ options.stringToAppend = '\n';
+ writeResponseHitsToStream(options);
+
+ let chunks: string[] = [];
+ options.stream.on('data', (chunk: Buffer) => {
+ chunks = [...chunks, chunk.toString()];
+ });
+
+ options.stream.end(() => {
+ expect(chunks).toEqual(['127.0.0.1\n', '127.0.0.1\n']);
+ done();
+ });
+ });
+
+ test('it will throw an exception with a status code if the hit_source is not a data type we expect', () => {
+ const options = getWriteResponseHitsToStreamOptionsMock();
+ options.response.hits.hits[0]._source.ip = undefined;
+ options.response.hits.hits[0]._source.keyword = undefined;
+ const expected = `Encountered an error where hit._source was an unexpected type: ${JSON.stringify(
+ options.response.hits.hits[0]._source
+ )}`;
+ expect(() => writeResponseHitsToStream(options)).toThrow(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts
new file mode 100644
index 0000000000000..b81e4a4fc73c2
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts
@@ -0,0 +1,160 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PassThrough } from 'stream';
+
+import { SearchResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import { SearchEsListItemSchema } from '../../../common/schemas';
+import { ErrorWithStatusCode } from '../../error_with_status_code';
+
+/**
+ * How many results to page through from the network at a time
+ * using search_after
+ */
+export const SIZE = 100;
+
+export interface ExportListItemsToStreamOptions {
+ listId: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ stream: PassThrough;
+ stringToAppend: string | null | undefined;
+}
+
+export const exportListItemsToStream = ({
+ listId,
+ callCluster,
+ stream,
+ listItemIndex,
+ stringToAppend,
+}: ExportListItemsToStreamOptions): void => {
+ // Use a timeout to start the reading process on the next tick.
+ // and prevent the async await from bubbling up to the caller
+ setTimeout(async () => {
+ let searchAfter = await writeNextResponse({
+ callCluster,
+ listId,
+ listItemIndex,
+ searchAfter: undefined,
+ stream,
+ stringToAppend,
+ });
+ while (searchAfter != null) {
+ searchAfter = await writeNextResponse({
+ callCluster,
+ listId,
+ listItemIndex,
+ searchAfter,
+ stream,
+ stringToAppend,
+ });
+ }
+ stream.end();
+ });
+};
+
+export interface WriteNextResponseOptions {
+ listId: string;
+ callCluster: APICaller;
+ listItemIndex: string;
+ stream: PassThrough;
+ searchAfter: string[] | undefined;
+ stringToAppend: string | null | undefined;
+}
+
+export const writeNextResponse = async ({
+ listId,
+ callCluster,
+ stream,
+ listItemIndex,
+ searchAfter,
+ stringToAppend,
+}: WriteNextResponseOptions): Promise => {
+ const response = await getResponse({
+ callCluster,
+ listId,
+ listItemIndex,
+ searchAfter,
+ });
+
+ if (response.hits.hits.length) {
+ writeResponseHitsToStream({ response, stream, stringToAppend });
+ return getSearchAfterFromResponse({ response });
+ } else {
+ return undefined;
+ }
+};
+
+export const getSearchAfterFromResponse = ({
+ response,
+}: {
+ response: SearchResponse;
+}): string[] | undefined =>
+ response.hits.hits.length > 0
+ ? response.hits.hits[response.hits.hits.length - 1].sort
+ : undefined;
+
+export interface GetResponseOptions {
+ callCluster: APICaller;
+ listId: string;
+ searchAfter: undefined | string[];
+ listItemIndex: string;
+ size?: number;
+}
+
+export const getResponse = async ({
+ callCluster,
+ searchAfter,
+ listId,
+ listItemIndex,
+ size = SIZE,
+}: GetResponseOptions): Promise> => {
+ return callCluster('search', {
+ body: {
+ query: {
+ term: {
+ list_id: listId,
+ },
+ },
+ search_after: searchAfter,
+ sort: [{ tie_breaker_id: 'asc' }],
+ },
+ ignoreUnavailable: true,
+ index: listItemIndex,
+ size,
+ });
+};
+
+export interface WriteResponseHitsToStreamOptions {
+ response: SearchResponse;
+ stream: PassThrough;
+ stringToAppend: string | null | undefined;
+}
+
+export const writeResponseHitsToStream = ({
+ response,
+ stream,
+ stringToAppend,
+}: WriteResponseHitsToStreamOptions): void => {
+ const stringToAppendOrEmpty = stringToAppend ?? '';
+
+ response.hits.hits.forEach(hit => {
+ if (hit._source.ip != null) {
+ stream.push(`${hit._source.ip}${stringToAppendOrEmpty}`);
+ } else if (hit._source.keyword != null) {
+ stream.push(`${hit._source.keyword}${stringToAppendOrEmpty}`);
+ } else {
+ throw new ErrorWithStatusCode(
+ `Encountered an error where hit._source was an unexpected type: ${JSON.stringify(
+ hit._source
+ )}`,
+ 400
+ );
+ }
+ });
+};
diff --git a/x-pack/plugins/lists/server/services/lists/client.ts b/x-pack/plugins/lists/server/services/lists/client.ts
new file mode 100644
index 0000000000000..ba22bf72cc18c
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/client.ts
@@ -0,0 +1,413 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { APICaller } from 'kibana/server';
+
+import { ListItemArraySchema, ListItemSchema, ListSchema } from '../../../common/schemas';
+import { ConfigType } from '../../config';
+import {
+ createList,
+ deleteList,
+ getList,
+ getListIndex,
+ getListTemplate,
+ updateList,
+} from '../../services/lists';
+import {
+ createListItem,
+ deleteListItem,
+ deleteListItemByValue,
+ exportListItemsToStream,
+ getListItem,
+ getListItemByValue,
+ getListItemByValues,
+ getListItemIndex,
+ getListItemTemplate,
+ importListItemsToStream,
+ updateListItem,
+} from '../../services/items';
+import {
+ createBootstrapIndex,
+ deleteAllIndex,
+ deletePolicy,
+ deleteTemplate,
+ getIndexExists,
+ getPolicyExists,
+ getTemplateExists,
+ setPolicy,
+ setTemplate,
+} from '../../siem_server_deps';
+import listsItemsPolicy from '../items/list_item_policy.json';
+
+import listPolicy from './list_policy.json';
+import {
+ ConstructorOptions,
+ CreateListIfItDoesNotExistOptions,
+ CreateListItemOptions,
+ CreateListOptions,
+ DeleteListItemByValueOptions,
+ DeleteListItemOptions,
+ DeleteListOptions,
+ ExportListItemsToStreamOptions,
+ GetListItemByValueOptions,
+ GetListItemOptions,
+ GetListItemsByValueOptions,
+ GetListOptions,
+ ImportListItemsToStreamOptions,
+ UpdateListItemOptions,
+ UpdateListOptions,
+} from './client_types';
+
+export class ListClient {
+ private readonly spaceId: string;
+ private readonly user: string;
+ private readonly config: ConfigType;
+ private readonly callCluster: APICaller;
+
+ constructor({ spaceId, user, config, callCluster }: ConstructorOptions) {
+ this.spaceId = spaceId;
+ this.user = user;
+ this.config = config;
+ this.callCluster = callCluster;
+ }
+
+ public getListIndex = (): string => {
+ const {
+ spaceId,
+ config: { listIndex: listsIndexName },
+ } = this;
+ return getListIndex({ listsIndexName, spaceId });
+ };
+
+ public getListItemIndex = (): string => {
+ const {
+ spaceId,
+ config: { listItemIndex: listsItemsIndexName },
+ } = this;
+ return getListItemIndex({ listsItemsIndexName, spaceId });
+ };
+
+ public getList = async ({ id }: GetListOptions): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return getList({ callCluster, id, listIndex });
+ };
+
+ public createList = async ({
+ id,
+ name,
+ description,
+ type,
+ meta,
+ }: CreateListOptions): Promise => {
+ const { callCluster, user } = this;
+ const listIndex = this.getListIndex();
+ return createList({ callCluster, description, id, listIndex, meta, name, type, user });
+ };
+
+ public createListIfItDoesNotExist = async ({
+ id,
+ name,
+ description,
+ type,
+ meta,
+ }: CreateListIfItDoesNotExistOptions): Promise => {
+ const list = await this.getList({ id });
+ if (list == null) {
+ return this.createList({ description, id, meta, name, type });
+ } else {
+ return list;
+ }
+ };
+
+ public getListIndexExists = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return getIndexExists(callCluster, listIndex);
+ };
+
+ public getListItemIndexExists = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return getIndexExists(callCluster, listItemIndex);
+ };
+
+ public createListBootStrapIndex = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return createBootstrapIndex(callCluster, listIndex);
+ };
+
+ public createListItemBootStrapIndex = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return createBootstrapIndex(callCluster, listItemIndex);
+ };
+
+ public getListPolicyExists = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return getPolicyExists(callCluster, listIndex);
+ };
+
+ public getListItemPolicyExists = async (): Promise => {
+ const { callCluster } = this;
+ const listsItemIndex = this.getListItemIndex();
+ return getPolicyExists(callCluster, listsItemIndex);
+ };
+
+ public getListTemplateExists = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return getTemplateExists(callCluster, listIndex);
+ };
+
+ public getListItemTemplateExists = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return getTemplateExists(callCluster, listItemIndex);
+ };
+
+ public getListTemplate = (): Record => {
+ const listIndex = this.getListIndex();
+ return getListTemplate(listIndex);
+ };
+
+ public getListItemTemplate = (): Record => {
+ const listItemIndex = this.getListItemIndex();
+ return getListItemTemplate(listItemIndex);
+ };
+
+ public setListTemplate = async (): Promise => {
+ const { callCluster } = this;
+ const template = this.getListTemplate();
+ const listIndex = this.getListIndex();
+ return setTemplate(callCluster, listIndex, template);
+ };
+
+ public setListItemTemplate = async (): Promise => {
+ const { callCluster } = this;
+ const template = this.getListItemTemplate();
+ const listItemIndex = this.getListItemIndex();
+ return setTemplate(callCluster, listItemIndex, template);
+ };
+
+ public setListPolicy = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return setPolicy(callCluster, listIndex, listPolicy);
+ };
+
+ public setListItemPolicy = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return setPolicy(callCluster, listItemIndex, listsItemsPolicy);
+ };
+
+ public deleteListIndex = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return deleteAllIndex(callCluster, `${listIndex}-*`);
+ };
+
+ public deleteListItemIndex = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return deleteAllIndex(callCluster, `${listItemIndex}-*`);
+ };
+
+ public deleteListPolicy = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return deletePolicy(callCluster, listIndex);
+ };
+
+ public deleteListItemPolicy = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return deletePolicy(callCluster, listItemIndex);
+ };
+
+ public deleteListTemplate = async (): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ return deleteTemplate(callCluster, listIndex);
+ };
+
+ public deleteListItemTemplate = async (): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return deleteTemplate(callCluster, listItemIndex);
+ };
+
+ public deleteListItem = async ({ id }: DeleteListItemOptions): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return deleteListItem({ callCluster, id, listItemIndex });
+ };
+
+ public deleteListItemByValue = async ({
+ listId,
+ value,
+ type,
+ }: DeleteListItemByValueOptions): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return deleteListItemByValue({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value,
+ });
+ };
+
+ public deleteList = async ({ id }: DeleteListOptions): Promise => {
+ const { callCluster } = this;
+ const listIndex = this.getListIndex();
+ const listItemIndex = this.getListItemIndex();
+ return deleteList({
+ callCluster,
+ id,
+ listIndex,
+ listItemIndex,
+ });
+ };
+
+ public exportListItemsToStream = ({
+ stringToAppend,
+ listId,
+ stream,
+ }: ExportListItemsToStreamOptions): void => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ exportListItemsToStream({
+ callCluster,
+ listId,
+ listItemIndex,
+ stream,
+ stringToAppend,
+ });
+ };
+
+ public importListItemsToStream = async ({
+ type,
+ listId,
+ stream,
+ meta,
+ }: ImportListItemsToStreamOptions): Promise => {
+ const { callCluster, user } = this;
+ const listItemIndex = this.getListItemIndex();
+ return importListItemsToStream({
+ callCluster,
+ listId,
+ listItemIndex,
+ meta,
+ stream,
+ type,
+ user,
+ });
+ };
+
+ public getListItemByValue = async ({
+ listId,
+ value,
+ type,
+ }: GetListItemByValueOptions): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return getListItemByValue({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value,
+ });
+ };
+
+ public createListItem = async ({
+ id,
+ listId,
+ value,
+ type,
+ meta,
+ }: CreateListItemOptions): Promise => {
+ const { callCluster, user } = this;
+ const listItemIndex = this.getListItemIndex();
+ return createListItem({
+ callCluster,
+ id,
+ listId,
+ listItemIndex,
+ meta,
+ type,
+ user,
+ value,
+ });
+ };
+
+ public updateListItem = async ({
+ id,
+ value,
+ meta,
+ }: UpdateListItemOptions): Promise => {
+ const { callCluster, user } = this;
+ const listItemIndex = this.getListItemIndex();
+ return updateListItem({
+ callCluster,
+ id,
+ listItemIndex,
+ meta,
+ user,
+ value,
+ });
+ };
+
+ public updateList = async ({
+ id,
+ name,
+ description,
+ meta,
+ }: UpdateListOptions): Promise => {
+ const { callCluster, user } = this;
+ const listIndex = this.getListIndex();
+ return updateList({
+ callCluster,
+ description,
+ id,
+ listIndex,
+ meta,
+ name,
+ user,
+ });
+ };
+
+ public getListItem = async ({ id }: GetListItemOptions): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return getListItem({
+ callCluster,
+ id,
+ listItemIndex,
+ });
+ };
+
+ public getListItemByValues = async ({
+ type,
+ listId,
+ value,
+ }: GetListItemsByValueOptions): Promise => {
+ const { callCluster } = this;
+ const listItemIndex = this.getListItemIndex();
+ return getListItemByValues({
+ callCluster,
+ listId,
+ listItemIndex,
+ type,
+ value,
+ });
+ };
+}
diff --git a/x-pack/plugins/lists/server/services/lists/client_types.ts b/x-pack/plugins/lists/server/services/lists/client_types.ts
new file mode 100644
index 0000000000000..2cc58c02dbfcf
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/client_types.ts
@@ -0,0 +1,115 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PassThrough, Readable } from 'stream';
+
+import { APICaller, KibanaRequest } from 'kibana/server';
+
+import { SecurityPluginSetup } from '../../../../security/server';
+import {
+ Description,
+ DescriptionOrUndefined,
+ Id,
+ IdOrUndefined,
+ MetaOrUndefined,
+ Name,
+ NameOrUndefined,
+ Type,
+} from '../../../common/schemas';
+import { ConfigType } from '../../config';
+
+export interface ConstructorOptions {
+ callCluster: APICaller;
+ config: ConfigType;
+ request: KibanaRequest;
+ spaceId: string;
+ user: string;
+ security: SecurityPluginSetup | undefined | null;
+}
+
+export interface GetListOptions {
+ id: Id;
+}
+
+export interface DeleteListOptions {
+ id: Id;
+}
+
+export interface DeleteListItemOptions {
+ id: Id;
+}
+
+export interface CreateListOptions {
+ id: IdOrUndefined;
+ name: Name;
+ description: Description;
+ type: Type;
+ meta: MetaOrUndefined;
+}
+
+export interface CreateListIfItDoesNotExistOptions {
+ id: Id;
+ name: Name;
+ description: Description;
+ type: Type;
+ meta: MetaOrUndefined;
+}
+
+export interface DeleteListItemByValueOptions {
+ listId: string;
+ value: string;
+ type: Type;
+}
+
+export interface GetListItemByValueOptions {
+ listId: string;
+ value: string;
+ type: Type;
+}
+
+export interface ExportListItemsToStreamOptions {
+ stringToAppend: string | null | undefined;
+ listId: string;
+ stream: PassThrough;
+}
+
+export interface ImportListItemsToStreamOptions {
+ listId: string;
+ type: Type;
+ stream: Readable;
+ meta: MetaOrUndefined;
+}
+
+export interface CreateListItemOptions {
+ id: IdOrUndefined;
+ listId: string;
+ type: Type;
+ value: string;
+ meta: MetaOrUndefined;
+}
+
+export interface UpdateListItemOptions {
+ id: Id;
+ value: string | null | undefined;
+ meta: MetaOrUndefined;
+}
+
+export interface UpdateListOptions {
+ id: Id;
+ name: NameOrUndefined;
+ description: DescriptionOrUndefined;
+ meta: MetaOrUndefined;
+}
+
+export interface GetListItemOptions {
+ id: Id;
+}
+
+export interface GetListItemsByValueOptions {
+ type: Type;
+ listId: string;
+ value: string[];
+}
diff --git a/x-pack/plugins/lists/server/services/lists/create_list.test.ts b/x-pack/plugins/lists/server/services/lists/create_list.test.ts
new file mode 100644
index 0000000000000..36284a70fb97d
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/create_list.test.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_INDEX,
+ getCreateListOptionsMock,
+ getIndexESListMock,
+ getListResponseMock,
+} from '../mocks';
+
+import { createList } from './create_list';
+
+describe('crete_list', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list as expected with the id changed out for the elastic id', async () => {
+ const options = getCreateListOptionsMock();
+ const list = await createList(options);
+ const expected = getListResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(list).toEqual(expected);
+ });
+
+ test('It calls "callCluster" with body, index, and listIndex', async () => {
+ const options = getCreateListOptionsMock();
+ await createList(options);
+ const body = getIndexESListMock();
+ const expected = {
+ body,
+ id: LIST_ID,
+ index: LIST_INDEX,
+ };
+ expect(options.callCluster).toBeCalledWith('index', expected);
+ });
+
+ test('It returns an auto-generated id if id is sent in undefined', async () => {
+ const options = getCreateListOptionsMock();
+ options.id = undefined;
+ const list = await createList(options);
+ const expected = getListResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(list).toEqual(expected);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts
new file mode 100644
index 0000000000000..ddbc99c88a877
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/create_list.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import uuid from 'uuid';
+import { CreateDocumentResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import {
+ Description,
+ IdOrUndefined,
+ IndexEsListSchema,
+ ListSchema,
+ MetaOrUndefined,
+ Name,
+ Type,
+} from '../../../common/schemas';
+
+export interface CreateListOptions {
+ id: IdOrUndefined;
+ type: Type;
+ name: Name;
+ description: Description;
+ callCluster: APICaller;
+ listIndex: string;
+ user: string;
+ meta: MetaOrUndefined;
+ dateNow?: string;
+ tieBreaker?: string;
+}
+
+export const createList = async ({
+ id,
+ name,
+ type,
+ description,
+ callCluster,
+ listIndex,
+ user,
+ meta,
+ dateNow,
+ tieBreaker,
+}: CreateListOptions): Promise => {
+ const createdAt = dateNow ?? new Date().toISOString();
+ const body: IndexEsListSchema = {
+ created_at: createdAt,
+ created_by: user,
+ description,
+ meta,
+ name,
+ tie_breaker_id: tieBreaker ?? uuid.v4(),
+ type,
+ updated_at: createdAt,
+ updated_by: user,
+ };
+ const response: CreateDocumentResponse = await callCluster('index', {
+ body,
+ id,
+ index: listIndex,
+ });
+ return {
+ id: response._id,
+ ...body,
+ };
+};
diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.test.ts b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts
new file mode 100644
index 0000000000000..62b5e7c7aec4a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_INDEX,
+ LIST_ITEM_INDEX,
+ getDeleteListOptionsMock,
+ getListResponseMock,
+} from '../mocks';
+
+import { getList } from './get_list';
+import { deleteList } from './delete_list';
+
+jest.mock('./get_list', () => ({
+ getList: jest.fn(),
+}));
+
+describe('delete_list', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Delete returns a null if the list is also null', async () => {
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(null);
+ const options = getDeleteListOptionsMock();
+ const deletedList = await deleteList(options);
+ expect(deletedList).toEqual(null);
+ });
+
+ test('Delete returns the list if a list is returned from getList', async () => {
+ const list = getListResponseMock();
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list);
+ const options = getDeleteListOptionsMock();
+ const deletedList = await deleteList(options);
+ expect(deletedList).toEqual(list);
+ });
+
+ test('Delete calls "deleteByQuery" and "delete" if a list is returned from getList', async () => {
+ const list = getListResponseMock();
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list);
+ const options = getDeleteListOptionsMock();
+ await deleteList(options);
+ const deleteByQuery = {
+ body: { query: { term: { list_id: LIST_ID } } },
+ index: LIST_ITEM_INDEX,
+ };
+ expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery);
+ });
+
+ test('Delete calls "delete" second if a list is returned from getList', async () => {
+ const list = getListResponseMock();
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list);
+ const options = getDeleteListOptionsMock();
+ await deleteList(options);
+ const deleteQuery = {
+ id: LIST_ID,
+ index: LIST_INDEX,
+ };
+ expect(options.callCluster).toHaveBeenNthCalledWith(2, 'delete', deleteQuery);
+ });
+
+ test('Delete does not call data client if the list returns null', async () => {
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(null);
+ const options = getDeleteListOptionsMock();
+ await deleteList(options);
+ expect(options.callCluster).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.ts b/x-pack/plugins/lists/server/services/lists/delete_list.ts
new file mode 100644
index 0000000000000..bc66c88b082a3
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/delete_list.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { APICaller } from 'kibana/server';
+
+import { Id, ListSchema } from '../../../common/schemas';
+
+import { getList } from './get_list';
+
+export interface DeleteListOptions {
+ id: Id;
+ callCluster: APICaller;
+ listIndex: string;
+ listItemIndex: string;
+}
+
+export const deleteList = async ({
+ id,
+ callCluster,
+ listIndex,
+ listItemIndex,
+}: DeleteListOptions): Promise => {
+ const list = await getList({ callCluster, id, listIndex });
+ if (list == null) {
+ return null;
+ } else {
+ await callCluster('deleteByQuery', {
+ body: {
+ query: {
+ term: {
+ list_id: id,
+ },
+ },
+ },
+ index: listItemIndex,
+ });
+
+ await callCluster('delete', {
+ id,
+ index: listIndex,
+ });
+ return list;
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/lists/get_list.test.ts b/x-pack/plugins/lists/server/services/lists/get_list.test.ts
new file mode 100644
index 0000000000000..c997d5325296a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ LIST_ID,
+ LIST_INDEX,
+ getCallClusterMock,
+ getListResponseMock,
+ getSearchListMock,
+} from '../mocks';
+
+import { getList } from './get_list';
+
+describe('get_list', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list as expected if the list is found', async () => {
+ const data = getSearchListMock();
+ const callCluster = getCallClusterMock(data);
+ const list = await getList({ callCluster, id: LIST_ID, listIndex: LIST_INDEX });
+ const expected = getListResponseMock();
+ expect(list).toEqual(expected);
+ });
+
+ test('it returns null if the search is empty', async () => {
+ const data = getSearchListMock();
+ data.hits.hits = [];
+ const callCluster = getCallClusterMock(data);
+ const list = await getList({ callCluster, id: LIST_ID, listIndex: LIST_INDEX });
+ expect(list).toEqual(null);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/get_list.ts b/x-pack/plugins/lists/server/services/lists/get_list.ts
new file mode 100644
index 0000000000000..c04bd504ad8c0
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import { Id, ListSchema, SearchEsListSchema } from '../../../common/schemas';
+
+interface GetListOptions {
+ id: Id;
+ callCluster: APICaller;
+ listIndex: string;
+}
+
+export const getList = async ({
+ id,
+ callCluster,
+ listIndex,
+}: GetListOptions): Promise => {
+ const result: SearchResponse = await callCluster('search', {
+ body: {
+ query: {
+ term: {
+ _id: id,
+ },
+ },
+ },
+ ignoreUnavailable: true,
+ index: listIndex,
+ });
+ if (result.hits.hits.length) {
+ return {
+ id: result.hits.hits[0]._id,
+ ...result.hits.hits[0]._source,
+ };
+ } else {
+ return null;
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/lists/get_list_index.test.ts b/x-pack/plugins/lists/server/services/lists/get_list_index.test.ts
new file mode 100644
index 0000000000000..f82928ffeddd2
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list_index.test.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListIndex } from './get_list_index';
+
+describe('get_list_index', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Returns the list index when there is a space', async () => {
+ const listIndex = getListIndex({
+ listsIndexName: 'lists-index',
+ spaceId: 'test-space',
+ });
+ expect(listIndex).toEqual('lists-index-test-space');
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/get_list_index.ts b/x-pack/plugins/lists/server/services/lists/get_list_index.ts
new file mode 100644
index 0000000000000..5086603fa8403
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list_index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+interface GetListIndexOptions {
+ spaceId: string;
+ listsIndexName: string;
+}
+
+export const getListIndex = ({ spaceId, listsIndexName }: GetListIndexOptions): string =>
+ `${listsIndexName}-${spaceId}`;
diff --git a/x-pack/plugins/lists/server/services/lists/get_list_template.test.ts b/x-pack/plugins/lists/server/services/lists/get_list_template.test.ts
new file mode 100644
index 0000000000000..e25eaaafd855e
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list_template.test.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListTemplate } from './get_list_template';
+
+jest.mock('./list_mappings.json', () => ({
+ listMappings: {},
+}));
+
+describe('get_list_template', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list template with the string filled in', async () => {
+ const template = getListTemplate('some_index');
+ expect(template).toEqual({
+ index_patterns: ['some_index-*'],
+ mappings: { listMappings: {} },
+ settings: { index: { lifecycle: { name: 'some_index', rollover_alias: 'some_index' } } },
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/get_list_template.ts b/x-pack/plugins/lists/server/services/lists/get_list_template.ts
new file mode 100644
index 0000000000000..9d93a051f2d10
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/get_list_template.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import listMappings from './list_mappings.json';
+
+export const getListTemplate = (index: string): Record => ({
+ index_patterns: [`${index}-*`],
+ mappings: listMappings,
+ settings: {
+ index: {
+ lifecycle: {
+ name: index,
+ rollover_alias: index,
+ },
+ },
+ },
+});
diff --git a/x-pack/plugins/lists/server/services/lists/index.ts b/x-pack/plugins/lists/server/services/lists/index.ts
new file mode 100644
index 0000000000000..f704ef0b05b82
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/index.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './create_list';
+export * from './delete_list';
+export * from './get_list';
+export * from './get_list_template';
+export * from './update_list';
+export * from './get_list_index';
diff --git a/x-pack/plugins/lists/server/services/lists/list_mappings.json b/x-pack/plugins/lists/server/services/lists/list_mappings.json
new file mode 100644
index 0000000000000..1136a53da787d
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/list_mappings.json
@@ -0,0 +1,33 @@
+{
+ "dynamic": "strict",
+ "properties": {
+ "name": {
+ "type": "keyword"
+ },
+ "description": {
+ "type": "keyword"
+ },
+ "type": {
+ "type": "keyword"
+ },
+ "tie_breaker_id": {
+ "type": "keyword"
+ },
+ "meta": {
+ "enabled": "false",
+ "type": "object"
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "created_by": {
+ "type": "keyword"
+ },
+ "updated_by": {
+ "type": "keyword"
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/services/lists/list_policy.json b/x-pack/plugins/lists/server/services/lists/list_policy.json
new file mode 100644
index 0000000000000..a4c84f73e7896
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/list_policy.json
@@ -0,0 +1,14 @@
+{
+ "policy": {
+ "phases": {
+ "hot": {
+ "min_age": "0ms",
+ "actions": {
+ "rollover": {
+ "max_size": "50gb"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/lists/server/services/lists/update_list.test.ts b/x-pack/plugins/lists/server/services/lists/update_list.test.ts
new file mode 100644
index 0000000000000..09bf0ee69c981
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/update_list.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getListResponseMock, getUpdateListOptionsMock } from '../mocks';
+
+import { updateList } from './update_list';
+import { getList } from './get_list';
+
+jest.mock('./get_list', () => ({
+ getList: jest.fn(),
+}));
+
+describe('update_list', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns a list as expected with the id changed out for the elastic id when there is a list to update', async () => {
+ const list = getListResponseMock();
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list);
+ const options = getUpdateListOptionsMock();
+ const updatedList = await updateList(options);
+ const expected = getListResponseMock();
+ expected.id = 'elastic-id-123';
+ expect(updatedList).toEqual(expected);
+ });
+
+ test('it returns null when there is not a list to update', async () => {
+ ((getList as unknown) as jest.Mock).mockResolvedValueOnce(null);
+ const options = getUpdateListOptionsMock();
+ const updatedList = await updateList(options);
+ expect(updatedList).toEqual(null);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts
new file mode 100644
index 0000000000000..9859adf062485
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/lists/update_list.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateDocumentResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import {
+ DescriptionOrUndefined,
+ Id,
+ ListSchema,
+ MetaOrUndefined,
+ NameOrUndefined,
+ UpdateEsListSchema,
+} from '../../../common/schemas';
+
+import { getList } from '.';
+
+export interface UpdateListOptions {
+ id: Id;
+ callCluster: APICaller;
+ listIndex: string;
+ user: string;
+ name: NameOrUndefined;
+ description: DescriptionOrUndefined;
+ meta: MetaOrUndefined;
+ dateNow?: string;
+}
+
+export const updateList = async ({
+ id,
+ name,
+ description,
+ callCluster,
+ listIndex,
+ user,
+ meta,
+ dateNow,
+}: UpdateListOptions): Promise => {
+ const updatedAt = dateNow ?? new Date().toISOString();
+ const list = await getList({ callCluster, id, listIndex });
+ if (list == null) {
+ return null;
+ } else {
+ const doc: UpdateEsListSchema = {
+ description,
+ meta,
+ name,
+ updated_at: updatedAt,
+ updated_by: user,
+ };
+ const response: CreateDocumentResponse = await callCluster('update', {
+ body: { doc },
+ id,
+ index: listIndex,
+ });
+ return {
+ created_at: list.created_at,
+ created_by: list.created_by,
+ description: description ?? list.description,
+ id: response._id,
+ meta,
+ name: name ?? list.name,
+ tie_breaker_id: list.tie_breaker_id,
+ type: list.type,
+ updated_at: updatedAt,
+ updated_by: user,
+ };
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/mocks/get_call_cluster_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_call_cluster_mock.ts
new file mode 100644
index 0000000000000..180ecbb797339
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_call_cluster_mock.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateDocumentResponse } from 'elasticsearch';
+import { APICaller } from 'kibana/server';
+
+import { LIST_INDEX } from './lists_services_mock_constants';
+import { getShardMock } from './get_shard_mock';
+
+export const getEmptyCreateDocumentResponseMock = (): CreateDocumentResponse => ({
+ _id: 'elastic-id-123',
+ _index: LIST_INDEX,
+ _shards: getShardMock(),
+ _type: '',
+ _version: 1,
+ created: true,
+ result: '',
+});
+
+export const getCallClusterMock = (
+ callCluster: unknown = getEmptyCreateDocumentResponseMock()
+): APICaller => jest.fn().mockResolvedValue(callCluster);
diff --git a/x-pack/plugins/lists/server/services/mocks/get_create_list_item_bulk_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_create_list_item_bulk_options_mock.ts
new file mode 100644
index 0000000000000..fcdad66d65251
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_create_list_item_bulk_options_mock.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateListItemsBulkOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import {
+ DATE_NOW,
+ LIST_ID,
+ LIST_ITEM_INDEX,
+ META,
+ TIE_BREAKERS,
+ TYPE,
+ USER,
+ VALUE,
+ VALUE_2,
+} from './lists_services_mock_constants';
+
+export const getCreateListItemBulkOptionsMock = (): CreateListItemsBulkOptions => ({
+ callCluster: getCallClusterMock(),
+ dateNow: DATE_NOW,
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ meta: META,
+ tieBreaker: TIE_BREAKERS,
+ type: TYPE,
+ user: USER,
+ value: [VALUE, VALUE_2],
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_create_list_item_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_create_list_item_options_mock.ts
new file mode 100644
index 0000000000000..17e3ad2f8de08
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_create_list_item_options_mock.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateListItemOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import {
+ DATE_NOW,
+ LIST_ID,
+ LIST_ITEM_ID,
+ LIST_ITEM_INDEX,
+ META,
+ TIE_BREAKER,
+ TYPE,
+ USER,
+} from './lists_services_mock_constants';
+
+export const getCreateListItemOptionsMock = (): CreateListItemOptions => ({
+ callCluster: getCallClusterMock(),
+ dateNow: DATE_NOW,
+ id: LIST_ITEM_ID,
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ meta: META,
+ tieBreaker: TIE_BREAKER,
+ type: TYPE,
+ user: USER,
+ value: '127.0.0.1',
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_create_list_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_create_list_options_mock.ts
new file mode 100644
index 0000000000000..0ea6533fc122a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_create_list_options_mock.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CreateListOptions } from '../lists';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import {
+ DATE_NOW,
+ DESCRIPTION,
+ LIST_ID,
+ LIST_INDEX,
+ META,
+ NAME,
+ TIE_BREAKER,
+ TYPE,
+ USER,
+} from './lists_services_mock_constants';
+
+export const getCreateListOptionsMock = (): CreateListOptions => ({
+ callCluster: getCallClusterMock(),
+ dateNow: DATE_NOW,
+ description: DESCRIPTION,
+ id: LIST_ID,
+ listIndex: LIST_INDEX,
+ meta: META,
+ name: NAME,
+ tieBreaker: TIE_BREAKER,
+ type: TYPE,
+ user: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_by_value_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_by_value_options_mock.ts
new file mode 100644
index 0000000000000..f6859e72d71b3
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_by_value_options_mock.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DeleteListItemByValueOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE } from './lists_services_mock_constants';
+
+export const getDeleteListItemByValueOptionsMock = (): DeleteListItemByValueOptions => ({
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ type: TYPE,
+ value: VALUE,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_options_mock.ts
new file mode 100644
index 0000000000000..271c185860b07
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_delete_list_item_options_mock.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DeleteListItemOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ITEM_ID, LIST_ITEM_INDEX } from './lists_services_mock_constants';
+
+export const getDeleteListItemOptionsMock = (): DeleteListItemOptions => ({
+ callCluster: getCallClusterMock(),
+ id: LIST_ITEM_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_delete_list_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_delete_list_options_mock.ts
new file mode 100644
index 0000000000000..8ec92dfa4ef77
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_delete_list_options_mock.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DeleteListOptions } from '../lists';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_INDEX, LIST_ITEM_INDEX } from './lists_services_mock_constants';
+
+export const getDeleteListOptionsMock = (): DeleteListOptions => ({
+ callCluster: getCallClusterMock(),
+ id: LIST_ID,
+ listIndex: LIST_INDEX,
+ listItemIndex: LIST_ITEM_INDEX,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_import_list_items_to_stream_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_import_list_items_to_stream_options_mock.ts
new file mode 100644
index 0000000000000..d7541f3e09e6c
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_import_list_items_to_stream_options_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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { ImportListItemsToStreamOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_ITEM_INDEX, META, TYPE, USER } from './lists_services_mock_constants';
+import { TestReadable } from './test_readable';
+
+export const getImportListItemsToStreamOptionsMock = (): ImportListItemsToStreamOptions => ({
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ meta: META,
+ stream: new TestReadable(),
+ type: TYPE,
+ user: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_index_es_list_item_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_index_es_list_item_mock.ts
new file mode 100644
index 0000000000000..574e4afcb36f0
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_index_es_list_item_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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IndexEsListItemSchema } from '../../../common/schemas';
+
+import { DATE_NOW, LIST_ID, META, TIE_BREAKER, USER, VALUE } from './lists_services_mock_constants';
+
+export const getIndexESListItemMock = (ip = VALUE): IndexEsListItemSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ ip,
+ list_id: LIST_ID,
+ meta: META,
+ tie_breaker_id: TIE_BREAKER,
+ updated_at: DATE_NOW,
+ updated_by: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_index_es_list_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_index_es_list_mock.ts
new file mode 100644
index 0000000000000..4e4d8d9c572e4
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_index_es_list_mock.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IndexEsListSchema } from '../../../common/schemas';
+
+import {
+ DATE_NOW,
+ DESCRIPTION,
+ META,
+ NAME,
+ TIE_BREAKER,
+ TYPE,
+ USER,
+} from './lists_services_mock_constants';
+
+export const getIndexESListMock = (): IndexEsListSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ description: DESCRIPTION,
+ meta: META,
+ name: NAME,
+ tie_breaker_id: TIE_BREAKER,
+ type: TYPE,
+ updated_at: DATE_NOW,
+ updated_by: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_list_item_by_value_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_list_item_by_value_options_mock.ts
new file mode 100644
index 0000000000000..96bc22ca7e6f2
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_list_item_by_value_options_mock.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { GetListItemByValueOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE } from './lists_services_mock_constants';
+
+export const getListItemByValueOptionsMocks = (): GetListItemByValueOptions => ({
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ type: TYPE,
+ value: VALUE,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_list_item_by_values_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_list_item_by_values_options_mock.ts
new file mode 100644
index 0000000000000..f21f97dc8d15f
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_list_item_by_values_options_mock.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { GetListItemByValuesOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from './lists_services_mock_constants';
+
+export const getListItemByValuesOptionsMocks = (): GetListItemByValuesOptions => ({
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ type: TYPE,
+ value: [VALUE, VALUE_2],
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_list_item_response_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_list_item_response_mock.ts
new file mode 100644
index 0000000000000..1a30282ddaeba
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_list_item_response_mock.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ListItemSchema } from '../../../common/schemas';
+
+import { DATE_NOW, LIST_ID, LIST_ITEM_ID, USER, VALUE } from './lists_services_mock_constants';
+
+export const getListItemResponseMock = (): ListItemSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ id: LIST_ITEM_ID,
+ list_id: LIST_ID,
+ meta: {},
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ type: 'ip',
+ updated_at: DATE_NOW,
+ updated_by: USER,
+ value: VALUE,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_list_response_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_list_response_mock.ts
new file mode 100644
index 0000000000000..ea068d774c4ed
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_list_response_mock.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ListSchema } from '../../../common/schemas';
+
+import { DATE_NOW, DESCRIPTION, LIST_ID, NAME, USER } from './lists_services_mock_constants';
+
+export const getListResponseMock = (): ListSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ description: DESCRIPTION,
+ id: LIST_ID,
+ meta: {},
+ name: NAME,
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ type: 'ip',
+ updated_at: DATE_NOW,
+ updated_by: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_search_es_list_item_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_search_es_list_item_mock.ts
new file mode 100644
index 0000000000000..5e9fd8995c0eb
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_search_es_list_item_mock.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchEsListItemSchema } from '../../../common/schemas';
+
+import { DATE_NOW, LIST_ID, USER, VALUE } from './lists_services_mock_constants';
+
+export const getSearchEsListItemMock = (): SearchEsListItemSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ ip: VALUE,
+ keyword: undefined,
+ list_id: LIST_ID,
+ meta: {},
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ updated_at: DATE_NOW,
+ updated_by: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_search_es_list_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_search_es_list_mock.ts
new file mode 100644
index 0000000000000..6a565437617ba
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_search_es_list_mock.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchEsListSchema } from '../../../common/schemas';
+
+import { DATE_NOW, DESCRIPTION, NAME, USER } from './lists_services_mock_constants';
+
+export const getSearchEsListMock = (): SearchEsListSchema => ({
+ created_at: DATE_NOW,
+ created_by: USER,
+ description: DESCRIPTION,
+ meta: {},
+ name: NAME,
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ type: 'ip',
+ updated_at: DATE_NOW,
+ updated_by: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_search_list_item_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_search_list_item_mock.ts
new file mode 100644
index 0000000000000..9f877c8168cca
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_search_list_item_mock.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+
+import { SearchEsListItemSchema } from '../../../common/schemas';
+
+import { getShardMock } from './get_shard_mock';
+import { LIST_INDEX, LIST_ITEM_ID } from './lists_services_mock_constants';
+import { getSearchEsListItemMock } from './get_search_es_list_item_mock';
+
+export const getSearchListItemMock = (): SearchResponse => ({
+ _scroll_id: '123',
+ _shards: getShardMock(),
+ hits: {
+ hits: [
+ {
+ _id: LIST_ITEM_ID,
+ _index: LIST_INDEX,
+ _score: 0,
+ _source: getSearchEsListItemMock(),
+ _type: '',
+ },
+ ],
+ max_score: 0,
+ total: 1,
+ },
+ timed_out: false,
+ took: 10,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_search_list_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_search_list_mock.ts
new file mode 100644
index 0000000000000..9728139eab42a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_search_list_mock.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+
+import { SearchEsListSchema } from '../../../common/schemas';
+
+import { getShardMock } from './get_shard_mock';
+import { LIST_ID, LIST_INDEX } from './lists_services_mock_constants';
+import { getSearchEsListMock } from './get_search_es_list_mock';
+
+export const getSearchListMock = (): SearchResponse => ({
+ _scroll_id: '123',
+ _shards: getShardMock(),
+ hits: {
+ hits: [
+ {
+ _id: LIST_ID,
+ _index: LIST_INDEX,
+ _score: 0,
+ _source: getSearchEsListMock(),
+ _type: '',
+ },
+ ],
+ max_score: 0,
+ total: 1,
+ },
+ timed_out: false,
+ took: 10,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_shard_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_shard_mock.ts
new file mode 100644
index 0000000000000..4cc6577d5e531
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_shard_mock.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ShardsResponse } from 'elasticsearch';
+
+export const getShardMock = (): ShardsResponse => ({
+ failed: 0,
+ skipped: 0,
+ successful: 0,
+ total: 0,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_update_list_item_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_update_list_item_options_mock.ts
new file mode 100644
index 0000000000000..0555997941baa
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_update_list_item_options_mock.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { UpdateListItemOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import {
+ DATE_NOW,
+ LIST_ITEM_ID,
+ LIST_ITEM_INDEX,
+ META,
+ USER,
+ VALUE,
+} from './lists_services_mock_constants';
+
+export const getUpdateListItemOptionsMock = (): UpdateListItemOptions => ({
+ callCluster: getCallClusterMock(),
+ dateNow: DATE_NOW,
+ id: LIST_ITEM_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ meta: META,
+ user: USER,
+ value: VALUE,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_update_list_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_update_list_options_mock.ts
new file mode 100644
index 0000000000000..fe6fc37eaf81e
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_update_list_options_mock.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { UpdateListOptions } from '../lists';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import {
+ DATE_NOW,
+ DESCRIPTION,
+ LIST_ID,
+ LIST_INDEX,
+ META,
+ NAME,
+ USER,
+} from './lists_services_mock_constants';
+
+export const getUpdateListOptionsMock = (): UpdateListOptions => ({
+ callCluster: getCallClusterMock(),
+ dateNow: DATE_NOW,
+ description: DESCRIPTION,
+ id: LIST_ID,
+ listIndex: LIST_INDEX,
+ meta: META,
+ name: NAME,
+ user: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_write_buffer_to_items_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_write_buffer_to_items_options_mock.ts
new file mode 100644
index 0000000000000..d6b7d70c1aa77
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_write_buffer_to_items_options_mock.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { WriteBufferToItemsOptions } from '../items';
+
+import { getCallClusterMock } from './get_call_cluster_mock';
+import { LIST_ID, LIST_ITEM_INDEX, META, TYPE, USER } from './lists_services_mock_constants';
+
+export const getWriteBufferToItemsOptionsMock = (): WriteBufferToItemsOptions => ({
+ buffer: [],
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ meta: META,
+ type: TYPE,
+ user: USER,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/get_write_list_items_to_stream_options_mock.ts b/x-pack/plugins/lists/server/services/mocks/get_write_list_items_to_stream_options_mock.ts
new file mode 100644
index 0000000000000..c945818a83e8a
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/get_write_list_items_to_stream_options_mock.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Stream } from 'stream';
+
+import {
+ ExportListItemsToStreamOptions,
+ GetResponseOptions,
+ WriteNextResponseOptions,
+ WriteResponseHitsToStreamOptions,
+} from '../items';
+
+import { LIST_ID, LIST_ITEM_INDEX } from './lists_services_mock_constants';
+import { getSearchListItemMock } from './get_search_list_item_mock';
+import { getCallClusterMock } from './get_call_cluster_mock';
+
+export const getExportListItemsToStreamOptionsMock = (): ExportListItemsToStreamOptions => ({
+ callCluster: getCallClusterMock(getSearchListItemMock()),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ stream: new Stream.PassThrough(),
+ stringToAppend: undefined,
+});
+
+export const getWriteNextResponseOptions = (): WriteNextResponseOptions => ({
+ callCluster: getCallClusterMock(getSearchListItemMock()),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ searchAfter: [],
+ stream: new Stream.PassThrough(),
+ stringToAppend: undefined,
+});
+
+export const getResponseOptionsMock = (): GetResponseOptions => ({
+ callCluster: getCallClusterMock(),
+ listId: LIST_ID,
+ listItemIndex: LIST_ITEM_INDEX,
+ searchAfter: [],
+ size: 100,
+});
+
+export const getWriteResponseHitsToStreamOptionsMock = (): WriteResponseHitsToStreamOptions => ({
+ response: getSearchListItemMock(),
+ stream: new Stream.PassThrough(),
+ stringToAppend: undefined,
+});
diff --git a/x-pack/plugins/lists/server/services/mocks/index.ts b/x-pack/plugins/lists/server/services/mocks/index.ts
new file mode 100644
index 0000000000000..c555ba322fa2b
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/index.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './get_call_cluster_mock';
+export * from './get_delete_list_options_mock';
+export * from './get_create_list_options_mock';
+export * from './get_list_response_mock';
+export * from './get_search_list_mock';
+export * from './get_shard_mock';
+export * from './lists_services_mock_constants';
+export * from './get_update_list_options_mock';
+export * from './get_create_list_item_options_mock';
+export * from './get_list_item_response_mock';
+export * from './get_index_es_list_mock';
+export * from './get_index_es_list_item_mock';
+export * from './get_create_list_item_bulk_options_mock';
+export * from './get_delete_list_item_by_value_options_mock';
+export * from './get_delete_list_item_options_mock';
+export * from './get_list_item_by_values_options_mock';
+export * from './get_search_es_list_mock';
+export * from './get_search_es_list_item_mock';
+export * from './get_list_item_by_value_options_mock';
+export * from './get_update_list_item_options_mock';
+export * from './get_write_buffer_to_items_options_mock';
+export * from './get_import_list_items_to_stream_options_mock';
+export * from './get_write_list_items_to_stream_options_mock';
+export * from './get_search_list_item_mock';
+export * from './test_readable';
diff --git a/x-pack/plugins/lists/server/services/mocks/lists_services_mock_constants.ts b/x-pack/plugins/lists/server/services/mocks/lists_services_mock_constants.ts
new file mode 100644
index 0000000000000..d174211f348ea
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/lists_services_mock_constants.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export const DATE_NOW = '2020-04-20T15:25:31.830Z';
+export const USER = 'some user';
+export const LIST_INDEX = '.lists';
+export const LIST_ITEM_INDEX = '.items';
+export const NAME = 'some name';
+export const DESCRIPTION = 'some description';
+export const LIST_ID = 'some-list-id';
+export const LIST_ITEM_ID = 'some-list-item-id';
+export const TIE_BREAKER = '6a76b69d-80df-4ab2-8c3e-85f466b06a0e';
+export const TIE_BREAKERS = [
+ '21530991-4051-46ec-bc35-2afa09a1b0b5',
+ '3c662054-ae37-4aa9-9936-3e8e2ea26775',
+ '60e49a20-3a23-48b6-8bf9-ed5e3b70f7a0',
+ '38814080-a40f-4358-992a-3b875f9b7dec',
+ '29fa61be-aaaf-411c-a78a-7059e3f723f1',
+ '9c19c959-cb9d-4cd2-99e4-1ea2baf0ef0e',
+ 'd409308c-f94b-4b3a-8234-bbd7a80c9140',
+ '87824c99-cd83-45c4-8aa6-4ad95dfea62c',
+ '7b940c17-9355-479f-b882-f3e575718f79',
+ '5983ad0c-4ef4-4fa0-8308-80ab9ecc4f74',
+];
+export const META = {};
+export const TYPE = 'ip';
+export const VALUE = '127.0.0.1';
+export const VALUE_2 = '255.255.255';
diff --git a/x-pack/plugins/lists/server/services/mocks/test_readable.ts b/x-pack/plugins/lists/server/services/mocks/test_readable.ts
new file mode 100644
index 0000000000000..52ad6de484005
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/mocks/test_readable.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Readable } from 'stream';
+
+export class TestReadable extends Readable {
+ public _read(): void {}
+}
diff --git a/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.test.ts b/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.test.ts
new file mode 100644
index 0000000000000..3b6f58479a2f2
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.test.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getSearchEsListItemMock } from '../mocks';
+import { Type } from '../../../common/schemas';
+
+import { deriveTypeFromItem } from './derive_type_from_es_type';
+
+describe('derive_type_from_es_type', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns the item ip if it exists', () => {
+ const item = getSearchEsListItemMock();
+ const derivedType = deriveTypeFromItem({ item });
+ const expected: Type = 'ip';
+ expect(derivedType).toEqual(expected);
+ });
+
+ test('it returns the item keyword if it exists', () => {
+ const item = getSearchEsListItemMock();
+ item.ip = undefined;
+ item.keyword = 'some keyword';
+ const derivedType = deriveTypeFromItem({ item });
+ const expected: Type = 'keyword';
+ expect(derivedType).toEqual(expected);
+ });
+
+ test('it throws an error with status code if neither one exists', () => {
+ const item = getSearchEsListItemMock();
+ item.ip = undefined;
+ item.keyword = undefined;
+ const expected = `Was expecting a valid type from the Elastic Search List Item such as ip or keyword but did not found one here ${JSON.stringify(
+ item
+ )}`;
+ expect(() => deriveTypeFromItem({ item })).toThrowError(expected);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.ts b/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.ts
new file mode 100644
index 0000000000000..7a65e74bf4947
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/derive_type_from_es_type.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchEsListItemSchema, Type } from '../../../common/schemas';
+import { ErrorWithStatusCode } from '../../error_with_status_code';
+
+interface DeriveTypeFromItemOptions {
+ item: SearchEsListItemSchema;
+}
+
+export const deriveTypeFromItem = ({ item }: DeriveTypeFromItemOptions): Type => {
+ if (item.ip != null) {
+ return 'ip';
+ } else if (item.keyword != null) {
+ return 'keyword';
+ } else {
+ throw new ErrorWithStatusCode(
+ `Was expecting a valid type from the Elastic Search List Item such as ip or keyword but did not found one here ${JSON.stringify(
+ item
+ )}`,
+ 400
+ );
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts
new file mode 100644
index 0000000000000..3d48e44e26eaa
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts
@@ -0,0 +1,92 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { QueryFilterType, getQueryFilterFromTypeValue } from './get_query_filter_from_type_value';
+
+describe('get_query_filter_from_type_value', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it returns an ip if given an ip', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'ip',
+ value: ['127.0.0.1'],
+ });
+ const expected: QueryFilterType = [
+ { term: { list_id: 'list-123' } },
+ { terms: { ip: ['127.0.0.1'] } },
+ ];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it returns two ip if given two ip', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'ip',
+ value: ['127.0.0.1', '127.0.0.2'],
+ });
+ const expected: QueryFilterType = [
+ { term: { list_id: 'list-123' } },
+ { terms: { ip: ['127.0.0.1', '127.0.0.2'] } },
+ ];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it returns a keyword if given a keyword', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'keyword',
+ value: ['host-name-1'],
+ });
+ const expected: QueryFilterType = [
+ { term: { list_id: 'list-123' } },
+ { terms: { keyword: ['host-name-1'] } },
+ ];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it returns two keywords if given two values', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'keyword',
+ value: ['host-name-1', 'host-name-2'],
+ });
+ const expected: QueryFilterType = [
+ { term: { list_id: 'list-123' } },
+ { terms: { keyword: ['host-name-1', 'host-name-2'] } },
+ ];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it returns an empty keyword given an empty value', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'keyword',
+ value: [],
+ });
+ const expected: QueryFilterType = [
+ { term: { list_id: 'list-123' } },
+ { terms: { keyword: [] } },
+ ];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it returns an empty ip given an empty value', () => {
+ const queryFilter = getQueryFilterFromTypeValue({
+ listId: 'list-123',
+ type: 'ip',
+ value: [],
+ });
+ const expected: QueryFilterType = [{ term: { list_id: 'list-123' } }, { terms: { ip: [] } }];
+ expect(queryFilter).toEqual(expected);
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts
new file mode 100644
index 0000000000000..3f50efe0c6c56
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Type } from '../../../common/schemas';
+
+export type QueryFilterType = Array<
+ { term: { list_id: string } } | { terms: { ip: string[] } } | { terms: { keyword: string[] } }
+>;
+
+export const getQueryFilterFromTypeValue = ({
+ type,
+ value,
+ listId,
+}: {
+ type: Type;
+ value: string[];
+ listId: string;
+ // We disable the consistent return since we want to use typescript for exhaustive type checks
+ // eslint-disable-next-line consistent-return
+}): QueryFilterType => {
+ const filter: QueryFilterType = [{ term: { list_id: listId } }];
+ switch (type) {
+ case 'ip': {
+ return [...filter, ...[{ terms: { ip: value } }]];
+ }
+ case 'keyword': {
+ return [...filter, ...[{ terms: { keyword: value } }]];
+ }
+ }
+};
diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts
new file mode 100644
index 0000000000000..8a44b5ab607bf
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './get_query_filter_from_type_value';
+export * from './transform_elastic_to_list_item';
+export * from './transform_list_item_to_elastic_query';
+export * from './derive_type_from_es_type';
diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts
new file mode 100644
index 0000000000000..3b9864be6df53
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ListItemArraySchema } from '../../../common/schemas';
+import { getListItemResponseMock, getSearchListItemMock } from '../mocks';
+
+import { transformElasticToListItem } from './transform_elastic_to_list_item';
+
+describe('transform_elastic_to_list_item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it transforms an elastic type to a list item type', () => {
+ const response = getSearchListItemMock();
+ const queryFilter = transformElasticToListItem({
+ response,
+ type: 'ip',
+ });
+ const expected: ListItemArraySchema = [getListItemResponseMock()];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it transforms an elastic keyword type to a list item type', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits[0]._source.ip = undefined;
+ response.hits.hits[0]._source.keyword = 'host-name-example';
+ const queryFilter = transformElasticToListItem({
+ response,
+ type: 'keyword',
+ });
+ const listItemResponse = getListItemResponseMock();
+ listItemResponse.type = 'keyword';
+ listItemResponse.value = 'host-name-example';
+ const expected: ListItemArraySchema = [listItemResponse];
+ expect(queryFilter).toEqual(expected);
+ });
+
+ test('it does a throw if it cannot determine the list item type from "ip"', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits[0]._source.ip = undefined;
+ response.hits.hits[0]._source.keyword = 'host-name-example';
+ expect(() =>
+ transformElasticToListItem({
+ response,
+ type: 'ip',
+ })
+ ).toThrow('Was expecting ip to not be null/undefined');
+ });
+
+ test('it does a throw if it cannot determine the list item type from "keyword"', () => {
+ const response = getSearchListItemMock();
+ response.hits.hits[0]._source.ip = '127.0.0.1';
+ response.hits.hits[0]._source.keyword = undefined;
+ expect(() =>
+ transformElasticToListItem({
+ response,
+ type: 'keyword',
+ })
+ ).toThrow('Was expecting keyword to not be null/undefined');
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts
new file mode 100644
index 0000000000000..2dc0f4fe7a821
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SearchResponse } from 'elasticsearch';
+
+import { ListItemArraySchema, SearchEsListItemSchema, Type } from '../../../common/schemas';
+import { ErrorWithStatusCode } from '../../error_with_status_code';
+
+export interface TransformElasticToListItemOptions {
+ response: SearchResponse;
+ type: Type;
+}
+
+export const transformElasticToListItem = ({
+ response,
+ type,
+}: TransformElasticToListItemOptions): ListItemArraySchema => {
+ return response.hits.hits.map(hit => {
+ const {
+ _id,
+ _source: {
+ created_at,
+ updated_at,
+ updated_by,
+ created_by,
+ list_id,
+ tie_breaker_id,
+ ip,
+ keyword,
+ meta,
+ },
+ } = hit;
+
+ const baseTypes = {
+ created_at,
+ created_by,
+ id: _id,
+ list_id,
+ meta,
+ tie_breaker_id,
+ type,
+ updated_at,
+ updated_by,
+ };
+
+ switch (type) {
+ case 'ip': {
+ if (ip == null) {
+ throw new ErrorWithStatusCode('Was expecting ip to not be null/undefined', 400);
+ }
+ return {
+ ...baseTypes,
+ value: ip,
+ };
+ }
+ case 'keyword': {
+ if (keyword == null) {
+ throw new ErrorWithStatusCode('Was expecting keyword to not be null/undefined', 400);
+ }
+ return {
+ ...baseTypes,
+ value: keyword,
+ };
+ }
+ }
+ return assertUnreachable();
+ });
+};
+
+const assertUnreachable = (): never => {
+ throw new Error('Unknown type in elastic_to_list_items');
+};
diff --git a/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.test.ts b/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.test.ts
new file mode 100644
index 0000000000000..217cad30bfdbb
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.test.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EsDataTypeUnion, Type } from '../../../common/schemas';
+
+import { transformListItemToElasticQuery } from './transform_list_item_to_elastic_query';
+
+describe('transform_elastic_to_elastic_query', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it transforms a ip type and value to a union', () => {
+ const elasticQuery = transformListItemToElasticQuery({
+ type: 'ip',
+ value: '127.0.0.1',
+ });
+ const expected: EsDataTypeUnion = { ip: '127.0.0.1' };
+ expect(elasticQuery).toEqual(expected);
+ });
+
+ test('it transforms a keyword type and value to a union', () => {
+ const elasticQuery = transformListItemToElasticQuery({
+ type: 'keyword',
+ value: 'host-name',
+ });
+ const expected: EsDataTypeUnion = { keyword: 'host-name' };
+ expect(elasticQuery).toEqual(expected);
+ });
+
+ test('it throws if the type is not known', () => {
+ const type: Type = 'made-up' as Type;
+ expect(() =>
+ transformListItemToElasticQuery({
+ type,
+ value: 'some-value',
+ })
+ ).toThrow('Unknown type: "made-up" in transformListItemToElasticQuery');
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.ts b/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.ts
new file mode 100644
index 0000000000000..051802cc41b5b
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/utils/transform_list_item_to_elastic_query.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EsDataTypeUnion, Type } from '../../../common/schemas';
+
+export const transformListItemToElasticQuery = ({
+ type,
+ value,
+}: {
+ type: Type;
+ value: string;
+}): EsDataTypeUnion => {
+ switch (type) {
+ case 'ip': {
+ return {
+ ip: value,
+ };
+ }
+ case 'keyword': {
+ return {
+ keyword: value,
+ };
+ }
+ }
+ return assertUnreachable(type);
+};
+
+const assertUnreachable = (type: string): never => {
+ throw new Error(`Unknown type: "${type}" in transformListItemToElasticQuery`);
+};
diff --git a/x-pack/plugins/lists/server/siem_server_deps.ts b/x-pack/plugins/lists/server/siem_server_deps.ts
new file mode 100644
index 0000000000000..e78debc8e4349
--- /dev/null
+++ b/x-pack/plugins/lists/server/siem_server_deps.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export {
+ transformError,
+ buildSiemResponse,
+} from '../../siem/server/lib/detection_engine/routes/utils';
+export { deleteTemplate } from '../../siem/server/lib/detection_engine/index/delete_template';
+export { deletePolicy } from '../../siem/server/lib/detection_engine/index/delete_policy';
+export { deleteAllIndex } from '../../siem/server/lib/detection_engine/index/delete_all_index';
+export { setPolicy } from '../../siem/server/lib/detection_engine/index/set_policy';
+export { setTemplate } from '../../siem/server/lib/detection_engine/index/set_template';
+export { getTemplateExists } from '../../siem/server/lib/detection_engine/index/get_template_exists';
+export { getPolicyExists } from '../../siem/server/lib/detection_engine/index/get_policy_exists';
+export { createBootstrapIndex } from '../../siem/server/lib/detection_engine/index/create_bootstrap_index';
+export { getIndexExists } from '../../siem/server/lib/detection_engine/index/get_index_exists';
+export { buildRouteValidation } from '../../siem/server/utils/build_validation/route_validation';
+export { validate } from '../../siem/server/lib/detection_engine/routes/rules/validate';
diff --git a/x-pack/plugins/lists/server/types.ts b/x-pack/plugins/lists/server/types.ts
new file mode 100644
index 0000000000000..e0e4495d47c34
--- /dev/null
+++ b/x-pack/plugins/lists/server/types.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IContextProvider, RequestHandler } from 'kibana/server';
+
+import { SecurityPluginSetup } from '../../security/server';
+import { SpacesPluginSetup } from '../../spaces/server';
+
+import { ListClient } from './services/lists/client';
+
+export type ContextProvider = IContextProvider, 'lists'>;
+
+export interface PluginsSetup {
+ security: SecurityPluginSetup | undefined | null;
+ spaces: SpacesPluginSetup | undefined | null;
+}
+
+export type ContextProviderReturn = Promise<{ getListClient: () => ListClient }>;
+declare module 'src/core/server' {
+ interface RequestHandlerContext {
+ lists?: {
+ getListClient: () => ListClient;
+ };
+ }
+}
diff --git a/x-pack/plugins/maps/public/actions/map_actions.d.ts b/x-pack/plugins/maps/public/actions/map_actions.d.ts
index c8db284a5c4f1..38c56405787eb 100644
--- a/x-pack/plugins/maps/public/actions/map_actions.d.ts
+++ b/x-pack/plugins/maps/public/actions/map_actions.d.ts
@@ -74,3 +74,11 @@ export function updateMapSetting(
settingKey: string,
settingValue: string | boolean | number
): AnyAction;
+
+export function cloneLayer(layerId: string): AnyAction;
+
+export function fitToLayerExtent(layerId: string): AnyAction;
+
+export function removeLayer(layerId: string): AnyAction;
+
+export function toggleLayerVisible(layerId: string): AnyAction;
diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap
index 27ea52bfed044..f1cb1a8864753 100644
--- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap
@@ -9,12 +9,10 @@ exports[`TOCEntry is rendered 1`] = `
-
-
-
-
-
{
- dispatch(toggleLayerVisible(layerId));
- },
- fitToBounds: layerId => {
- dispatch(fitToLayerExtent(layerId));
- },
- cloneLayer: layerId => {
- dispatch(cloneLayer(layerId));
- },
- removeLayer: layerId => {
- dispatch(removeLayer(layerId));
- },
hideTOCDetails: layerId => {
dispatch(hideTOCDetails(layerId));
},
diff --git a/x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap
similarity index 92%
rename from x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap
rename to x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap
index af836ceffa4b7..b8c652909408a 100644
--- a/x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`LayerTocActions is rendered 1`] = `
+exports[`TOCEntryActionsPopover is rendered 1`] = `
,
"name": "Hide layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "editLayerButton",
+ "disabled": false,
"icon":
,
"name": "Edit layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "cloneLayerButton",
@@ -104,6 +107,7 @@ exports[`LayerTocActions is rendered 1`] = `
/>,
"name": "Clone layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "removeLayerButton",
@@ -113,6 +117,7 @@ exports[`LayerTocActions is rendered 1`] = `
/>,
"name": "Remove layer",
"onClick": [Function],
+ "toolTipContent": null,
},
],
"title": "Layer actions",
@@ -123,7 +128,7 @@ exports[`LayerTocActions is rendered 1`] = `
`;
-exports[`LayerTocActions should disable fit to data when supportsFitToBounds is false 1`] = `
+exports[`TOCEntryActionsPopover should disable fit to data when supportsFitToBounds is false 1`] = `
,
"name": "Hide layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "editLayerButton",
+ "disabled": false,
"icon":
,
"name": "Edit layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "cloneLayerButton",
@@ -227,6 +235,7 @@ exports[`LayerTocActions should disable fit to data when supportsFitToBounds is
/>,
"name": "Clone layer",
"onClick": [Function],
+ "toolTipContent": null,
},
Object {
"data-test-subj": "removeLayerButton",
@@ -236,6 +245,7 @@ exports[`LayerTocActions should disable fit to data when supportsFitToBounds is
/>,
"name": "Remove layer",
"onClick": [Function],
+ "toolTipContent": null,
},
],
"title": "Layer actions",
@@ -246,7 +256,7 @@ exports[`LayerTocActions should disable fit to data when supportsFitToBounds is
`;
-exports[`LayerTocActions should not show edit actions in read only mode 1`] = `
+exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1`] = `
,
"name": "Hide layer",
"onClick": [Function],
+ "toolTipContent": null,
},
],
"title": "Layer actions",
diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts
new file mode 100644
index 0000000000000..1437370557efc
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AnyAction, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { MapStoreState } from '../../../../../../reducers/store';
+import {
+ fitToLayerExtent,
+ toggleLayerVisible,
+ cloneLayer,
+ removeLayer,
+} from '../../../../../../actions/map_actions';
+import { getMapZoom, isUsingSearch } from '../../../../../../selectors/map_selectors';
+import { getIsReadOnly } from '../../../../../../selectors/ui_selectors';
+import { TOCEntryActionsPopover } from './toc_entry_actions_popover';
+
+function mapStateToProps(state: MapStoreState) {
+ return {
+ isReadOnly: getIsReadOnly(state),
+ isUsingSearch: isUsingSearch(state),
+ zoom: getMapZoom(state),
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch
) {
+ return {
+ cloneLayer: (layerId: string) => {
+ dispatch(cloneLayer(layerId));
+ },
+ fitToBounds: (layerId: string) => {
+ dispatch(fitToLayerExtent(layerId));
+ },
+ removeLayer: (layerId: string) => {
+ dispatch(removeLayer(layerId));
+ },
+ toggleVisible: (layerId: string) => {
+ dispatch(toggleLayerVisible(layerId));
+ },
+ };
+}
+
+const connectedTOCEntryActionsPopover = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(TOCEntryActionsPopover);
+export { connectedTOCEntryActionsPopover as TOCEntryActionsPopover };
diff --git a/x-pack/plugins/maps/public/components/layer_toc_actions.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx
similarity index 52%
rename from x-pack/plugins/maps/public/components/layer_toc_actions.test.js
rename to x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx
index c3a8f59c4c736..b873119fd7d13 100644
--- a/x-pack/plugins/maps/public/components/layer_toc_actions.test.js
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx
@@ -3,21 +3,45 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+/* eslint-disable max-classes-per-file */
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
+import { AbstractLayer, ILayer } from '../../../../../../layers/layer';
+import { AbstractSource, ISource } from '../../../../../../layers/sources/source';
+import { AbstractStyle, IStyle } from '../../../../../../layers/styles/style';
-import { LayerTocActions } from './layer_toc_actions';
+import { TOCEntryActionsPopover } from './toc_entry_actions_popover';
-let supportsFitToBounds;
-const layerMock = {
- supportsFitToBounds: () => {
+let supportsFitToBounds: boolean;
+
+class MockSource extends AbstractSource implements ISource {}
+
+class MockStyle extends AbstractStyle implements IStyle {}
+
+class LayerMock extends AbstractLayer implements ILayer {
+ constructor() {
+ const sourceDescriptor = {
+ type: 'mySourceType',
+ };
+ const source = new MockSource(sourceDescriptor);
+ const style = new MockStyle({ type: 'myStyleType' });
+ const layerDescriptor = {
+ id: 'testLayer',
+ sourceDescriptor,
+ };
+ super({ layerDescriptor, source, style });
+ }
+
+ async supportsFitToBounds(): Promise {
return supportsFitToBounds;
- },
- isVisible: () => {
+ }
+
+ isVisible() {
return true;
- },
- getIconAndTooltipContent: (zoom, isUsingSearch) => {
+ }
+
+ getIconAndTooltipContent(zoom: number, isUsingSearch: boolean) {
return {
icon: mockIcon,
tooltipContent: `simulated tooltip content at zoom: ${zoom}`,
@@ -28,24 +52,31 @@ const layerMock = {
},
],
};
- },
-};
+ }
+}
const defaultProps = {
+ cloneLayer: () => {},
displayName: 'layer 1',
+ editLayer: () => {},
escapedDisplayName: 'layer1',
- zoom: 0,
- layer: layerMock,
+ fitToBounds: () => {},
+ isEditButtonDisabled: false,
+ isReadOnly: false,
isUsingSearch: true,
+ layer: new LayerMock(),
+ removeLayer: () => {},
+ toggleVisible: () => {},
+ zoom: 0,
};
-describe('LayerTocActions', () => {
+describe('TOCEntryActionsPopover', () => {
beforeEach(() => {
supportsFitToBounds = true;
});
test('is rendered', async () => {
- const component = shallowWithIntl();
+ const component = shallowWithIntl();
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
@@ -56,7 +87,9 @@ describe('LayerTocActions', () => {
});
test('should not show edit actions in read only mode', async () => {
- const component = shallowWithIntl();
+ const component = shallowWithIntl(
+
+ );
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
@@ -68,7 +101,7 @@ describe('LayerTocActions', () => {
test('should disable fit to data when supportsFitToBounds is false', async () => {
supportsFitToBounds = false;
- const component = shallowWithIntl();
+ const component = shallowWithIntl();
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
diff --git a/x-pack/plugins/maps/public/components/layer_toc_actions.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx
similarity index 81%
rename from x-pack/plugins/maps/public/components/layer_toc_actions.js
rename to x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx
index d79eda16037cb..d628cca61de11 100644
--- a/x-pack/plugins/maps/public/components/layer_toc_actions.js
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx
@@ -8,8 +8,31 @@ import React, { Component, Fragment } from 'react';
import { EuiButtonEmpty, EuiPopover, EuiContextMenu, EuiIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { ILayer } from '../../../../../../layers/layer';
+
+interface Props {
+ cloneLayer: (layerId: string) => void;
+ displayName: string;
+ editLayer: () => void;
+ escapedDisplayName: string;
+ fitToBounds: (layerId: string) => void;
+ isEditButtonDisabled: boolean;
+ isReadOnly: boolean;
+ isUsingSearch: boolean;
+ layer: ILayer;
+ removeLayer: (layerId: string) => void;
+ toggleVisible: (layerId: string) => void;
+ zoom: number;
+}
+
+interface State {
+ isPopoverOpen: boolean;
+ supportsFitToBounds: boolean;
+}
+
+export class TOCEntryActionsPopover extends Component {
+ private _isMounted: boolean = false;
-export class LayerTocActions extends Component {
state = {
isPopoverOpen: false,
supportsFitToBounds: false,
@@ -43,6 +66,22 @@ export class LayerTocActions extends Component {
}));
};
+ _cloneLayer() {
+ this.props.cloneLayer(this.props.layer.getId());
+ }
+
+ _fitToBounds() {
+ this.props.fitToBounds(this.props.layer.getId());
+ }
+
+ _removeLayer() {
+ this.props.fitToBounds(this.props.layer.getId());
+ }
+
+ _toggleVisible() {
+ this.props.toggleVisible(this.props.layer.getId());
+ }
+
_renderPopoverToggleButton() {
const { icon, tooltipContent, footnotes } = this.props.layer.getIconAndTooltipContent(
this.props.zoom,
@@ -108,7 +147,7 @@ export class LayerTocActions extends Component {
disabled: !this.state.supportsFitToBounds,
onClick: () => {
this._closePopover();
- this.props.fitToBounds();
+ this._fitToBounds();
},
},
{
@@ -121,20 +160,23 @@ export class LayerTocActions extends Component {
}),
icon: ,
'data-test-subj': 'layerVisibilityToggleButton',
+ toolTipContent: null,
onClick: () => {
this._closePopover();
- this.props.toggleVisible();
+ this._toggleVisible();
},
},
];
if (!this.props.isReadOnly) {
actionItems.push({
+ disabled: this.props.isEditButtonDisabled,
name: i18n.translate('xpack.maps.layerTocActions.editLayerTitle', {
defaultMessage: 'Edit layer',
}),
icon: ,
'data-test-subj': 'editLayerButton',
+ toolTipContent: null,
onClick: () => {
this._closePopover();
this.props.editLayer();
@@ -145,10 +187,11 @@ export class LayerTocActions extends Component {
defaultMessage: 'Clone layer',
}),
icon: ,
+ toolTipContent: null,
'data-test-subj': 'cloneLayerButton',
onClick: () => {
this._closePopover();
- this.props.cloneLayer();
+ this._cloneLayer();
},
});
actionItems.push({
@@ -156,10 +199,11 @@ export class LayerTocActions extends Component {
defaultMessage: 'Remove layer',
}),
icon: ,
+ toolTipContent: null,
'data-test-subj': 'removeLayerButton',
onClick: () => {
this._closePopover();
- this.props.removeLayer();
+ this._removeLayer();
},
});
}
diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js
index fe56523fb2580..c0ce24fef9cd8 100644
--- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js
+++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js
@@ -8,7 +8,7 @@ import React from 'react';
import classNames from 'classnames';
import { EuiIcon, EuiOverlayMask, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui';
-import { LayerTocActions } from '../../../../../components/layer_toc_actions';
+import { TOCEntryActionsPopover } from './toc_entry_actions_popover';
import { i18n } from '@kbn/i18n';
function escapeLayerName(name) {
@@ -124,6 +124,7 @@ export class TOCEntry extends React.Component {
return (
- {
- fitToBounds(layer.getId());
- }}
- zoom={zoom}
- toggleVisible={() => {
- toggleVisible(layer.getId());
- }}
displayName={this.state.displayName}
escapedDisplayName={escapeLayerName(this.state.displayName)}
- cloneLayer={() => {
- cloneLayer(layer.getId());
- }}
editLayer={this._openLayerPanelWithCheck}
- isReadOnly={isReadOnly}
- removeLayer={() => {
- removeLayer(layer.getId());
- }}
+ isEditButtonDisabled={this.props.isEditButtonDisabled}
/>
{this._renderLayerIcons()}
diff --git a/x-pack/plugins/maps/public/layers/layer.tsx b/x-pack/plugins/maps/public/layers/layer.tsx
index 13fe447cec3da..dccf413b489f1 100644
--- a/x-pack/plugins/maps/public/layers/layer.tsx
+++ b/x-pack/plugins/maps/public/layers/layer.tsx
@@ -45,7 +45,7 @@ export interface ILayer {
supportsFitToBounds(): Promise