diff --git a/.eslintrc.js b/.eslintrc.js index 8c2a46f80a3a8..d8a47583e5d22 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -351,7 +351,7 @@ module.exports = { from: [ '(src|x-pack)/plugins/**/(public|server)/**/*', '!(src|x-pack)/plugins/**/(public|server)/mocks/index.{js,mjs,ts}', - '!(src|x-pack)/plugins/**/(public|server)/(index|mocks).{js,mjs,ts,tsx}', + '!(src|x-pack)/plugins/**/(public|server)/(index|mocks|test_utils).{js,mjs,ts,tsx}', ], allowSameFolder: true, errorMessage: 'Plugins may only import from top-level public and server modules.', @@ -517,7 +517,7 @@ module.exports = { 'packages/kbn-interpreter/tasks/**/*.js', 'packages/kbn-interpreter/src/plugin/**/*.js', 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*.js', - 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__}/**/*.js', + 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__,public}/**/*.js', 'x-pack/**/*.test.js', 'x-pack/test_utils/**/*', 'x-pack/gulpfile.js', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 52df586b8bda7..66fb31cc91d5a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,6 @@ /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app /src/plugins/dashboard/ @elastic/kibana-app -/src/plugins/dashboard/**/*.scss @elastic/kibana-core-ui-designers /src/plugins/discover/ @elastic/kibana-app /src/plugins/input_control_vis/ @elastic/kibana-app /src/plugins/kibana_legacy/ @elastic/kibana-app @@ -59,7 +58,6 @@ # APM /x-pack/plugins/apm/ @elastic/apm-ui -/x-pack/plugins/apm/**/*.scss @elastic/observability-design /x-pack/test/functional/apps/apm/ @elastic/apm-ui /src/legacy/core_plugins/apm_oss/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui @@ -70,7 +68,6 @@ # Canvas /x-pack/plugins/canvas/ @elastic/kibana-canvas -/x-pack/plugins/canvas/**/*.scss @elastic/kibana-core-ui-designers /x-pack/test/functional/apps/canvas/ @elastic/kibana-canvas # Core UI @@ -80,18 +77,14 @@ /src/plugins/home/server/services/ @elastic/kibana-core-ui # Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon /src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/**/*.scss @elastic/kibana-core-ui-designers /src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui # Observability UIs /x-pack/legacy/plugins/infra/ @elastic/logs-metrics-ui /x-pack/plugins/infra/ @elastic/logs-metrics-ui -/x-pack/plugins/infra/**/*.scss @elastic/observability-design /x-pack/plugins/ingest_manager/ @elastic/ingest-management -/x-pack/plugins/ingest_manager/**/*.scss @elastic/observability-design /x-pack/legacy/plugins/ingest_manager/ @elastic/ingest-management /x-pack/plugins/observability/ @elastic/observability-ui -/x-pack/plugins/observability/**/*.scss @elastic/observability-design /x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime @@ -165,14 +158,10 @@ # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform /x-pack/legacy/plugins/security/ @elastic/kibana-security -/x-pack/legacy/plugins/security/**/*.scss @elastic/kibana-core-ui-designers /x-pack/legacy/plugins/spaces/ @elastic/kibana-security -/x-pack/legacy/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/spaces/ @elastic/kibana-security -/x-pack/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security -/x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers /x-pack/test/api_integration/apis/security/ @elastic/kibana-security /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security @@ -220,13 +209,9 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib /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 -# Design -**/*.scss @elastic/kibana-design - # Enterprise Search /x-pack/plugins/enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend /x-pack/test/functional_enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend -/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design # Elasticsearch UI /src/plugins/dev_tools/ @elastic/es-ui @@ -255,7 +240,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Endpoint /x-pack/plugins/endpoint/ @elastic/endpoint-app-team @elastic/siem -/x-pack/plugins/endpoint/**/*.scss @elastic/security-design /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/security_solution_endpoint/ @elastic/endpoint-app-team @elastic/siem @@ -265,7 +249,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Security Solution /x-pack/plugins/security_solution/ @elastic/siem @elastic/endpoint-app-team -/x-pack/plugins/security_solution/**/*.scss @elastic/security-design /x-pack/test/detection_engine_api_integration @elastic/siem @elastic/endpoint-app-team /x-pack/test/lists_api_integration @elastic/siem @elastic/endpoint-app-team /x-pack/test/api_integration/apis/security_solution @elastic/siem @elastic/endpoint-app-team @@ -274,3 +257,29 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics + +# Design (at the bottom for specificity of SASS files) +**/*.scss @elastic/kibana-design + +# Core design +/src/plugins/dashboard/**/*.scss @elastic/kibana-core-ui-designers +/x-pack/plugins/canvas/**/*.scss @elastic/kibana-core-ui-designers +/src/legacy/core_plugins/kibana/public/home/**/*.scss @elastic/kibana-core-ui-designers +/x-pack/legacy/plugins/security/**/*.scss @elastic/kibana-core-ui-designers +/x-pack/legacy/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers +/x-pack/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers +/x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers + +# Observability design +/x-pack/plugins/apm/**/*.scss @elastic/observability-design +/x-pack/plugins/infra/**/*.scss @elastic/observability-design +/x-pack/plugins/ingest_manager/**/*.scss @elastic/observability-design +/x-pack/plugins/observability/**/*.scss @elastic/observability-design + +# Ent. Search design +/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design + +# Security design +/x-pack/plugins/endpoint/**/*.scss @elastic/security-design +/x-pack/plugins/security_solution/**/*.scss @elastic/security-design + diff --git a/.telemetryrc.json b/.telemetryrc.json index 2f57566159a70..818f9805628e1 100644 --- a/.telemetryrc.json +++ b/.telemetryrc.json @@ -7,7 +7,6 @@ "src/plugins/testbed/", "src/plugins/kibana_utils/", "src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts", - "src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts", "src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts", "src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts", "src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts" diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index ad07112567479..c43b58d3aa989 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -44,7 +44,10 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, array|string) The fields to return in the `attributes` key of the response. `sort_field`:: - (Optional, string) The field that sorts the response. + (Optional, string) Sorts the response. Includes "root" and "type" fields. "root" fields exist for all saved objects, such as "updated_at". + "type" fields are specific to an object type, such as fields returned in the `attributes` key of the response. When a single type is + defined in the `type` parameter, the "root" and "type" fields are allowed, and validity checks are made in that order. When multiple types + are defined in the `type` parameter, only "root" fields are allowed. `has_reference`:: (Optional, object) Filters to objects that have a relationship with the type and ID combination. diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index abf20b44cd17f..4df2f07bfcf41 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -17,13 +17,22 @@ experimental[] Create sets of {kib} saved objects from a file created by the exp ==== Path parameters `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + (Optional, string) An identifier for the <>. If `space_id` is not provided in the URL, the default space is used. [[saved-objects-api-import-query-params]] ==== Query parameters +`createNewCopies`:: + (Optional, boolean) Creates copies of saved objects, regenerates each object ID, and resets the origin. When used, potential conflict + errors are avoided. ++ +NOTE: This cannot be used with the `overwrite` option. + `overwrite`:: - (Optional, boolean) Overwrites saved objects. + (Optional, boolean) Overwrites saved objects when they already exist. When used, potential conflict errors are automatically resolved by + overwriting the destination object. ++ +NOTE: This cannot be used with the `createNewCopies` option. [[saved-objects-api-import-request-body]] ==== Request body @@ -37,13 +46,23 @@ The request body must include the multipart/form-data type. ==== Response body `success`:: - Top-level property that indicates if the import was successful. + (boolean) Indicates when the import was successfully completed. When set to `false`, some objects may not have been created. For + additional information, refer to the `errors` and `successResults` properties. `successCount`:: - Indicates the number of successfully imported records. + (number) Indicates the number of successfully imported records. `errors`:: - (array) Indicates the import was unsuccessful and specifies the objects that failed to import. + (Optional, array) Indicates the import was unsuccessful and specifies the objects that failed to import. ++ +NOTE: One object may result in multiple errors, which requires separate steps to resolve. For instance, a `missing_references` error and +`conflict` error). + +`successResults`:: + (Optional, array) Indicates the objects that are successfully imported, with any metadata if applicable. ++ +NOTE: Objects are only created when all resolvable errors are addressed, including conflicts and missing references. For information on how +to resolve errors, refer to the <>. [[saved-objects-api-import-codes]] ==== Response code @@ -51,8 +70,64 @@ The request body must include the multipart/form-data type. `200`:: Indicates a successful call. +[[saved-objects-api-import-example]] ==== Examples +[[saved-objects-api-import-example-1]] +===== Successful import with `createNewCopies` enabled + +Import an index pattern and dashboard: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/saved_objects/_import?createNewCopies=true -H "kbn-xsrf: true" --form file=@file.ndjson +-------------------------------------------------- +// KIBANA + +The `file.ndjson` file contains the following: + +[source,sh] +-------------------------------------------------- +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} +-------------------------------------------------- + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "success": true, + "successCount": 2, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern", + "destinationId": "4aba3770-0d04-45e1-9e34-4cf0fd2165ae", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "destinationId": "c31d1eca-9bc0-4a81-b5f9-30c442824c48", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] +} +-------------------------------------------------- + +The result indicates a successful import, and both objects are created. Since these objects are created as new copies, each entry in the +`successResults` array includes a `destinationId` attribute. + +[[saved-objects-api-import-example-2]] +===== Successful import with `createNewCopies` disabled + Import an index pattern and dashboard: [source,sh] @@ -75,11 +150,34 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 2 + "successCount": 2, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } -------------------------------------------------- -Import an index pattern and dashboard that includes a conflict on the index pattern: +The result indicates a successful import, and both objects are created. + +[[saved-objects-api-import-example-3]] +===== Failed import with conflict errors + +Import an index pattern, visualization, *Canvas* workpad, and dashboard that include saved objects: [source,sh] -------------------------------------------------- @@ -92,6 +190,8 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- {"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}} +{"type":"canvas-workpad","id":"my-canvas","attributes":{"name":"Look at my canvas"}} {"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} -------------------------------------------------- @@ -110,12 +210,85 @@ The API returns the following: "error": { "type": "conflict" }, + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-visualization", + "type": "my-vis", + "title": "Look at my visualization", + "error": { + "type": "conflict", + "destinationId": "another-vis" + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "title": "Look at my canvas", + "error": { + "type": "ambiguous_conflict", + "destinations": [ + { + "id": "another-canvas", + "title": "Look at another canvas", + "updatedAt": "2020-07-08T16:36:32.377Z" + }, + { + "id": "yet-another-canvas", + "title": "Look at yet another canvas", + "updatedAt": "2020-07-05T12:29:54.849Z" + } + ] + }, + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + } ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } -------------------------------------------------- -Import a visualization and dashboard with an index pattern for the visualization reference that doesn't exist: +The result indicates an unsuccessful import because the index pattern, visualization, *Canvas* workpad, and dashboard resulted in a conflict +error: + +* An index pattern with the same ID already exists, which resulted in a conflict error. To resolve the error, overwrite the existing object, +or skip the object. + +* A visualization with a different ID, but the same origin already exists, which resulted in a conflict error. The `destinationId` field +contains the `id` of the other visualization, which caused the conflict. The behavior is added to make sure that new objects that can be +shared between <> behave in a similar way as legacy non-shareable objects. When a shareable object is exported and then +imported into a new space, it retains its origin so that the conflicts are encountered as expected. To resolve, overwrite the specified +destination object, or skip the object. + +* Two *Canvas* workpads with different IDs, but the same origin, already exist, which resulted in a conflict error. The `destinations` array +describes the other workpads which caused the conflict. When a shareable object is exported, imported into a new space, then shared to +another space where an object of the same origin exists, the conflict error occurs. To resolve, pick a destination object to overwrite, or +skip the object. + +Objects are created when the error is resolved using the <>. + +[[saved-objects-api-import-example-4]] +===== Failed import with missing reference errors + +Import a visualization and dashboard when the index pattern for the visualization doesn't exist: [source,sh] -------------------------------------------------- @@ -127,21 +300,23 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- -{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]} -{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]} +{"type":"search","id":"my-search","attributes":{"title":"Look at my search"},"references":[{"name":"ref_0","type":"index-pattern","id":"another-pattern-*"}]} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"},{"name":"ref_1","type":"search","id":"my-search"}]} -------------------------------------------------- The API returns the following: [source,sh] -------------------------------------------------- +{ "success": false, - "successCount": 0, + "successCount": 1, "errors": [ { "id": "my-vis", "type": "visualization", - "title": "my-vis", + "title": "Look at my visualization", "error": { "type": "missing_references", "references": [ @@ -149,14 +324,45 @@ The API returns the following: "type": "index-pattern", "id": "my-pattern-*" } - ], - "blocking": [ + ] + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-search", + "type": "search", + "title": "Look at my search", + "error": { + "type": "missing_references", + "references": [ { - "type": "dashboard", - "id": "my-dashboard" + "type": "index-pattern", + "id": "another-pattern-*" } ] + }, + "meta": { + "icon": "searchApp", + "title": "Look at my search" + } + } + ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" } } ] +} -------------------------------------------------- + +The result indicates an unsuccessful import because the visualization and search resulted in a missing references error. + +Objects are created when the errors are resolved using the <>. diff --git a/docs/api/saved-objects/resolve_import_errors.asciidoc b/docs/api/saved-objects/resolve_import_errors.asciidoc index 03c116c39dd80..13d4ac9bbf7d0 100644 --- a/docs/api/saved-objects/resolve_import_errors.asciidoc +++ b/docs/api/saved-objects/resolve_import_errors.asciidoc @@ -4,7 +4,7 @@ Resolve import errors ++++ -experimental[] Resolve errors from the import API. +experimental[] Resolve errors from the <>. To resolve errors, you can: @@ -25,7 +25,14 @@ To resolve errors, you can: ==== Path parameters `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + (Optional, string) An identifier for the <>. When `space_id` is unspecfied in the URL, the default space is used. + +[[saved-objects-api-resolve-import-errors-query-params]] +==== Query parameters + +`createNewCopies`:: + (Optional, boolean) Creates copies of the saved objects, regenerates each object ID, and resets the origin. When enabled during the + initial import, also enable when resolving import errors. [[saved-objects-api-resolve-import-errors-request-body]] ==== Request body @@ -36,19 +43,47 @@ The request body must include the multipart/form-data type. The same file given to the import API. `retries`:: - (array) A list of `type`, `id`, `replaceReferences`, and `overwrite` objects to retry. The property `replaceReferences` is a list of `type`, `from`, and `to` used to change the object references. + (Required, array) The retry operations, which can specify how to resolve different types of errors. ++ +.Properties of `` +[%collapsible%open] +===== + `type`::: + (Required, string) The saved object type. + `id`::: + (Required, string) The saved object ID. + `overwrite`::: + (Optional, boolean) When set to `true`, the source object overwrites the conflicting destination object. When set to `false`, does + nothing. + `destinationId`::: + (Optional, string) Specifies the destination ID that the imported object should have, if different from the current ID. + `replaceReferences`::: + (Optional, array) A list of `type`, `from`, and `to` used to change the object references. + `ignoreMissingReferences`::: + (Optional, boolean) When set to `true`, ignores missing reference errors. When set to `false`, does nothing. +===== [[saved-objects-api-resolve-import-errors-response-body]] ==== Response body `success`:: - Top-level property that indicates if the errors successfully resolved. + (boolean) Indicates a successful import. When set to `false`, some objects may not have been created. For additional information, refer to + the `errors` and `successResults` properties. `successCount`:: - Indicates the number of successfully resolved records. + (number) Indicates the number of successfully resolved records. `errors`:: - (array) Specifies the objects that failed to resolve. + (Optional, array) Specifies the objects that failed to resolve. ++ +NOTE: One object can result in multiple errors, which requires separate steps to resolve. For instance, a `missing_references` error and a +`conflict` error. + +`successResults`:: + (Optional, array) Indicates the objects that are successfully imported, with any metadata if applicable. ++ +NOTE: Objects are only created when all resolvable errors are addressed, including conflict and missing references. To resolve errors, refer +to the <>. [[saved-objects-api-resolve-import-errors-codes]] ==== Response code @@ -59,11 +94,16 @@ The request body must include the multipart/form-data type. [[saved-objects-api-resolve-import-errors-example]] ==== Examples -Retry a dashboard import: +[[saved-objects-api-resolve-import-errors-example-1]] +===== Resolve conflict errors + +This example builds upon the <>. + +Resolve conflict errors for an index pattern, visualization, and *Canvas* workpad by overwriting the existing saved objects: [source,sh] -------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"dashboard","id":"my-dashboard"}]' +$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"index-pattern","id":"my-pattern","overwrite":true},{"type":"visualization","id":"my-vis","overwrite":true,"destinationId":"another-vis"},{"type":"canvas","id":"my-canvas","overwrite":true,"destinationId":"yet-another-canvas"},{"type":"dashboard","id":"my-dashboard"}]' -------------------------------------------------- // KIBANA @@ -71,6 +111,9 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}} +{"type":"canvas-workpad","id":"my-canvas","attributes":{"name":"Look at my canvas"}} {"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} -------------------------------------------------- @@ -80,41 +123,62 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 1 + "successCount": 4, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "another-vis", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "destinationId": "yet-another-canvas", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } -------------------------------------------------- -Resolve errors for a dashboard and overwrite the existing saved object: +The result indicates a successful import, and all four objects were created. -[source,sh] --------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"dashboard","id":"my-dashboard","overwrite":true}]' --------------------------------------------------- -// KIBANA +TIP: If a prior import attempt resulted in resolvable errors, you must include a retry for each object you want to import, including any +that were returned in the `successResults` array. In this example, we retried importing the dashboard accordingly. -The `file.ndjson` file contains the following: +[[saved-objects-api-resolve-import-errors-example-2]] +===== Resolve missing reference errors -[source,sh] --------------------------------------------------- -{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} -{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} --------------------------------------------------- +This example builds upon the <>. -The API returns the following: +Resolve a missing reference error for a visualization by replacing the index pattern with another, and resolve a missing reference error for +a search by ignoring it: [source,sh] -------------------------------------------------- -{ - "success": true, - "successCount": 1 -} --------------------------------------------------- - -Resolve errors for a visualization by replacing the index pattern with another: - -[source,sh] --------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]' +$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern-*","to":"existing-pattern"}]},{"type":"search","id":"my-search","ignoreMissingReferences":true},{"type":"dashboard","id":"my-dashboard"}]' -------------------------------------------------- // KIBANA @@ -122,7 +186,9 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- -{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]} +{"type":"search","id":"my-search","attributes":{"title":"Look at my search"},"references":[{"name":"ref_0","type":"index-pattern","id":"another-pattern-*"}]} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]} -------------------------------------------------- The API returns the following: @@ -131,6 +197,37 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 1 + "successCount": 3, + "successResults": [ + { + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-search", + "type": "search", + "meta": { + "icon": "searchApp", + "title": "Look at my search" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } -------------------------------------------------- + +The result indicates a successful import, and all three objects were created. + +TIP: If a prior import attempt resulted in resolvable errors, you must include a retry for each object you want to import, including any +that were returned in the `successResults` array. In this example, we retried importing the dashboard accordingly. diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index d39b1e134c9dc..853cca035a291 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -24,7 +24,8 @@ You can request to overwrite any objects that already exist in the target space ==== {api-path-parms-title} `space_id`:: -(Optional, string) The ID of the space that contains the saved objects you want to copy. When `space_id` is unspecified in the URL, the default space is used. + (Optional, string) The ID of the space that contains the saved objects you want to copy. When `space_id` is unspecified in the URL, the + default space is used. [role="child_attributes"] [[spaces-api-copy-saved-objects-request-body]] @@ -47,10 +48,12 @@ You can request to overwrite any objects that already exist in the target space ===== `includeReferences`:: - (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target spaces. The default value is `false`. + (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target + spaces. The default value is `false`. `overwrite`:: - (Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id` exists in the target space, that version is replaced with the version from the source space. The default value is `false`. + (Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id` + exists in the target space, that version is replaced with the version from the source space. The default value is `false`. [role="child_attributes"] [[spaces-api-copy-saved-objects-response-body]] @@ -63,7 +66,8 @@ You can request to overwrite any objects that already exist in the target space [%collapsible%open] ===== `success`::: - (boolean) The copy operation was successful. When set to `false`, some objects may have been copied. For additional information, refer to the `successCount` and `errors` properties. + (boolean) The copy operation was successful. When set to `false`, some objects may have been copied. For additional information, refer + to the `errors` and `successResults` properties. `successCount`::: (number) The number of objects that successfully copied. @@ -71,6 +75,9 @@ You can request to overwrite any objects that already exist in the target space `errors`::: (Optional, array) The errors that occurred during the copy operation. When errors are reported, the `success` flag is set to `false`. + +NOTE: One object may result in multiple errors, which requires separate steps to resolve. For instance, a `missing_references` error and a +`conflict` error. ++ .Properties of `errors` [%collapsible%open] ====== @@ -84,15 +91,159 @@ You can request to overwrite any objects that already exist in the target space .Properties of `error` [%collapsible%open] ======= - `type`::::: - (string) The type of error. For example, `unsupported_type`, `missing_references`, or `unknown`. Errors marked as `conflict` may be resolved by using the <>. + `type`:::: + (string) The type of error. For example, `conflict`, `ambiguous_conflict`, `missing_references`, `unsupported_type`, or `unknown`. + Errors marked as `conflict` or `ambiguous_conflict` may be resolved by using the <>. + `destinationId`:::: + (Optional, string) The destination ID that was used during the copy attempt. This is only present on `conflict` error types. + `destinations`:::: + (Optional, array) A list of possible object destinations with `id`, `title`, and `updatedAt` fields to describe each one. This is + only present on `ambiguous_conflict` error types. ======= ====== + + `successResults`::: + (Optional, array) Indicates successfully copied objects, with any applicable metadata. ++ +NOTE: Objects are created when all resolvable errors are addressed, including conflict and missing references errors. For more information, +refer to the <>. + ===== [[spaces-api-copy-saved-objects-example]] ==== {api-examples-title} -Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` and `sales` spaces: +[[spaces-api-copy-saved-objects-example-1]] +===== Successful copy (with `createNewCopies` enabled) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization, and that has a reference to an index pattern: + +[source,sh] +---- +$ curl -X POST api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "spaces": ["marketing"], + "includeReferences": true, + "createNewcopies": true +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": true, + "successCount": 3, + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "destinationId": "1e127098-5b80-417f-b0f1-c60c8395358f", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "a610ed80-1c73-4507-9e13-d3af736c8e04", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-index-pattern", + "type": "index-pattern", + "destinationId": "bc3c9c70-bf6f-4bec-b4ce-f4189aa9e26b", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + } + ] + } +} +---- + +The result indicates a successful copy, and all three objects are created. Since these objects were created as new copies, each entry in the +`successResults` array includes a `destinationId` attribute. + +[[spaces-api-copy-saved-objects-example-2]] +===== Successful copy (with `createNewCopies` disabled) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization, and that has a reference to an index pattern: + +[source,sh] +---- +$ curl -X POST api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "spaces": ["marketing"], + "includeReferences": true +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": true, + "successCount": 3, + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + }, + { + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-index-pattern", + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + } + ] + } +} +---- + +The result indicates a successful copy, and all three objects are created. + +[[spaces-api-copy-saved-objects-example-3]] +===== Failed copy (with conflict errors) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` and `sales` spaces. In +this example, the dashboard has a reference to a visualization and a *Canvas* workpad, and the visualization has a reference to an index +pattern: [source,sh] ---- @@ -115,35 +266,146 @@ The API returns the following: { "marketing": { "success": true, - "successCount": 5 + "successCount": 4, + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + }, + { + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + }, + { + "id": "my-index-pattern", + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + } + ] }, "sales": { "success": false, - "successCount": 4, - "errors": [{ - "id": "my-index-pattern", - "type": "index-pattern", - "error": { - "type": "conflict" + "successCount": 1, + "errors": [ + { + "id": "my-pattern", + "type": "index-pattern", + "title": "my-pattern-*", + "error": { + "type": "conflict" + }, + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-visualization", + "type": "my-vis", + "title": "Look at my visualization", + "error": { + "type": "conflict", + "destinationId": "another-vis" + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "title": "Look at my canvas", + "error": { + "type": "ambiguous_conflict", + "destinations": [ + { + "id": "another-canvas", + "title": "Look at another canvas", + "updatedAt": "2020-07-08T16:36:32.377Z" + }, + { + "id": "yet-another-canvas", + "title": "Look at yet another canvas", + "updatedAt": "2020-07-05T12:29:54.849Z" + } + ] + }, + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } } - }] + ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } } ---- -The `marketing` space succeeds, but the `sales` space fails due to a conflict in the index pattern. +The result indicates a successful copy for the `marketing` space, and an unsuccessful copy for the `sales` space because the index pattern, +visualization, and *Canvas* workpad each resulted in a conflict error: + +* An index pattern with the same ID already exists, which resulted in a conflict error. To resolve the error, overwrite the existing object, +or skip the object. -Copy a visualization with the `my-viz` ID from the `marketing` space to the `default` space: +* A visualization with a different ID, but the same origin already exists, which resulted in a conflict error. The `destinationId` field +contains the `id` of the other visualization, which caused the conflict. The behavior is added to make sure that new objects that can be +shared between spaces behave in a similar way as legacy non-shareable objects. When a shareable object is copied into a new space, it +retains its origin so that the conflicts are encountered as expected. To resolve, overwrite the specified destination object, or skip the +object. + +* Two *Canvas* workpads with different IDs, but the same origin, already exist, which resulted in a conflict error. The `destinations` array +describes the other workpads which caused the conflict. When a shareable object is copied into a new space, then shared to another space +where an object of the same origin exists, the conflict error occurs. To resolve, pick a destination object to overwrite, or skip the +object. + +Objects are created when the error is resolved using the <>. + +[[spaces-api-copy-saved-objects-example-4]] +===== Failed copy (with missing reference errors) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization and a *Canvas* workpad, and the visualization has a reference to an index pattern: [source,sh] ---- -$ curl -X POST s/marketing/api/spaces/_copy_saved_objects +$ curl -X POST api/spaces/_copy_saved_objects { "objects": [{ - "type": "visualization", - "id": "my-viz" + "type": "dashboard", + "id": "my-dashboard" }], - "spaces": ["default"] + "spaces": ["marketing"], + "includeReferences": true } ---- // KIBANA @@ -153,9 +415,52 @@ The API returns the following: [source,sh] ---- { - "default": { - "success": true, - "successCount": 1 + "marketing": { + "success": false, + "successCount": 2, + "errors": [ + { + "id": "my-vis", + "type": "visualization", + "title": "Look at my visualization", + "error": { + "type": "missing_references", + "references": [ + { + "type": "index-pattern", + "id": "my-pattern-*" + } + ] + }, + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + ] + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + } + ], } } ---- + +The result indicates an unsuccessful copy because the visualization resulted in a missing references error. + +Objects are created when the errors are resolved using the <>. diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index 6c10ae9046cab..6d799ebb0014e 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -46,7 +46,8 @@ Execute the <>, w (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects are copied into the target spaces. The `includeReferences` must be the same values used during the failed <> operation. The default value is `false`. `retries`:: - (Required, object) The retry operations to attempt. Object keys represent the target space IDs. + (Required, object) The retry operations to attempt, which can specify how to resolve different types of errors. Object keys represent the + target space IDs. + .Properties of `retries` [%collapsible%open] @@ -64,6 +65,10 @@ Execute the <>, w (Required, string) The saved object ID. `overwrite`:::: (Required, boolean) When set to `true`, the saved object from the source space (desigated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. + `destinationId`:::: + (Optional, string) Specifies the destination ID that the copied object should have, if different from the current ID. + `ignoreMissingReferences`::: + (Optional, boolean) When set to `true`, any missing references errors are ignored. When set to `false`, does nothing. ====== ===== @@ -86,6 +91,9 @@ Execute the <>, w `errors`::: (Optional, array) The errors that occurred during the copy operation. When errors are reported, the `success` flag is set to `false`. + +NOTE: One object may result in multiple errors, which requires separate steps to resolve. For instance, a `missing_references` error and a +`conflict` error. ++ .Properties of `errors` [%collapsible%open] @@ -104,15 +112,32 @@ Execute the <>, w [%collapsible%open] ======= `type`:::: - (string) The type of error. For example, `unsupported_type`, `missing_references`, or `unknown`. + (string) The type of error. For example, `conflict`, `ambiguous_conflict`, `missing_references`, `unsupported_type`, or `unknown`. + `destinationId`:::: + (Optional, string) The destination ID that was used during the copy attempt. This is only present on `conflict` errors types. + `destinations`:::: + (Optional, array) A list of possible object destinations with `id`, `title`, and `updatedAt` fields to describe each one. This is + only present on `ambiguous_conflict` error types. ======= ====== + +`successResults`::: + (Optional, array) Indicates successfully copied objects, with any applicable metadata. ++ +NOTE: Objects are created when all resolvable errors are addressed, including conflict and missing references errors. For more information, +refer to the <>. + ===== [[spaces-api-resolve-copy-saved-objects-conflicts-example]] ==== {api-examples-title} -Overwrite an index pattern in the `marketing` space, and a visualization in the `sales` space: +[[spaces-api-resolve-copy-saved-objects-conflicts-example-1]] +===== Resolve conflict errors + +This example builds upon the <>. + +Resolve conflict errors for an index pattern, visualization, and *Canvas* workpad by overwriting the existing saved objects: [source,sh] ---- @@ -124,16 +149,29 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors }], "includeReferences": true, "retries": { - "marketing": [{ - "type": "index-pattern", - "id": "my-pattern", - "overwrite": true - }], - "sales": [{ - "type": "visualization", - "id": "my-viz", - "overwrite": true - }] + "sales": [ + { + "type": "index-pattern", + "id": "my-pattern", + "overwrite": true + }, + { + "type": "visualization", + "id": "my-vis", + "overwrite": true, + "destinationId": "another-vis" + }, + { + "type": "canvas", + "id": "my-canvas", + "overwrite": true, + "destinationId": "yet-another-canvas" + }, + { + "type": "dashboard", + "id": "my-dashboard" + } + ] } } ---- @@ -144,13 +182,130 @@ The API returns the following: [source,sh] ---- { - "marketing": { - "success": true, - "successCount": 1 - }, "sales": { "success": true, - "successCount": 1 + "successCount": 4, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern", + "meta": { + "icon": "indexPatternApp", + "title": "my-pattern-*" + } + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "another-vis", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "destinationId": "yet-another-canvas", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] } } ---- + +The result indicates a successful copy, and all four objects are created. + +TIP: If a prior copy attempt resulted in resolvable errors, you must include a retry for each object you want to copy, including any that +were returned in the `successResults` array. In this example, we retried copying the dashboard accordingly. + +[[spaces-api-resolve-copy-saved-objects-conflicts-example-2]] +===== Resolve missing reference errors + +This example builds upon the <>. + +Resolve missing reference errors for a visualization by ignoring the error: + +[source,sh] +---- +$ curl -X POST api/spaces/_resolve_copy_saved_objects_errors +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "includeReferences": true, + "retries": { + "marketing": [ + { + "type": "visualization", + "id": "my-vis", + "ignoreMissingReferences": true + }, + { + "type": "canvas", + "id": "my-canvas" + }, + { + "type": "dashboard", + "id": "my-dashboard" + } + ] + } +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": true, + "successCount": 3, + "successResults": [ + { + "id": "my-vis", + "type": "visualization", + "meta": { + "icon": "visualizeApp", + "title": "Look at my visualization" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "meta": { + "icon": "canvasApp", + "title": "Look at my canvas" + } + }, + { + "id": "my-dashboard", + "type": "dashboard", + "meta": { + "icon": "dashboardApp", + "title": "Look at my dashboard" + } + } + ] + } +} +---- + +The result indicates a successful copy and all three objects are created. + +TIP: If a prior copy attempt resulted in resolvable errors, you must include a retry for each object you want to copy, including any that +were returned in the `successResults` array. In this example, we retried copying the dashboard and canvas accordingly. diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 657e3ec8b8bb1..6a6c840074f02 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -13,7 +13,7 @@ A *** denotes a required argument. A † denotes an argument can be passed multiple times. -<> | B | <> | <> | <> | <> | <> | <> | <> | <> | K | <> | <> | <> | O | <> | Q | <> | <> | <> | <> | V | W | X | Y | Z +<> | B | <> | <> | <> | <> | <> | <> | <> | <> | K | <> | <> | <> | O | <> | Q | <> | <> | <> | <> | <> | W | X | Y | Z [float] [[a_fns]] @@ -897,7 +897,7 @@ Default: `"-_index:.kibana"` |`string` |An index or index pattern. For example, `"logstash-*"`. -Default: `_all` +Default: `"_all"` |=== *Returns:* `number` @@ -965,7 +965,7 @@ Default: `1000` |`string` |An index or index pattern. For example, `"logstash-*"`. -Default: `_all` +Default: `"_all"` |`metaFields` |`string` @@ -1026,7 +1026,7 @@ Alias: `tz` |`string` |The timezone to use for date operations. Valid ISO8601 formats and UTC offsets both work. -Default: `UTC` +Default: `"UTC"` |=== *Returns:* `datatable` @@ -1238,7 +1238,7 @@ filters |`string` |The horizontal text alignment. -Default: `left` +Default: `"left"` |`color` |`string` @@ -1280,7 +1280,7 @@ Default: `false` |`string` |The font weight. For example, `"normal"`, `"bold"`, `"bolder"`, `"lighter"`, `"100"`, `"200"`, `"300"`, `"400"`, `"500"`, `"600"`, `"700"`, `"800"`, or `"900"`. -Default: `normal` +Default: `"normal"` |=== *Returns:* `style` @@ -2469,7 +2469,7 @@ Alias: `shape` |`string` |Pick a shape. -Default: `square` +Default: `"square"` |`border` @@ -2732,7 +2732,7 @@ Aliases: `c`, `field` |`string` |The column or field that you want to filter. -Default: `@timestamp` +Default: `"@timestamp"` |`compact` |`boolean` @@ -2871,3 +2871,56 @@ Default: `""` |=== *Returns:* `string` + +[float] +[[v_fns]] +== V + +[float] +[[var_fn]] +=== `var` + +Updates the Kibana global context. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|_Unnamed_ *** + +Alias: `name` +|`string` +|Specify the name of the variable. +|=== + +*Returns:* Depends on your input and arguments + + +[float] +[[var_set_fn]] +=== `var_set` + +Updates the Kibana global context. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|_Unnamed_ *** + +Alias: `name` +|`string` +|Specify the name of the variable. + +|`value` + +Alias: `val` +|`any` +|Specify the value for the variable. When unspecified, the input context is used. +|=== + +*Returns:* Depends on your input and arguments diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 63a44b54d454f..42cee6ef0e58a 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -48,7 +48,7 @@ guidelines] * Write all new code on {kib-repo}blob/{branch}/src/core/README.md[the platform], and following -{kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions] +{kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions]. * _Always_ use the `SavedObjectClient` for reading and writing Saved Objects. * Add `README`s to all your plugins and services. diff --git a/docs/developer/best-practices/stability.asciidoc b/docs/developer/best-practices/stability.asciidoc index f4b7ae1229909..348412e593d9e 100644 --- a/docs/developer/best-practices/stability.asciidoc +++ b/docs/developer/best-practices/stability.asciidoc @@ -52,15 +52,15 @@ storeinSessions?) [discrete] === Browser coverage -Refer to the list of browsers and OS {kib} supports +Refer to the list of browsers and OS {kib} supports: https://www.elastic.co/support/matrix Does the feature work efficiently on the list of supported browsers? [discrete] -=== Upgrade Scenarios - Migration scenarios- +=== Upgrade and Migration scenarios -Does the feature affect old -indices, saved objects ? - Has the feature been tested with {kib} -aliases - Read/Write privileges of the indices before and after the +* Does the feature affect old indices or saved objects? +* Has the feature been tested with {kib} aliases? +* Read/Write privileges of the indices before and after the upgrade? diff --git a/docs/developer/getting-started/building-kibana.asciidoc b/docs/developer/getting-started/building-kibana.asciidoc index 72054b1628fc2..04771b34bf69f 100644 --- a/docs/developer/getting-started/building-kibana.asciidoc +++ b/docs/developer/getting-started/building-kibana.asciidoc @@ -1,7 +1,7 @@ [[building-kibana]] == Building a {kib} distributable -The following commands will build a {kib} production distributable. +The following command will build a {kib} production distributable: [source,bash] ---- @@ -36,4 +36,4 @@ To specify a package to build you can add `rpm` or `deb` as an argument. yarn build --rpm ---- -Distributable packages can be found in `target/` after the build completes. \ No newline at end of file +Distributable packages can be found in `target/` after the build completes. diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index eaa35eece5a2c..10e603a8da8bb 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -49,7 +49,7 @@ ____ (You can also run `yarn kbn` to see the other available commands. For more info about this tool, see -{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}packages/kbn-pm].) +{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}/packages/kbn-pm].) When switching branches which use different versions of npm packages you may need to run: @@ -137,4 +137,4 @@ include::debugging.asciidoc[leveloffset=+1] include::building-kibana.asciidoc[leveloffset=+1] -include::development-plugin-resources.asciidoc[leveloffset=+1] \ No newline at end of file +include::development-plugin-resources.asciidoc[leveloffset=+1] diff --git a/docs/developer/getting-started/running-kibana-advanced.asciidoc b/docs/developer/getting-started/running-kibana-advanced.asciidoc index c3b7847b0f8ba..44897184f88f2 100644 --- a/docs/developer/getting-started/running-kibana-advanced.asciidoc +++ b/docs/developer/getting-started/running-kibana-advanced.asciidoc @@ -73,8 +73,8 @@ settings]. [discrete] === Potential Optimization Pitfalls -* Webpack is trying to include a file in the bundle that I deleted and -is now complaining about it is missing +* Webpack is trying to include a file in the bundle that was deleted and +is now complaining about it being missing * A module id that used to resolve to a single file now resolves to a directory, but webpack isn’t adapting * (if you discover other scenarios, please send a PR!) @@ -84,4 +84,4 @@ directory, but webpack isn’t adapting {kib} includes self-signed certificates that can be used for development purposes in the browser and for communicating with -{es}: `yarn start --ssl` & `yarn es snapshot --ssl`. \ No newline at end of file +{es}: `yarn start --ssl` & `yarn es snapshot --ssl`. diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 8f2bde3856019..c931ce544f5d5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -105,6 +105,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginInitializerContext](./kibana-plugin-core-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | | [SavedObject](./kibana-plugin-core-public.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | | | [SavedObjectReference](./kibana-plugin-core-public.savedobjectreference.md) | A reference to another saved object. | | [SavedObjectsBaseOptions](./kibana-plugin-core-public.savedobjectsbaseoptions.md) | | | [SavedObjectsBatchResponse](./kibana-plugin-core-public.savedobjectsbatchresponse.md) | | @@ -115,11 +116,13 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) | | | [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponsePublic](./kibana-plugin-core-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | | [SavedObjectsImportConflictError](./kibana-plugin-core-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | | [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) | Represents a failure to import. | | [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | | [SavedObjectsImportResponse](./kibana-plugin-core-public.savedobjectsimportresponse.md) | The response describing the result of an import. | | [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) | Represents a successful import. | | [SavedObjectsImportUnknownError](./kibana-plugin-core-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsMigrationVersion](./kibana-plugin-core-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | @@ -175,6 +178,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md index f6ffa49c2e6b2..ab9a611fc3a5c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md @@ -7,8 +7,5 @@ Signature: ```typescript -error?: { - message: string; - statusCode: number; - }; +error?: SavedObjectError; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index b67d0536fb336..eb6059747426d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -15,10 +15,11 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | -| [error](./kibana-plugin-core-public.savedobject.error.md) | {
message: string;
statusCode: number;
} | | +| [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [originId](./kibana-plugin-core-public.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-public.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md new file mode 100644 index 0000000000000..f5bab09b9bcc0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [originId](./kibana-plugin-core-public.savedobject.originid.md) + +## SavedObject.originId property + +The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.error.md new file mode 100644 index 0000000000000..87180a520090f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [error](./kibana-plugin-core-public.savedobjecterror.error.md) + +## SavedObjectError.error property + +Signature: + +```typescript +error: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md new file mode 100644 index 0000000000000..2117cea433b5c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) + +## SavedObjectError interface + +Signature: + +```typescript +export interface SavedObjectError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-core-public.savedobjecterror.error.md) | string | | +| [message](./kibana-plugin-core-public.savedobjecterror.message.md) | string | | +| [metadata](./kibana-plugin-core-public.savedobjecterror.metadata.md) | Record<string, unknown> | | +| [statusCode](./kibana-plugin-core-public.savedobjecterror.statuscode.md) | number | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md new file mode 100644 index 0000000000000..2a51d4d1a514d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [message](./kibana-plugin-core-public.savedobjecterror.message.md) + +## SavedObjectError.message property + +Signature: + +```typescript +message: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md new file mode 100644 index 0000000000000..a2725f0206655 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [metadata](./kibana-plugin-core-public.savedobjecterror.metadata.md) + +## SavedObjectError.metadata property + +Signature: + +```typescript +metadata?: Record; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md new file mode 100644 index 0000000000000..75a57e98fece2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [statusCode](./kibana-plugin-core-public.savedobjecterror.statuscode.md) + +## SavedObjectError.statusCode property + +Signature: + +```typescript +statusCode: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 70ad235fb8971..ebd0a99531755 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -23,6 +23,7 @@ export interface SavedObjectsFindOptions | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | +| [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md new file mode 100644 index 0000000000000..faa971509eca2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) + +## SavedObjectsFindOptions.rootSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rootSearchFields?: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md new file mode 100644 index 0000000000000..59ce43c4bea62 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) > [destinations](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md) + +## SavedObjectsImportAmbiguousConflictError.destinations property + +Signature: + +```typescript +destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md new file mode 100644 index 0000000000000..76dfacf132f0a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) + +## SavedObjectsImportAmbiguousConflictError interface + +Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + +Signature: + +```typescript +export interface SavedObjectsImportAmbiguousConflictError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinations](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md) | Array<{
id: string;
title?: string;
updatedAt?: string;
}> | | +| [type](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md) | 'ambiguous_conflict' | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md new file mode 100644 index 0000000000000..600c56988ac75 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) > [type](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md) + +## SavedObjectsImportAmbiguousConflictError.type property + +Signature: + +```typescript +type: 'ambiguous_conflict'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md new file mode 100644 index 0000000000000..ba4002d932f57 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportConflictError](./kibana-plugin-core-public.savedobjectsimportconflicterror.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md) + +## SavedObjectsImportConflictError.destinationId property + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md index a54cdac56c218..b0320b05ecadc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md @@ -16,5 +16,6 @@ export interface SavedObjectsImportConflictError | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsimportconflicterror.type.md) | 'conflict' | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md index a76ab8e5c926a..201f56bf925d1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md @@ -7,5 +7,5 @@ Signature: ```typescript -error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; +error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md index 5703c613adbd7..e12396e9fa7b9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md @@ -16,8 +16,10 @@ export interface SavedObjectsImportError | Property | Type | Description | | --- | --- | --- | -| [error](./kibana-plugin-core-public.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | +| [error](./kibana-plugin-core-public.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-public.savedobjectsimporterror.id.md) | string | | +| [meta](./kibana-plugin-core-public.savedobjectsimporterror.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-public.savedobjectsimporterror.overwrite.md) | boolean | If overwrite is specified, an attempt was made to overwrite an existing object. | | [title](./kibana-plugin-core-public.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md new file mode 100644 index 0000000000000..97bf3c4cff8eb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) > [meta](./kibana-plugin-core-public.savedobjectsimporterror.meta.md) + +## SavedObjectsImportError.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md new file mode 100644 index 0000000000000..69a8726b0588a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) > [overwrite](./kibana-plugin-core-public.savedobjectsimporterror.overwrite.md) + +## SavedObjectsImportError.overwrite property + +If `overwrite` is specified, an attempt was made to overwrite an existing object. + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md index 40e5814d30fb3..95eeaaedf94c5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.title.md @@ -4,6 +4,11 @@ ## SavedObjectsImportError.title property +> Warning: This API is now obsolete. +> +> Use `meta.title` instead +> + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md deleted file mode 100644 index 5b6862fa21bbc..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md +++ /dev/null @@ -1,14 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md) > [blocking](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md) - -## SavedObjectsImportMissingReferencesError.blocking property - -Signature: - -```typescript -blocking: Array<{ - type: string; - id: string; - }>; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md index 4417a19b28792..1fea85ea239d5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md @@ -16,7 +16,6 @@ export interface SavedObjectsImportMissingReferencesError | Property | Type | Description | | --- | --- | --- | -| [blocking](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.blocking.md) | Array<{
type: string;
id: string;
}> | | | [references](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.references.md) | Array<{
type: string;
id: string;
}> | | | [type](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.type.md) | 'missing_references' | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md index 910de33c30e62..0aba4d517e43a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md @@ -19,4 +19,5 @@ export interface SavedObjectsImportResponse | [errors](./kibana-plugin-core-public.savedobjectsimportresponse.errors.md) | SavedObjectsImportError[] | | | [success](./kibana-plugin-core-public.savedobjectsimportresponse.success.md) | boolean | | | [successCount](./kibana-plugin-core-public.savedobjectsimportresponse.successcount.md) | number | | +| [successResults](./kibana-plugin-core-public.savedobjectsimportresponse.successresults.md) | SavedObjectsImportSuccess[] | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md new file mode 100644 index 0000000000000..51a47b6c2d953 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportResponse](./kibana-plugin-core-public.savedobjectsimportresponse.md) > [successResults](./kibana-plugin-core-public.savedobjectsimportresponse.successresults.md) + +## SavedObjectsImportResponse.successResults property + +Signature: + +```typescript +successResults?: SavedObjectsImportSuccess[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md new file mode 100644 index 0000000000000..f60c713973d58 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [createNewCopy](./kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md) + +## SavedObjectsImportRetry.createNewCopy property + +If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + +Signature: + +```typescript +createNewCopy?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md new file mode 100644 index 0000000000000..5131d1d01ff02 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportretry.destinationid.md) + +## SavedObjectsImportRetry.destinationId property + +The object ID that will be created or overwritten. If not specified, the `id` field will be used. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md new file mode 100644 index 0000000000000..4ce833f2966cc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [ignoreMissingReferences](./kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md) + +## SavedObjectsImportRetry.ignoreMissingReferences property + +If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + +Signature: + +```typescript +ignoreMissingReferences?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md index d625302d97eed..b0bda93ef8b72 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md @@ -16,7 +16,10 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-public.savedobjectsimportretry.id.md) | string | | +| [ignoreMissingReferences](./kibana-plugin-core-public.savedobjectsimportretry.ignoremissingreferences.md) | boolean | If ignoreMissingReferences is specified, reference validation will be skipped for this object. | | [overwrite](./kibana-plugin-core-public.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-public.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | | [type](./kibana-plugin-core-public.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md new file mode 100644 index 0000000000000..0598691fbd525 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [createNewCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md) + +## SavedObjectsImportSuccess.createNewCopy property + +> Warning: This API is now obsolete. +> +> If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +createNewCopy?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md new file mode 100644 index 0000000000000..55611a77aeb67 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md) + +## SavedObjectsImportSuccess.destinationId property + +If `destinationId` is specified, the new object has a new ID that is different from the import ID. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md new file mode 100644 index 0000000000000..6d6271e37dffe --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [id](./kibana-plugin-core-public.savedobjectsimportsuccess.id.md) + +## SavedObjectsImportSuccess.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md new file mode 100644 index 0000000000000..4872deb5ee0db --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) + +## SavedObjectsImportSuccess interface + +Represents a successful import. + +Signature: + +```typescript +export interface SavedObjectsImportSuccess +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md) | boolean | | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | +| [id](./kibana-plugin-core-public.savedobjectsimportsuccess.id.md) | string | | +| [meta](./kibana-plugin-core-public.savedobjectsimportsuccess.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md) | boolean | If overwrite is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). | +| [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md new file mode 100644 index 0000000000000..d1c7bc92b5cbf --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [meta](./kibana-plugin-core-public.savedobjectsimportsuccess.meta.md) + +## SavedObjectsImportSuccess.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md new file mode 100644 index 0000000000000..18ae2ca9bee3d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [overwrite](./kibana-plugin-core-public.savedobjectsimportsuccess.overwrite.md) + +## SavedObjectsImportSuccess.overwrite property + +If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md new file mode 100644 index 0000000000000..6ac14455d281f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) + +## SavedObjectsImportSuccess.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md new file mode 100644 index 0000000000000..f2205d2cee424 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) + +## SavedObjectsNamespaceType type + +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. + +Signature: + +```typescript +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 597bb9bc2376a..ccc73d4fb858e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -26,5 +26,4 @@ export interface CoreSetupSavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [status](./kibana-plugin-core-server.coresetup.status.md) | StatusServiceSetup | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | -| [uuid](./kibana-plugin-core-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md deleted file mode 100644 index c709c74497bd0..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [uuid](./kibana-plugin-core-server.coresetup.uuid.md) - -## CoreSetup.uuid property - -[UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) - -Signature: - -```typescript -uuid: UuidServiceSetup; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md b/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md index 6fabfb7a321ae..cebebbaf94fe6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md +++ b/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md @@ -9,14 +9,14 @@ Import saved objects from given stream. See the [options](./kibana-plugin-core-s Signature: ```typescript -export declare function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsImportOptions): Promise; +export declare function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsImportOptions | | +| { readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, } | SavedObjectsImportOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 98d7b0610abea..89330d2a86f76 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -45,10 +45,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [deepFreeze(object)](./kibana-plugin-core-server.deepfreeze.md) | Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively | | [exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, })](./kibana-plugin-core-server.exportsavedobjectstostream.md) | Generates sorted saved object stream to be used for export. See the [options](./kibana-plugin-core-server.savedobjectsexportoptions.md) for more detailed information. | | [getFlattenedObject(rootValue)](./kibana-plugin-core-server.getflattenedobject.md) | Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from rootValue.example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } | -| [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | +| [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | | [isRelativeUrl(candidatePath)](./kibana-plugin-core-server.isrelativeurl.md) | Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* | | [modifyUrl(url, urlModifier)](./kibana-plugin-core-server.modifyurl.md) | Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url.Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hashWhy? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints | -| [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | +| [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | ## Interfaces @@ -161,6 +161,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) | | | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md) | | | [SavedObjectsBulkUpdateResponse](./kibana-plugin-core-server.savedobjectsbulkupdateresponse.md) | | +| [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) | | +| [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | @@ -175,12 +177,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | | [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) | | +| [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | | [SavedObjectsImportConflictError](./kibana-plugin-core-server.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | | [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) | Represents a failure to import. | | [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | | [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) | Options to control the import operation. | | [SavedObjectsImportResponse](./kibana-plugin-core-server.savedobjectsimportresponse.md) | The response describing the result of an import. | | [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) | Represents a successful import. | | [SavedObjectsImportUnknownError](./kibana-plugin-core-server.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-server.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | | @@ -214,7 +218,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | | | [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) | We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". | | [UserProvidedValues](./kibana-plugin-core-server.userprovidedvalues.md) | Describes the values explicitly set by user. | -| [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | APIs to access the application's instance uuid. | ## Variables @@ -302,7 +305,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global.Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md index 4d111c8f20887..76e4f222f0228 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md @@ -10,5 +10,6 @@ env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md index 0d7fcf3b10bca..18760170afa1f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md @@ -17,7 +17,7 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | | [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
};
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | -| [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
} | | +| [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
instanceUuid: string;
} | | | [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | | | [opaqueId](./kibana-plugin-core-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md b/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md index c7f30b0533d04..a2255613e0f6c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md +++ b/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md @@ -9,14 +9,14 @@ Resolve and return saved object import errors. See the [options](./kibana-plugin Signature: ```typescript -export declare function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsResolveImportErrorsOptions): Promise; +export declare function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsResolveImportErrorsOptions | | +| { readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, } | SavedObjectsResolveImportErrorsOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md index dffef4392c85c..ef42053e38626 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md @@ -7,8 +7,5 @@ Signature: ```typescript -error?: { - message: string; - statusCode: number; - }; +error?: SavedObjectError; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 94d1c378899df..5aefc55736cd1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -15,10 +15,11 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | -| [error](./kibana-plugin-core-server.savedobject.error.md) | {
message: string;
statusCode: number;
} | | +| [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [originId](./kibana-plugin-core-server.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-server.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md new file mode 100644 index 0000000000000..95bcad7ce8b1b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [originId](./kibana-plugin-core-server.savedobject.originid.md) + +## SavedObject.originId property + +The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 5ccad134248f6..019d30570ab36 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,6 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | | [type](./kibana-plugin-core-server.savedobjectsbulkcreateobject.type.md) | string | | | [version](./kibana-plugin-core-server.savedobjectsbulkcreateobject.version.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md new file mode 100644 index 0000000000000..c182a47891f62 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) + +## SavedObjectsBulkCreateObject.originId property + +Optional ID of the original saved object, if this object's `id` was regenerated + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md new file mode 100644 index 0000000000000..2b7cd5cc486a8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) > [id](./kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md) + +## SavedObjectsCheckConflictsObject.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md new file mode 100644 index 0000000000000..c327cc4a20551 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) + +## SavedObjectsCheckConflictsObject interface + + +Signature: + +```typescript +export interface SavedObjectsCheckConflictsObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md) | string | | +| [type](./kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md new file mode 100644 index 0000000000000..82f89536e4189 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) > [type](./kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md) + +## SavedObjectsCheckConflictsObject.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md new file mode 100644 index 0000000000000..80bd61d8906e3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) > [errors](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md) + +## SavedObjectsCheckConflictsResponse.errors property + +Signature: + +```typescript +errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md new file mode 100644 index 0000000000000..499398586e7dd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) + +## SavedObjectsCheckConflictsResponse interface + + +Signature: + +```typescript +export interface SavedObjectsCheckConflictsResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [errors](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md) | Array<{
id: string;
type: string;
error: SavedObjectError;
}> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md new file mode 100644 index 0000000000000..5cffb0c498b0b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [checkConflicts](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) + +## SavedObjectsClient.checkConflicts() method + +Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + +Signature: + +```typescript +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCheckConflictsObject[] | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 7038c0c07012f..7c1273e63d24b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -29,6 +29,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | +| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index c5201efd0608d..d936829443753 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | | [references](./kibana-plugin-core-server.savedobjectscreateoptions.references.md) | SavedObjectReference[] | | | [refresh](./kibana-plugin-core-server.savedobjectscreateoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md new file mode 100644 index 0000000000000..14333079f7440 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) + +## SavedObjectsCreateOptions.originId property + +Optional ID of the original saved object, if this object's `id` was regenerated + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 67e931f0cb3b3..15a9d99b3d062 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -23,6 +23,7 @@ export interface SavedObjectsFindOptions | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | +| [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md new file mode 100644 index 0000000000000..204342c45f64e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) + +## SavedObjectsFindOptions.rootSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rootSearchFields?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md new file mode 100644 index 0000000000000..445979dd740d3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) > [destinations](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md) + +## SavedObjectsImportAmbiguousConflictError.destinations property + +Signature: + +```typescript +destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md new file mode 100644 index 0000000000000..d2c0a397ebe8a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) + +## SavedObjectsImportAmbiguousConflictError interface + +Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + +Signature: + +```typescript +export interface SavedObjectsImportAmbiguousConflictError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinations](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md) | Array<{
id: string;
title?: string;
updatedAt?: string;
}> | | +| [type](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md) | 'ambiguous_conflict' | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md new file mode 100644 index 0000000000000..ca98682873033 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) > [type](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md) + +## SavedObjectsImportAmbiguousConflictError.type property + +Signature: + +```typescript +type: 'ambiguous_conflict'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md new file mode 100644 index 0000000000000..858f171223472 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportConflictError](./kibana-plugin-core-server.savedobjectsimportconflicterror.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md) + +## SavedObjectsImportConflictError.destinationId property + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md index a3e946eccb984..153cd55c9199e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md @@ -16,5 +16,6 @@ export interface SavedObjectsImportConflictError | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsimportconflicterror.type.md) | 'conflict' | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md index a5d33de32d594..6fc0c86b2fafc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md @@ -7,5 +7,5 @@ Signature: ```typescript -error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; +error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md index 473812fcbfd72..713e23edef081 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md @@ -16,8 +16,10 @@ export interface SavedObjectsImportError | Property | Type | Description | | --- | --- | --- | -| [error](./kibana-plugin-core-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | +| [error](./kibana-plugin-core-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-server.savedobjectsimporterror.id.md) | string | | +| [meta](./kibana-plugin-core-server.savedobjectsimporterror.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-server.savedobjectsimporterror.overwrite.md) | boolean | If overwrite is specified, an attempt was made to overwrite an existing object. | | [title](./kibana-plugin-core-server.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md new file mode 100644 index 0000000000000..8d88bf1e375d4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) > [meta](./kibana-plugin-core-server.savedobjectsimporterror.meta.md) + +## SavedObjectsImportError.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md new file mode 100644 index 0000000000000..f706f921cf052 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) > [overwrite](./kibana-plugin-core-server.savedobjectsimporterror.overwrite.md) + +## SavedObjectsImportError.overwrite property + +If `overwrite` is specified, an attempt was made to overwrite an existing object. + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md index bfa20bb963acb..3d787cbe20bb4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.title.md @@ -4,6 +4,11 @@ ## SavedObjectsImportError.title property +> Warning: This API is now obsolete. +> +> Use `meta.title` instead +> + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md deleted file mode 100644 index 7ab5662003d8f..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md +++ /dev/null @@ -1,14 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md) > [blocking](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md) - -## SavedObjectsImportMissingReferencesError.blocking property - -Signature: - -```typescript -blocking: Array<{ - type: string; - id: string; - }>; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md index b489b1bec26c3..01557eff549f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md @@ -16,7 +16,6 @@ export interface SavedObjectsImportMissingReferencesError | Property | Type | Description | | --- | --- | --- | -| [blocking](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.blocking.md) | Array<{
type: string;
id: string;
}> | | | [references](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.references.md) | Array<{
type: string;
id: string;
}> | | | [type](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.type.md) | 'missing_references' | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md new file mode 100644 index 0000000000000..23c6fe0051746 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [createNewCopies](./kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md) + +## SavedObjectsImportOptions.createNewCopies property + +If true, will create new copies of import objects, each with a random `id` and undefined `originId`. + +Signature: + +```typescript +createNewCopies: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md index f9da9956772bb..6578b01ffa609 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md @@ -16,10 +16,11 @@ export interface SavedObjectsImportOptions | Property | Type | Description | | --- | --- | --- | +| [createNewCopies](./kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md) | boolean | If true, will create new copies of import objects, each with a random id and undefined originId. | | [namespace](./kibana-plugin-core-server.savedobjectsimportoptions.namespace.md) | string | if specified, will import in given namespace, else will import as global object | | [objectLimit](./kibana-plugin-core-server.savedobjectsimportoptions.objectlimit.md) | number | The maximum number of object to import | -| [overwrite](./kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md) | boolean | if true, will override existing object if present | +| [overwrite](./kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md) | boolean | If true, will override existing object if present. Note: this has no effect when used with the createNewCopies option. | | [readStream](./kibana-plugin-core-server.savedobjectsimportoptions.readstream.md) | Readable | The stream of [saved objects](./kibana-plugin-core-server.savedobject.md) to import | | [savedObjectsClient](./kibana-plugin-core-server.savedobjectsimportoptions.savedobjectsclient.md) | SavedObjectsClientContract | [client](./kibana-plugin-core-server.savedobjectsclientcontract.md) to use to perform the import operation | -| [supportedTypes](./kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md) | string[] | the list of allowed types to import | +| [typeRegistry](./kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md) | ISavedObjectTypeRegistry | The registry of all known saved object types | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md index e42d04c5a9180..1e9192c47679d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md @@ -4,7 +4,7 @@ ## SavedObjectsImportOptions.overwrite property -if true, will override existing object if present +If true, will override existing object if present. Note: this has no effect when used with the `createNewCopies` option. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md deleted file mode 100644 index 999cb73cbdfba..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [supportedTypes](./kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md) - -## SavedObjectsImportOptions.supportedTypes property - -the list of allowed types to import - -Signature: - -```typescript -supportedTypes: string[]; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md new file mode 100644 index 0000000000000..89c49471d24ef --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [typeRegistry](./kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md) + +## SavedObjectsImportOptions.typeRegistry property + +The registry of all known saved object types + +Signature: + +```typescript +typeRegistry: ISavedObjectTypeRegistry; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md index 641934d43eddf..52d39d981d0c2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md @@ -19,4 +19,5 @@ export interface SavedObjectsImportResponse | [errors](./kibana-plugin-core-server.savedobjectsimportresponse.errors.md) | SavedObjectsImportError[] | | | [success](./kibana-plugin-core-server.savedobjectsimportresponse.success.md) | boolean | | | [successCount](./kibana-plugin-core-server.savedobjectsimportresponse.successcount.md) | number | | +| [successResults](./kibana-plugin-core-server.savedobjectsimportresponse.successresults.md) | SavedObjectsImportSuccess[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md new file mode 100644 index 0000000000000..63951d3a0b25f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportResponse](./kibana-plugin-core-server.savedobjectsimportresponse.md) > [successResults](./kibana-plugin-core-server.savedobjectsimportresponse.successresults.md) + +## SavedObjectsImportResponse.successResults property + +Signature: + +```typescript +successResults?: SavedObjectsImportSuccess[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md new file mode 100644 index 0000000000000..e9cc92c55ded1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [createNewCopy](./kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md) + +## SavedObjectsImportRetry.createNewCopy property + +If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + +Signature: + +```typescript +createNewCopy?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md new file mode 100644 index 0000000000000..9a3ccf4442db7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportretry.destinationid.md) + +## SavedObjectsImportRetry.destinationId property + +The object ID that will be created or overwritten. If not specified, the `id` field will be used. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md new file mode 100644 index 0000000000000..a23bec3c5341f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [ignoreMissingReferences](./kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md) + +## SavedObjectsImportRetry.ignoreMissingReferences property + +If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + +Signature: + +```typescript +ignoreMissingReferences?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md index 64d8164a1c4a5..70693e6f43a39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md @@ -16,7 +16,10 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-server.savedobjectsimportretry.id.md) | string | | +| [ignoreMissingReferences](./kibana-plugin-core-server.savedobjectsimportretry.ignoremissingreferences.md) | boolean | If ignoreMissingReferences is specified, reference validation will be skipped for this object. | | [overwrite](./kibana-plugin-core-server.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-server.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | | [type](./kibana-plugin-core-server.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md new file mode 100644 index 0000000000000..66b7a268f2ed5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [createNewCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md) + +## SavedObjectsImportSuccess.createNewCopy property + +> Warning: This API is now obsolete. +> +> If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +createNewCopy?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md new file mode 100644 index 0000000000000..c5acc51c3ec99 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md) + +## SavedObjectsImportSuccess.destinationId property + +If `destinationId` is specified, the new object has a new ID that is different from the import ID. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md new file mode 100644 index 0000000000000..5b95f7f64bfac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [id](./kibana-plugin-core-server.savedobjectsimportsuccess.id.md) + +## SavedObjectsImportSuccess.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md new file mode 100644 index 0000000000000..18a226f636b1d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) + +## SavedObjectsImportSuccess interface + +Represents a successful import. + +Signature: + +```typescript +export interface SavedObjectsImportSuccess +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md) | boolean | | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | +| [id](./kibana-plugin-core-server.savedobjectsimportsuccess.id.md) | string | | +| [meta](./kibana-plugin-core-server.savedobjectsimportsuccess.meta.md) | {
title?: string;
icon?: string;
} | | +| [overwrite](./kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md) | boolean | If overwrite is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). | +| [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md new file mode 100644 index 0000000000000..de6057b4729ec --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.meta.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [meta](./kibana-plugin-core-server.savedobjectsimportsuccess.meta.md) + +## SavedObjectsImportSuccess.meta property + +Signature: + +```typescript +meta: { + title?: string; + icon?: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md new file mode 100644 index 0000000000000..80cb659ef2cd2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [overwrite](./kibana-plugin-core-server.savedobjectsimportsuccess.overwrite.md) + +## SavedObjectsImportSuccess.overwrite property + +If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + +Signature: + +```typescript +overwrite?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md new file mode 100644 index 0000000000000..e6aa894cd0af9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) + +## SavedObjectsImportSuccess.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 173b9e19321d0..9075a780bd2c7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -6,8 +6,6 @@ The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. -Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). - Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md new file mode 100644 index 0000000000000..6e44bd704d6a7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [checkConflicts](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) + +## SavedObjectsRepository.checkConflicts() method + +Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + +Signature: + +```typescript +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCheckConflictsObject[] | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index 6c41441302c0b..1b562263145da 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,14 +7,14 @@ Signature: ```typescript -find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | +| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md index 6b02cd910cdb1..f3a2ee38cbdbd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md @@ -9,14 +9,7 @@ Increases a counter field by one. Creates the document if one doesn't exist for Signature: ```typescript -incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>; +incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; ``` ## Parameters @@ -30,14 +23,7 @@ incrementCounter(type: string, id: string, counterFieldName: string, options?: S Returns: -`Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>` +`Promise` {promise} diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 5b02707a3c0f4..14d3741425987 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -19,11 +19,12 @@ export declare class SavedObjectsRepository | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | +| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | -| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md new file mode 100644 index 0000000000000..82831eae37d7b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [createNewCopies](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md) + +## SavedObjectsResolveImportErrorsOptions.createNewCopies property + +If true, will create new copies of import objects, each with a random `id` and undefined `originId`. + +Signature: + +```typescript +createNewCopies: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md index c701b0a6d9bf7..f97bf284375d1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md @@ -16,10 +16,11 @@ export interface SavedObjectsResolveImportErrorsOptions | Property | Type | Description | | --- | --- | --- | +| [createNewCopies](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md) | boolean | If true, will create new copies of import objects, each with a random id and undefined originId. | | [namespace](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.namespace.md) | string | if specified, will import in given namespace | | [objectLimit](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.objectlimit.md) | number | The maximum number of object to import | | [readStream](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.readstream.md) | Readable | The stream of [saved objects](./kibana-plugin-core-server.savedobject.md) to resolve errors from | | [retries](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.retries.md) | SavedObjectsImportRetry[] | saved object import references to retry | | [savedObjectsClient](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md) | SavedObjectsClientContract | client to use to perform the import operation | -| [supportedTypes](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) | string[] | the list of allowed types to import | +| [typeRegistry](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md) | ISavedObjectTypeRegistry | The registry of all known saved object types | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md similarity index 54% rename from docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md rename to docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md index f5b7c3692b017..f06d3eb08c0ac 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [supportedTypes](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [typeRegistry](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md) -## SavedObjectsResolveImportErrorsOptions.supportedTypes property +## SavedObjectsResolveImportErrorsOptions.typeRegistry property -the list of allowed types to import +The registry of all known saved object types Signature: ```typescript -supportedTypes: string[]; +typeRegistry: ISavedObjectTypeRegistry; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md b/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md deleted file mode 100644 index f33176a32954d..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) > [getInstanceUuid](./kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md) - -## UuidServiceSetup.getInstanceUuid() method - -Retrieve the Kibana instance uuid. - -Signature: - -```typescript -getInstanceUuid(): string; -``` -Returns: - -`string` - diff --git a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md deleted file mode 100644 index 99ce4cb08af47..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) - -## UuidServiceSetup interface - -APIs to access the application's instance uuid. - -Signature: - -```typescript -export interface UuidServiceSetup -``` - -## Methods - -| Method | Description | -| --- | --- | -| [getInstanceUuid()](./kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md) | Retrieve the Kibana instance uuid. | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern._constructor_.md index 0268846772f2c..2e078e3404fe6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `IndexPattern` class Signature: ```typescript -constructor(id: string | undefined, { getConfig, savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, uiSettingsValues, }: IndexPatternDeps); +constructor(id: string | undefined, { savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, shortDotsEnable, metaFields, }: IndexPatternDeps); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(id: string | undefined, { getConfig, savedObjectsClient, apiClient, | Parameter | Type | Description | | --- | --- | --- | | id | string | undefined | | -| { getConfig, savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, uiSettingsValues, } | IndexPatternDeps | | +| { savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, shortDotsEnable, metaFields, } | IndexPatternDeps | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index d340aaeeef25e..649f8ef077e3f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -14,7 +14,7 @@ export declare class IndexPattern implements IIndexPattern | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(id, { getConfig, savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, uiSettingsValues, })](./kibana-plugin-plugins-data-public.indexpattern._constructor_.md) | | Constructs a new instance of the IndexPattern class | +| [(constructor)(id, { savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, shortDotsEnable, metaFields, })](./kibana-plugin-plugins-data-public.indexpattern._constructor_.md) | | Constructs a new instance of the IndexPattern class | ## Properties diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md index 18fca3d2c8a66..139c5794f0146 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md @@ -8,6 +8,7 @@ ```typescript setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { + __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; @@ -25,6 +26,7 @@ setup(core: CoreSetup, { expressio Returns: `{ + __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; diff --git a/examples/alerting_example/tsconfig.json b/examples/alerting_example/tsconfig.json index fbcec9de439bd..09c130aca4642 100644 --- a/examples/alerting_example/tsconfig.json +++ b/examples/alerting_example/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target" }, diff --git a/examples/bfetch_explorer/tsconfig.json b/examples/bfetch_explorer/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/examples/bfetch_explorer/tsconfig.json +++ b/examples/bfetch_explorer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/dashboard_embeddable_examples/tsconfig.json b/examples/dashboard_embeddable_examples/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/examples/dashboard_embeddable_examples/tsconfig.json +++ b/examples/dashboard_embeddable_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/developer_examples/tsconfig.json b/examples/developer_examples/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/examples/developer_examples/tsconfig.json +++ b/examples/developer_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index 33876ab24414e..b033fe86cd1c7 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -60,7 +60,8 @@ function getHasMatch(search?: string, savedAttributes?: BookSavedObjectAttribute ); } -export class BookEmbeddable extends Embeddable +export class BookEmbeddable + extends Embeddable implements ReferenceOrValueEmbeddable { public readonly type = BOOK_EMBEDDABLE; private subscription: Subscription; diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index b31d69696598e..5b14dc85b1fc7 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -65,8 +65,8 @@ export const createEditBookAction = (getStartServices: () => Promise { const newInput = await attributeService.wrapAttributes(attributes, useRefType, embeddable); if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) { - // Remove the savedObejctId when un-linking - newInput.savedObjectId = null; + // Set the saved object ID to null so that update input will remove the existing savedObjectId... + (newInput as BookByValueInput & { savedObjectId: unknown }).savedObjectId = null; } embeddable.updateInput(newInput); if (useRefType) { diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json index 7fa03739119b4..caeed2c1a434f 100644 --- a/examples/embeddable_examples/tsconfig.json +++ b/examples/embeddable_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/embeddable_explorer/public/list_container_example.tsx b/examples/embeddable_explorer/public/list_container_example.tsx index b9bd825ed0240..d9d9c49249ab3 100644 --- a/examples/embeddable_explorer/public/list_container_example.tsx +++ b/examples/embeddable_explorer/public/list_container_example.tsx @@ -29,11 +29,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { - EmbeddableInput, - EmbeddableRenderer, - ViewMode, -} from '../../../src/plugins/embeddable/public'; +import { EmbeddableRenderer, ViewMode } from '../../../src/plugins/embeddable/public'; import { HELLO_WORLD_EMBEDDABLE, MULTI_TASK_TODO_EMBEDDABLE, @@ -41,6 +37,9 @@ import { ListContainerFactory, SearchableListContainerFactory, } from '../../embeddable_examples/public'; +import { SearchableContainerInput } from '../../embeddable_examples/public/searchable_list_container/searchable_list_container'; +import { TodoInput } from '../../embeddable_examples/public/todo'; +import { MultiTaskTodoInput } from '../../embeddable_examples/public/multi_task_todo'; interface Props { listContainerEmbeddableFactory: ListContainerFactory; @@ -51,7 +50,7 @@ export function ListContainerExample({ listContainerEmbeddableFactory, searchableListContainerEmbeddableFactory, }: Props) { - const listInput: EmbeddableInput = { + const listInput: SearchableContainerInput = { id: 'hello', title: 'My todo list', viewMode: ViewMode.VIEW, @@ -69,7 +68,7 @@ export function ListContainerExample({ task: 'Goes out on Wednesdays!', icon: 'broom', title: 'Take out the trash', - }, + } as TodoInput, }, '3': { type: TODO_EMBEDDABLE, @@ -77,12 +76,12 @@ export function ListContainerExample({ id: '3', icon: 'broom', title: 'Vaccum the floor', - }, + } as TodoInput, }, }, }; - const searchableInput: EmbeddableInput = { + const searchableInput: SearchableContainerInput = { id: '1', title: 'My searchable todo list', viewMode: ViewMode.VIEW, @@ -101,7 +100,7 @@ export function ListContainerExample({ task: 'Goes out on Wednesdays!', icon: 'broom', title: 'Take out the trash', - }, + } as TodoInput, }, '3': { type: MULTI_TASK_TODO_EMBEDDABLE, @@ -110,7 +109,7 @@ export function ListContainerExample({ icon: 'searchProfilerApp', title: 'Learn more', tasks: ['Go to school', 'Watch planet earth', 'Read the encyclopedia'], - }, + } as MultiTaskTodoInput, }, }, }; diff --git a/examples/embeddable_explorer/tsconfig.json b/examples/embeddable_explorer/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/examples/embeddable_explorer/tsconfig.json +++ b/examples/embeddable_explorer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/routing_example/tsconfig.json b/examples/routing_example/tsconfig.json index 9bbd9021b2e0a..761a5c4da65ba 100644 --- a/examples/routing_example/tsconfig.json +++ b/examples/routing_example/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/search_examples/tsconfig.json b/examples/search_examples/tsconfig.json index 8a3ced743d0fa..8bec69ca40ccc 100644 --- a/examples/search_examples/tsconfig.json +++ b/examples/search_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/state_containers_examples/tsconfig.json b/examples/state_containers_examples/tsconfig.json index 3f43072c2aade..007322e2d9525 100644 --- a/examples/state_containers_examples/tsconfig.json +++ b/examples/state_containers_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/ui_action_examples/tsconfig.json b/examples/ui_action_examples/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/examples/ui_action_examples/tsconfig.json +++ b/examples/ui_action_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/ui_actions_explorer/tsconfig.json b/examples/ui_actions_explorer/tsconfig.json index 199fbe1fcfa26..119209114a7bb 100644 --- a/examples/ui_actions_explorer/tsconfig.json +++ b/examples/ui_actions_explorer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/url_generators_examples/tsconfig.json b/examples/url_generators_examples/tsconfig.json index 091130487791b..327b4642a8e7f 100644 --- a/examples/url_generators_examples/tsconfig.json +++ b/examples/url_generators_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/examples/url_generators_explorer/tsconfig.json b/examples/url_generators_explorer/tsconfig.json index 091130487791b..327b4642a8e7f 100644 --- a/examples/url_generators_explorer/tsconfig.json +++ b/examples/url_generators_explorer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/package.json b/package.json index 156563d93e08f..a06f5bf579d62 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "kbn:watch": "node scripts/kibana --dev --logging.json=false", "build:types": "tsc --p tsconfig.types.json", "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", - "kbn:bootstrap": "node scripts/register_git_hook", + "kbn:bootstrap": "yarn build:types && node scripts/register_git_hook", "spec_to_console": "node scripts/spec_to_console", "backport-skip-ci": "backport --prDescription \"[skip-ci]\"", "storybook": "node scripts/storybook", @@ -123,18 +123,13 @@ "dependencies": { "@babel/core": "^7.11.1", "@babel/register": "^7.10.5", - "@elastic/apm-rum": "^5.5.0", - "@elastic/charts": "19.8.1", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.0-rc.2", - "@elastic/ems-client": "7.9.3", "@elastic/eui": "27.4.1", - "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", "@elastic/request-crypto": "1.1.4", "@elastic/safer-lodash-set": "0.0.0", - "@elastic/ui-ace": "0.2.3", "@hapi/good-squeeze": "5.2.1", "@hapi/wreck": "^15.0.2", "@kbn/analytics": "1.0.0", @@ -151,35 +146,24 @@ "abortcontroller-polyfill": "^1.4.0", "accept": "3.0.2", "angular": "^1.8.0", - "angular-aria": "^1.8.0", "angular-elastic": "^2.5.1", - "angular-recursion": "^1.0.5", - "angular-route": "^1.8.0", "angular-sanitize": "^1.8.0", - "angular-sortable-view": "^0.0.17", "bluebird": "3.5.5", "boom": "^7.2.0", - "brace": "0.11.1", "chalk": "^2.4.2", "check-disk-space": "^2.1.0", "chokidar": "3.2.1", "color": "1.0.3", "commander": "3.0.2", - "compare-versions": "3.5.1", "core-js": "^3.6.4", - "d3": "3.5.17", - "d3-cloud": "1.2.5", "deep-freeze-strict": "^1.1.1", - "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.7.0", "elasticsearch": "^16.7.0", - "elasticsearch-browser": "^16.7.0", "execa": "^4.0.2", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", "font-awesome": "4.7.0", - "fp-ts": "^2.3.1", "getos": "^3.1.0", "glob": "^7.1.2", "glob-all": "^3.2.1", @@ -188,67 +172,41 @@ "handlebars": "4.7.6", "hapi": "^17.5.3", "hapi-auth-cookie": "^9.0.0", - "history": "^4.9.0", - "hjson": "3.2.1", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", - "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", "joi": "^13.5.2", - "jquery": "^3.5.0", - "js-levenshtein": "^1.1.6", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", - "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", - "leaflet": "1.5.1", - "leaflet-draw": "0.4.14", - "leaflet-responsive-popup": "0.6.4", - "leaflet-vega": "^0.8.6", - "leaflet.heat": "0.2.0", - "less": "npm:@elastic/less@2.7.3-kibana", "lodash": "^4.17.20", "lru-cache": "4.1.5", - "markdown-it": "^10.0.0", "minimatch": "^3.0.4", "moment": "^2.24.0", "moment-timezone": "^0.5.27", - "monaco-editor": "~0.17.0", "mustache": "2.3.2", - "ngreact": "0.5.1", "node-fetch": "1.7.3", "node-forge": "^0.9.1", "opn": "^5.5.0", "oppsy": "^2.0.0", + "p-map": "^4.0.0", "pegjs": "0.10.0", - "prop-types": "15.6.0", "proxy-from-env": "1.0.0", "query-string": "5.1.1", "re2": "^1.15.4", "react": "^16.12.0", "react-color": "^2.13.8", "react-dom": "^16.12.0", - "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", - "react-markdown": "^4.3.1", - "react-monaco-editor": "~0.27.0", - "react-redux": "^7.2.0", - "react-resize-detector": "^4.2.0", "react-router": "^5.2.0", - "react-router-dom": "^5.2.0", - "react-sizeme": "^2.3.6", "react-use": "^13.27.0", - "reactcss": "1.2.3", - "redux": "^4.0.5", "redux-actions": "^2.6.5", "redux-thunk": "^2.3.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", - "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", "rison-node": "1.0.2", "rxjs": "^6.5.5", "seedrandom": "^3.0.5", @@ -258,15 +216,9 @@ "tar": "4.4.13", "tinygradient": "0.4.3", "tinymath": "1.2.1", - "topojson-client": "3.0.0", "tslib": "^2.0.0", "type-detect": "^4.0.8", - "ui-select": "0.19.8", "uuid": "3.3.2", - "vega": "^5.13.0", - "vega-lite": "^4.13.1", - "vega-schema-url-parser": "^1.1.0", - "vega-tooltip": "^0.12.0", "vision": "^5.3.3", "whatwg-fetch": "^3.0.0", "yauzl": "2.10.0" @@ -274,10 +226,15 @@ "devDependencies": { "@babel/parser": "^7.11.2", "@babel/types": "^7.11.0", + "@elastic/apm-rum": "^5.5.0", + "@elastic/charts": "19.8.1", + "@elastic/ems-client": "7.9.3", "@elastic/eslint-config-kibana": "0.15.0", "@elastic/eslint-plugin-eui": "0.0.2", + "@elastic/filesaver": "1.1.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", + "@elastic/ui-ace": "0.2.3", "@kbn/dev-utils": "1.0.0", "@kbn/es": "1.0.0", "@kbn/es-archiver": "1.0.0", @@ -383,20 +340,30 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", + "angular-aria": "^1.8.0", "angular-mocks": "^1.7.9", + "angular-recursion": "^1.0.5", + "angular-route": "^1.8.0", + "angular-sortable-view": "^0.0.17", "archiver": "^3.1.1", "axe-core": "^3.4.1", "babel-eslint": "^10.0.3", "babel-jest": "^25.5.1", "babel-plugin-istanbul": "^6.0.0", "backport": "5.5.1", + "brace": "0.11.1", "chai": "3.5.0", "chance": "1.0.18", "cheerio": "0.22.0", "chromedriver": "^84.0.0", "classnames": "2.2.6", + "compare-versions": "3.5.1", + "d3": "3.5.17", + "d3-cloud": "1.2.5", "dedent": "^0.7.0", + "deepmerge": "^4.2.2", "delete-empty": "^2.0.0", + "elasticsearch-browser": "^16.7.0", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-utils": "^1.13.0", @@ -414,13 +381,14 @@ "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-node": "^11.0.0", "eslint-plugin-prefer-object-spread": "^1.2.1", - "eslint-plugin-prettier": "^3.1.3", + "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^4.0.4", "eslint-plugin-react-perf": "^3.2.3", "exit-hook": "^2.2.0", "faker": "1.1.0", "fetch-mock": "^7.3.9", + "fp-ts": "^2.3.1", "geckodriver": "^1.20.0", "getopts": "^2.2.4", "grunt": "1.0.4", @@ -432,7 +400,10 @@ "gulp-babel": "^8.0.0", "gulp-sourcemaps": "2.6.5", "has-ansi": "^3.0.0", + "history": "^4.9.0", + "hjson": "3.2.1", "iedriver": "^3.14.2", + "immer": "^1.5.0", "intl-messageformat-parser": "^1.4.0", "jest": "^25.5.4", "jest-canvas-mock": "^2.2.0", @@ -441,18 +412,30 @@ "jest-environment-jsdom-thirteen": "^1.0.1", "jest-raw-loader": "^1.0.1", "jimp": "^0.14.0", + "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", + "json-stringify-pretty-compact": "1.2.0", "json5": "^1.0.1", + "leaflet": "1.5.1", + "leaflet-draw": "0.4.14", + "leaflet-responsive-popup": "0.6.4", + "leaflet-vega": "^0.8.6", + "leaflet.heat": "0.2.0", + "less": "npm:@elastic/less@2.7.3-kibana", "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", "load-json-file": "^6.2.0", + "markdown-it": "^10.0.0", "mocha": "^7.1.1", "mock-fs": "^4.12.0", "mock-http-server": "1.3.0", + "monaco-editor": "~0.17.0", "ms-chromium-edge-driver": "^0.2.3", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", + "ngreact": "0.5.1", "nock": "12.0.3", "normalize-path": "^3.0.0", "nyc": "^15.0.1", @@ -460,10 +443,22 @@ "pkg-up": "^2.0.0", "pngjs": "^3.4.0", "postcss": "^7.0.32", - "prettier": "^2.0.5", + "prettier": "^2.1.1", + "prop-types": "15.6.0", "proxyquire": "1.8.0", + "react-grid-layout": "^0.16.2", + "react-markdown": "^4.3.1", + "react-monaco-editor": "~0.27.0", "react-popper-tooltip": "^2.10.1", + "react-redux": "^7.2.0", + "react-resize-detector": "^4.2.0", + "react-router-dom": "^5.2.0", + "react-sizeme": "^2.3.6", + "reactcss": "1.2.3", + "redux": "^4.0.5", "regenerate": "^1.4.0", + "reselect": "^4.0.0", + "resize-observer-polyfill": "^1.5.0", "sass-lint": "^1.12.1", "selenium-webdriver": "^4.0.0-alpha.7", "simple-git": "1.116.0", @@ -472,9 +467,15 @@ "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", "tape": "^4.13.0", + "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "typescript": "4.0.2", "typings-tester": "^0.3.2", + "ui-select": "0.19.8", + "vega": "^5.13.0", + "vega-lite": "^4.13.1", + "vega-schema-url-parser": "^1.1.0", + "vega-tooltip": "^0.12.0", "vinyl-fs": "^3.0.3", "xml2js": "^0.4.22", "xmlbuilder": "13.0.2", diff --git a/packages/elastic-datemath/tsconfig.json b/packages/elastic-datemath/tsconfig.json index 3604f1004cf6c..8d1c263609c2e 100644 --- a/packages/elastic-datemath/tsconfig.json +++ b/packages/elastic-datemath/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "index.d.ts" ], diff --git a/packages/elastic-safer-lodash-set/tsconfig.json b/packages/elastic-safer-lodash-set/tsconfig.json index bc1d1a3a7e413..d392bd337195c 100644 --- a/packages/elastic-safer-lodash-set/tsconfig.json +++ b/packages/elastic-safer-lodash-set/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "**/*" ], diff --git a/packages/eslint-config-kibana/package.json b/packages/eslint-config-kibana/package.json index 2838faaae7ded..4ec3bcdfd7c05 100644 --- a/packages/eslint-config-kibana/package.json +++ b/packages/eslint-config-kibana/package.json @@ -16,7 +16,7 @@ "homepage": "https://github.com/elastic/kibana/tree/master/packages/eslint-config-kibana", "peerDependencies": { "@typescript-eslint/eslint-plugin": "^3.10.0", - "@typescript-eslint/parser": "^3.10.0s", + "@typescript-eslint/parser": "^3.10.0", "babel-eslint": "^10.0.3", "eslint": "^6.8.0", "eslint-plugin-babel": "^5.3.0", diff --git a/packages/kbn-analytics/tsconfig.json b/packages/kbn-analytics/tsconfig.json index fdd9e8281fba8..f28f860b9993c 100644 --- a/packages/kbn-analytics/tsconfig.json +++ b/packages/kbn-analytics/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "declaration": true, "declarationDir": "./target/types", diff --git a/packages/kbn-config-schema/tsconfig.json b/packages/kbn-config-schema/tsconfig.json index f6c61268da17c..6a268f2e7c016 100644 --- a/packages/kbn-config-schema/tsconfig.json +++ b/packages/kbn-config-schema/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "declaration": true, "declarationDir": "./target/types", diff --git a/packages/kbn-dev-utils/tsconfig.json b/packages/kbn-dev-utils/tsconfig.json index 0ec058eeb8a28..1c6c671d0b768 100644 --- a/packages/kbn-dev-utils/tsconfig.json +++ b/packages/kbn-dev-utils/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "target", "target": "ES2019", diff --git a/packages/kbn-es-archiver/tsconfig.json b/packages/kbn-es-archiver/tsconfig.json index 6ffa64d91fba0..02209a29e5817 100644 --- a/packages/kbn-es-archiver/tsconfig.json +++ b/packages/kbn-es-archiver/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "declaration": true, diff --git a/packages/kbn-es/tsconfig.json b/packages/kbn-es/tsconfig.json index 6bb61453c99e7..89a0d93c2ddd5 100644 --- a/packages/kbn-es/tsconfig.json +++ b/packages/kbn-es/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "src/**/*.ts" ] diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index a09ae2d7ae641..6d1e9470ac874 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "expect.js.d.ts" ] diff --git a/packages/kbn-i18n/tsconfig.json b/packages/kbn-i18n/tsconfig.json index d3dae3078c1d7..303c29faf1258 100644 --- a/packages/kbn-i18n/tsconfig.json +++ b/packages/kbn-i18n/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "src/**/*.ts", "src/**/*.tsx", diff --git a/packages/kbn-interpreter/tsconfig.json b/packages/kbn-interpreter/tsconfig.json index 63376a7ca1ae8..6465c528beda3 100644 --- a/packages/kbn-interpreter/tsconfig.json +++ b/packages/kbn-interpreter/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": ["index.d.ts", "src/**/*.d.ts"] } diff --git a/packages/kbn-monaco/tsconfig.json b/packages/kbn-monaco/tsconfig.json index 95acfd32b24dd..6d3f433c6a6d1 100644 --- a/packages/kbn-monaco/tsconfig.json +++ b/packages/kbn-monaco/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "declaration": true, diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index 740555fd87897..b80d1365659dd 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -14,10 +14,6 @@ "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", - "@types/compression-webpack-plugin": "^2.0.2", - "@types/loader-utils": "^1.1.3", - "@types/watchpack": "^1.1.5", - "@types/webpack": "^4.41.3", "autoprefixer": "^9.7.4", "babel-loader": "^8.0.6", "clean-webpack-plugin": "^3.0.0", @@ -46,5 +42,11 @@ "watchpack": "^1.6.0", "webpack": "^4.41.5", "webpack-merge": "^4.2.2" + }, + "devDependencies": { + "@types/compression-webpack-plugin": "^2.0.2", + "@types/loader-utils": "^1.1.3", + "@types/watchpack": "^1.1.5", + "@types/webpack": "^4.41.3" } } diff --git a/packages/kbn-optimizer/tsconfig.json b/packages/kbn-optimizer/tsconfig.json index e2994f4d02414..ea16da6ff7a8e 100644 --- a/packages/kbn-optimizer/tsconfig.json +++ b/packages/kbn-optimizer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "index.d.ts", "src/**/*" diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index 89e4251bd7802..1d9637c8279da 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -14,7 +14,7 @@ "execa": "^4.0.2", "inquirer": "^7.3.3", "normalize-path": "^3.0.0", - "prettier": "^2.0.5", + "prettier": "^2.1.1", "vinyl": "^2.2.0", "vinyl-fs": "^3.0.3" }, diff --git a/packages/kbn-plugin-generator/tsconfig.json b/packages/kbn-plugin-generator/tsconfig.json index fc88223dae4b2..c54ff041d7065 100644 --- a/packages/kbn-plugin-generator/tsconfig.json +++ b/packages/kbn-plugin-generator/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "target", "target": "ES2019", diff --git a/packages/kbn-plugin-helpers/tsconfig.json b/packages/kbn-plugin-helpers/tsconfig.json index d0dbe1e44f0fa..39722e303776d 100644 --- a/packages/kbn-plugin-helpers/tsconfig.json +++ b/packages/kbn-plugin-helpers/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "target", "declaration": true, diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index 840d80e36eaf7..914ca2fd65fa2 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -51,7 +51,7 @@ "multimatch": "^4.0.0", "ncp": "^2.0.0", "ora": "^1.4.0", - "prettier": "^2.0.5", + "prettier": "^2.1.1", "read-pkg": "^5.2.0", "rxjs": "^6.5.5", "spawn-sync": "^1.0.15", diff --git a/packages/kbn-pm/tsconfig.json b/packages/kbn-pm/tsconfig.json index c13a9243c50aa..fd5ace23e97c7 100644 --- a/packages/kbn-pm/tsconfig.json +++ b/packages/kbn-pm/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "./index.d.ts", "./src/**/*.ts", diff --git a/packages/kbn-release-notes/tsconfig.json b/packages/kbn-release-notes/tsconfig.json index 6ffa64d91fba0..02209a29e5817 100644 --- a/packages/kbn-release-notes/tsconfig.json +++ b/packages/kbn-release-notes/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "declaration": true, diff --git a/packages/kbn-spec-to-console/package.json b/packages/kbn-spec-to-console/package.json index 5423bc3fd4ecd..63ce93ac54f46 100644 --- a/packages/kbn-spec-to-console/package.json +++ b/packages/kbn-spec-to-console/package.json @@ -18,7 +18,7 @@ "homepage": "https://github.com/jbudz/spec-to-console#readme", "devDependencies": { "jest": "^25.5.4", - "prettier": "^2.0.5" + "prettier": "^2.1.1" }, "dependencies": { "commander": "^3.0.0", diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json index e87699825b4e1..2e69d3625d7ff 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json @@ -5,6 +5,22 @@ "flat": { "type": "keyword" }, + "my_index_signature_prop": { + "properties": { + "avg": { + "type": "number" + }, + "count": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + } + } + }, "my_str": { "type": "text" }, diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts new file mode 100644 index 0000000000000..83866a2b6afec --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.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. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedIndexedInterfaceWithNoMatchingSchema: ParsedUsageCollection = [ + 'src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts', + { + collectorName: 'indexed_interface_with_not_matching_schema', + schema: { + value: { + something: { + count_1: { + type: 'number', + }, + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + '': { + '@@INDEX@@': { + count_1: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + count_2: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts index 803bc7f13f59e..b238c5aa346ad 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts @@ -32,6 +32,20 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ my_str: { type: 'text', }, + my_index_signature_prop: { + avg: { + type: 'number', + }, + count: { + type: 'number', + }, + max: { + type: 'number', + }, + min: { + type: 'number', + }, + }, my_objects: { total: { type: 'number', @@ -60,6 +74,14 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ kind: SyntaxKind.StringKeyword, type: 'StringKeyword', }, + my_index_signature_prop: { + '': { + '@@INDEX@@': { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + }, + }, my_objects: { total: { kind: SyntaxKind.NumberKeyword, diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap index 4e86a3cf6d4a4..68b068b0cfe06 100644 --- a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap +++ b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap @@ -90,6 +90,38 @@ Array [ }, }, ], + Array [ + "src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts", + Object { + "collectorName": "indexed_interface_with_not_matching_schema", + "fetch": Object { + "typeDescriptor": Object { + "": Object { + "@@INDEX@@": Object { + "count_1": Object { + "kind": 143, + "type": "NumberKeyword", + }, + "count_2": Object { + "kind": 143, + "type": "NumberKeyword", + }, + }, + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "something": Object { + "count_1": Object { + "type": "long", + }, + }, + }, + }, + }, + ], Array [ "src/fixtures/telemetry_collectors/nested_collector.ts", Object { @@ -132,6 +164,14 @@ Array [ "type": "BooleanKeyword", }, }, + "my_index_signature_prop": Object { + "": Object { + "@@INDEX@@": Object { + "kind": 143, + "type": "NumberKeyword", + }, + }, + }, "my_objects": Object { "total": Object { "kind": 143, @@ -166,6 +206,20 @@ Array [ "type": "boolean", }, }, + "my_index_signature_prop": Object { + "avg": Object { + "type": "number", + }, + "count": Object { + "type": "number", + }, + "max": Object { + "type": "number", + }, + "min": Object { + "type": "number", + }, + }, "my_objects": Object { "total": Object { "type": "number", diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts index dbdda3f38afd5..a101210185a63 100644 --- a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts @@ -20,6 +20,7 @@ import { cloneDeep } from 'lodash'; import * as ts from 'typescript'; import { parsedWorkingCollector } from './__fixture__/parsed_working_collector'; +import { parsedIndexedInterfaceWithNoMatchingSchema } from './__fixture__/parsed_indexed_interface_with_not_matching_schema'; import { checkCompatibleTypeDescriptor, checkMatchingMapping } from './check_collector_integrity'; import * as path from 'path'; import { readFile } from 'fs'; @@ -82,6 +83,20 @@ describe('checkCompatibleTypeDescriptor', () => { expect(incompatibles).toHaveLength(0); }); + it('returns diff on indexed interface with no matching schema', () => { + const incompatibles = checkCompatibleTypeDescriptor([ + parsedIndexedInterfaceWithNoMatchingSchema, + ]); + expect(incompatibles).toHaveLength(1); + const { diff, message } = incompatibles[0]; + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(diff).toEqual({ '.@@INDEX@@.count_2.kind': 'number' }); + expect(message).toHaveLength(1); + expect(message).toEqual([ + 'incompatible Type key (Usage..@@INDEX@@.count_2): expected (undefined) got ("number").', + ]); + }); + describe('Interface Change', () => { it('returns diff on incompatible type descriptor with mapping', () => { const malformedParsedCollector = cloneDeep(parsedWorkingCollector); diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts index 1b4ed21a1635c..0517cb9034d0a 100644 --- a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts @@ -34,7 +34,7 @@ describe('extractCollectors', () => { const programPaths = await getProgramPaths(configs[0]); const results = [...extractCollectors(programPaths, tsConfig)]; - expect(results).toHaveLength(6); + expect(results).toHaveLength(7); expect(results).toMatchSnapshot(); }); }); diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.ts b/packages/kbn-telemetry-tools/src/tools/serializer.ts index 2fcbad1e68380..d5412f64f3615 100644 --- a/packages/kbn-telemetry-tools/src/tools/serializer.ts +++ b/packages/kbn-telemetry-tools/src/tools/serializer.ts @@ -84,6 +84,11 @@ export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor | }, {} as any); } + // If it's defined as signature { [key: string]: OtherInterface } + if (ts.isIndexSignatureDeclaration(node) && node.type) { + return { '@@INDEX@@': getDescriptor(node.type, program) }; + } + if (ts.SyntaxKind.FirstNode === node.kind) { return getDescriptor((node as any).right, program); } diff --git a/packages/kbn-telemetry-tools/src/tools/utils.ts b/packages/kbn-telemetry-tools/src/tools/utils.ts index 212b06a4c9895..c1424785b22a5 100644 --- a/packages/kbn-telemetry-tools/src/tools/utils.ts +++ b/packages/kbn-telemetry-tools/src/tools/utils.ts @@ -98,6 +98,14 @@ export function getVariableValue(node: ts.Node): string | Record { return serializeObject(node); } + if (ts.isIdentifier(node)) { + const declaration = getIdentifierDeclaration(node); + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return getVariableValue(declaration.initializer); + } + // TODO: If this is another imported value from another file, we'll need to go fetch it like in getPropertyValue + } + throw Error(`Unsuppored Node: cannot get value of node (${node.getText()}) of kind ${node.kind}`); } @@ -112,10 +120,11 @@ export function serializeObject(node: ts.Node) { if (typeof propertyName === 'undefined') { throw new Error(`Unable to get property name ${property.getText()}`); } + const cleanPropertyName = propertyName.replace(/["']/g, ''); if (ts.isPropertyAssignment(property)) { - value[propertyName] = getVariableValue(property.initializer); + value[cleanPropertyName] = getVariableValue(property.initializer); } else { - value[propertyName] = getVariableValue(property); + value[cleanPropertyName] = getVariableValue(property); } } @@ -222,9 +231,29 @@ export const flattenKeys = (obj: any, keyPath: any[] = []): any => { }; export function difference(actual: any, expected: any) { - function changes(obj: any, base: any) { + function changes(obj: { [key: string]: any }, base: { [key: string]: any }) { return transform(obj, function (result, value, key) { - if (key && !isEqual(value, base[key])) { + if (key && /@@INDEX@@/.test(`${key}`)) { + // The type definition is an Index Signature, fuzzy searching for similar keys + const regexp = new RegExp(`${key}`.replace(/@@INDEX@@/g, '(.+)?')); + const keysInBase = Object.keys(base) + .map((k) => { + const match = k.match(regexp); + return match && match[0]; + }) + .filter((s): s is string => !!s); + + if (keysInBase.length === 0) { + // Mark this key as wrong because we couldn't find any matching keys + result[key] = value; + } + + keysInBase.forEach((k) => { + if (!isEqual(value, base[k])) { + result[k] = isObject(value) && isObject(base[k]) ? changes(value, base[k]) : value; + } + }); + } else if (key && !isEqual(value, base[key])) { result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value; } }); diff --git a/packages/kbn-telemetry-tools/tsconfig.json b/packages/kbn-telemetry-tools/tsconfig.json index 13ce8ef2bad60..8460e2779d18a 100644 --- a/packages/kbn-telemetry-tools/tsconfig.json +++ b/packages/kbn-telemetry-tools/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "src/**/*", ] diff --git a/packages/kbn-test-subj-selector/tsconfig.json b/packages/kbn-test-subj-selector/tsconfig.json index 3604f1004cf6c..8d1c263609c2e 100644 --- a/packages/kbn-test-subj-selector/tsconfig.json +++ b/packages/kbn-test-subj-selector/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "index.d.ts" ], diff --git a/packages/kbn-test/tsconfig.json b/packages/kbn-test/tsconfig.json index fdb53de52687b..f07f79c01f5d0 100644 --- a/packages/kbn-test/tsconfig.json +++ b/packages/kbn-test/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "types/**/*", "src/**/*", diff --git a/packages/kbn-ui-shared-deps/tsconfig.json b/packages/kbn-ui-shared-deps/tsconfig.json index cef9a442d17bc..7be36f8ef5978 100644 --- a/packages/kbn-ui-shared-deps/tsconfig.json +++ b/packages/kbn-ui-shared-deps/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "include": [ "index.d.ts", "theme.ts" diff --git a/packages/kbn-utility-types/tsconfig.json b/packages/kbn-utility-types/tsconfig.json index 202df37faf561..03cace5b9cb2c 100644 --- a/packages/kbn-utility-types/tsconfig.json +++ b/packages/kbn-utility-types/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "declaration": true, "declarationDir": "./target", diff --git a/src/core/TESTING.md b/src/core/TESTING.md index a62922d9b5d64..a0fd0a6ffc255 100644 --- a/src/core/TESTING.md +++ b/src/core/TESTING.md @@ -330,7 +330,7 @@ Cons: To have access to Kibana TestUtils, you should create `integration_tests` folder and import `test_utils` within a test file: ```typescript // src/plugins/my_plugin/server/integration_tests/formatter.test.ts -import * as kbnTestServer from 'src/test_utils/kbn_server'; +import * as kbnTestServer from 'src/core/test_helpers/kbn_server'; describe('myPlugin', () => { describe('GET /myPlugin/formatter', () => { diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 8dc81dceaccd6..0150554a60906 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -405,6 +405,59 @@ describe('start', () => { `); }); }); + + describe('erase chrome fields', () => { + it('while switching an app', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha')]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const helpExtensionPromise = chrome.getHelpExtension$().pipe(toArray()).toPromise(); + const breadcrumbsPromise = chrome.getBreadcrumbs$().pipe(toArray()).toPromise(); + const badgePromise = chrome.getBadge$().pipe(toArray()).toPromise(); + const docTitleResetSpy = jest.spyOn(chrome.docTitle, 'reset'); + + const promises = Promise.all([helpExtensionPromise, breadcrumbsPromise, badgePromise]); + + chrome.setHelpExtension({ appName: 'App name' }); + chrome.setBreadcrumbs([{ text: 'App breadcrumb' }]); + chrome.setBadge({ text: 'App badge', tooltip: 'App tooltip' }); + + navigateToApp('alpha'); + + service.stop(); + + expect(docTitleResetSpy).toBeCalledTimes(1); + await expect(promises).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + undefined, + Object { + "appName": "App name", + }, + undefined, + ], + Array [ + Array [], + Array [ + Object { + "text": "App breadcrumb", + }, + ], + Array [], + ], + Array [ + undefined, + Object { + "text": "App badge", + "tooltip": "App tooltip", + }, + undefined, + ], + ] + `); + }); + }); }); describe('stop', () => { diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index d29120e6ee9ac..ef9a682d609ec 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -157,6 +157,14 @@ export class ChromeService { const recentlyAccessed = await this.recentlyAccessed.start({ http }); const docTitle = this.docTitle.start({ document: window.document }); + // erase chrome fields from a previous app while switching to a next app + application.currentAppId$.subscribe(() => { + helpExtension$.next(undefined); + breadcrumbs$.next([]); + badge$.next(undefined); + docTitle.reset(); + }); + const setIsNavDrawerLocked = (isLocked: boolean) => { isNavDrawerLocked$.next(isLocked); localStorage.setItem(IS_LOCKED_KEY, `${isLocked}`); diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 2cfe232bf5653..fe959e570ab98 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -147,7 +147,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "baseUrl": "/", "category": Object { "euiIconType": "logoSecurity", - "id": "security", + "id": "securitySolution", "label": "Security", "order": 4000, }, @@ -1393,11 +1393,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1433,7 +1433,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-security" + data-test-subj="collapsibleNavGroup-securitySolution" id="mockId" initialIsOpen={true} onToggle={[Function]} @@ -1441,7 +1441,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
- + {navType === 'modern' ? ( diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 3e4e70fb99508..9176a277b3f43 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -140,6 +140,7 @@ export { SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, + SavedObjectError, SavedObjectReference, SavedObjectsBaseOptions, SavedObjectsFindOptions, @@ -148,12 +149,15 @@ export { SavedObjectsClient, SimpleSavedObject, SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, SavedObjectsImportError, SavedObjectsImportRetry, + SavedObjectsNamespaceType, } from './saved_objects'; export { diff --git a/src/core/public/overlays/banners/_banners_list.scss b/src/core/public/overlays/banners/_banners_list.scss index ff260f7dc42fd..9d4df065a0a4f 100644 --- a/src/core/public/overlays/banners/_banners_list.scss +++ b/src/core/public/overlays/banners/_banners_list.scss @@ -3,5 +3,5 @@ } .kbnGlobalBannerList__item + .kbnGlobalBannerList__item { - margin-top: $euiSize; + margin-top: $euiSizeS; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 17626418cbeeb..6f25f46c76fb9 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1061,13 +1061,11 @@ export type PublicUiSettingsParams = Omit; export interface SavedObject { attributes: T; // (undocumented) - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; id: string; migrationVersion?: SavedObjectsMigrationVersion; namespaces?: string[]; + originId?: string; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1086,6 +1084,20 @@ export interface SavedObjectAttributes { // @public export type SavedObjectAttributeSingle = string | number | boolean | null | undefined | SavedObjectAttributes; +// Warning: (ae-missing-release-tag) "SavedObjectError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SavedObjectError { + // (undocumented) + error: string; + // (undocumented) + message: string; + // (undocumented) + metadata?: Record; + // (undocumented) + statusCode: number; +} + // @public export interface SavedObjectReference { // (undocumented) @@ -1190,6 +1202,7 @@ export interface SavedObjectsFindOptions { // (undocumented) perPage?: number; preference?: string; + rootSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -1210,8 +1223,22 @@ export interface SavedObjectsFindResponsePublic extends SavedObject total: number; } +// @public +export interface SavedObjectsImportAmbiguousConflictError { + // (undocumented) + destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; + // (undocumented) + type: 'ambiguous_conflict'; +} + // @public export interface SavedObjectsImportConflictError { + // (undocumented) + destinationId?: string; // (undocumented) type: 'conflict'; } @@ -1219,10 +1246,16 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // @deprecated (undocumented) title?: string; // (undocumented) type: string; @@ -1230,11 +1263,6 @@ export interface SavedObjectsImportError { // @public export interface SavedObjectsImportMissingReferencesError { - // (undocumented) - blocking: Array<{ - type: string; - id: string; - }>; // (undocumented) references: Array<{ type: string; @@ -1252,12 +1280,17 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + createNewCopy?: boolean; + destinationId?: string; // (undocumented) id: string; + ignoreMissingReferences?: boolean; // (undocumented) overwrite: boolean; // (undocumented) @@ -1270,6 +1303,23 @@ export interface SavedObjectsImportRetry { type: string; } +// @public +export interface SavedObjectsImportSuccess { + // @deprecated (undocumented) + createNewCopy?: boolean; + destinationId?: string; + // (undocumented) + id: string; + // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsImportUnknownError { // (undocumented) @@ -1292,6 +1342,9 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +// @public +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + // @public (undocumented) export interface SavedObjectsStart { // (undocumented) diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index 13b4a12893666..ef7b23448ad6f 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -36,12 +36,15 @@ export { SavedObjectsFindOptions, SavedObjectsMigrationVersion, SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, SavedObjectsImportError, SavedObjectsImportRetry, + SavedObjectsNamespaceType, } from '../../server/types'; export { @@ -49,5 +52,6 @@ export { SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, + SavedObjectError, SavedObjectReference, } from '../../types'; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 209f489e29139..351020004b0e7 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -31,7 +31,10 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; -type SavedObjectsFindOptions = Omit; +type SavedObjectsFindOptions = Omit< + SavedObjectFindOptionsServer, + 'namespace' | 'sortOrder' | 'rootSearchFields' +>; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/public/ui_settings/ui_settings_api.test.ts b/src/core/public/ui_settings/ui_settings_api.test.ts index 14791407d2550..b15754e5f1383 100644 --- a/src/core/public/ui_settings/ui_settings_api.test.ts +++ b/src/core/public/ui_settings/ui_settings_api.test.ts @@ -22,7 +22,7 @@ import fetchMock from 'fetch-mock/es5/client'; import * as Rx from 'rxjs'; import { takeUntil, toArray } from 'rxjs/operators'; -import { setup as httpSetup } from '../../../test_utils/public/http_test_setup'; +import { setup as httpSetup } from '../../test_helpers/http_test_setup'; import { UiSettingsApi } from './ui_settings_api'; function setup() { diff --git a/src/core/server/config/integration_tests/config_deprecation.test.ts b/src/core/server/config/integration_tests/config_deprecation.test.ts index 65f5bbdac5248..3ebf6d507a2fd 100644 --- a/src/core/server/config/integration_tests/config_deprecation.test.ts +++ b/src/core/server/config/integration_tests/config_deprecation.test.ts @@ -19,7 +19,7 @@ import { mockLoggingSystem } from './config_deprecation.test.mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; describe('configuration deprecations', () => { let root: ReturnType; diff --git a/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts b/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts index 3284be5ba4750..340f45a0a2c18 100644 --- a/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts +++ b/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; import { Root } from '../../root'; const { startES } = kbnTestServer.createTestServers({ diff --git a/src/core/server/core_app/integration_tests/static_assets.test.ts b/src/core/server/core_app/integration_tests/static_assets.test.ts index 160ef064a14d9..ca03c4228221f 100644 --- a/src/core/server/core_app/integration_tests/static_assets.test.ts +++ b/src/core/server/core_app/integration_tests/static_assets.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; import { Root } from '../../root'; describe('Platform assets', function () { diff --git a/src/core/server/environment/create_data_folder.test.ts b/src/core/server/environment/create_data_folder.test.ts new file mode 100644 index 0000000000000..2a480a7a3954f --- /dev/null +++ b/src/core/server/environment/create_data_folder.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { PathConfigType } from '../path'; +import { createDataFolder } from './create_data_folder'; +import { mkdir } from './fs'; +import { loggingSystemMock } from '../logging/logging_system.mock'; + +jest.mock('./fs', () => ({ + mkdir: jest.fn(() => Promise.resolve('')), +})); + +const mkdirMock = mkdir as jest.Mock; + +describe('createDataFolder', () => { + let logger: ReturnType; + let pathConfig: PathConfigType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + pathConfig = { + data: '/path/to/data/folder', + }; + mkdirMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls `mkdir` with the correct parameters', async () => { + await createDataFolder({ pathConfig, logger }); + expect(mkdirMock).toHaveBeenCalledTimes(1); + expect(mkdirMock).toHaveBeenCalledWith(pathConfig.data, { recursive: true }); + }); + + it('does not log error if the `mkdir` call is successful', async () => { + await createDataFolder({ pathConfig, logger }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('throws an error if the `mkdir` call fails', async () => { + mkdirMock.mockRejectedValue('some-error'); + await expect(() => createDataFolder({ pathConfig, logger })).rejects.toMatchInlineSnapshot( + `"some-error"` + ); + }); + + it('logs an error message if the `mkdir` call fails', async () => { + mkdirMock.mockRejectedValue('some-error'); + try { + await createDataFolder({ pathConfig, logger }); + } catch (e) { + /* trap */ + } + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Error trying to create data folder at /path/to/data/folder: some-error", + ] + `); + }); +}); diff --git a/src/core/server/environment/create_data_folder.ts b/src/core/server/environment/create_data_folder.ts new file mode 100644 index 0000000000000..641d95cbf9411 --- /dev/null +++ b/src/core/server/environment/create_data_folder.ts @@ -0,0 +1,40 @@ +/* + * 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 { mkdir } from './fs'; +import { Logger } from '../logging'; +import { PathConfigType } from '../path'; + +export async function createDataFolder({ + pathConfig, + logger, +}: { + pathConfig: PathConfigType; + logger: Logger; +}): Promise { + const dataFolder = pathConfig.data; + try { + // Create the data directory (recursively, if the a parent dir doesn't exist). + // If it already exists, does nothing. + await mkdir(dataFolder, { recursive: true }); + } catch (e) { + logger.error(`Error trying to create data folder at ${dataFolder}: ${e}`); + throw e; + } +} diff --git a/src/core/server/uuid/uuid_service.mock.ts b/src/core/server/environment/environment_service.mock.ts similarity index 74% rename from src/core/server/uuid/uuid_service.mock.ts rename to src/core/server/environment/environment_service.mock.ts index bf40eaee20636..8bf726b4a6388 100644 --- a/src/core/server/uuid/uuid_service.mock.ts +++ b/src/core/server/environment/environment_service.mock.ts @@ -17,25 +17,25 @@ * under the License. */ -import { UuidService, UuidServiceSetup } from './uuid_service'; +import { EnvironmentService, InternalEnvironmentServiceSetup } from './environment_service'; const createSetupContractMock = () => { - const setupContract: jest.Mocked = { - getInstanceUuid: jest.fn().mockImplementation(() => 'uuid'), + const setupContract: jest.Mocked = { + instanceUuid: 'uuid', }; return setupContract; }; -type UuidServiceContract = PublicMethodsOf; +type EnvironmentServiceContract = PublicMethodsOf; const createMock = () => { - const mocked: jest.Mocked = { + const mocked: jest.Mocked = { setup: jest.fn(), }; mocked.setup.mockResolvedValue(createSetupContractMock()); return mocked; }; -export const uuidServiceMock = { +export const environmentServiceMock = { create: createMock, createSetupContract: createSetupContractMock, }; diff --git a/src/core/server/uuid/uuid_service.test.ts b/src/core/server/environment/environment_service.test.ts similarity index 52% rename from src/core/server/uuid/uuid_service.test.ts rename to src/core/server/environment/environment_service.test.ts index 3b1087d72c677..06fd250ebe4f9 100644 --- a/src/core/server/uuid/uuid_service.test.ts +++ b/src/core/server/environment/environment_service.test.ts @@ -17,10 +17,13 @@ * under the License. */ -import { UuidService } from './uuid_service'; +import { BehaviorSubject } from 'rxjs'; +import { EnvironmentService } from './environment_service'; import { resolveInstanceUuid } from './resolve_uuid'; +import { createDataFolder } from './create_data_folder'; import { CoreContext } from '../core_context'; +import { configServiceMock } from '../config/config_service.mock'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { mockCoreContext } from '../core_context.mock'; @@ -28,31 +31,69 @@ jest.mock('./resolve_uuid', () => ({ resolveInstanceUuid: jest.fn().mockResolvedValue('SOME_UUID'), })); +jest.mock('./create_data_folder', () => ({ + createDataFolder: jest.fn(), +})); + +const pathConfig = { + data: 'data-folder', +}; +const serverConfig = { + uuid: 'SOME_UUID', +}; + +const getConfigService = () => { + const configService = configServiceMock.create(); + configService.atPath.mockImplementation((path) => { + if (path === 'path') { + return new BehaviorSubject(pathConfig); + } + if (path === 'server') { + return new BehaviorSubject(serverConfig); + } + return new BehaviorSubject({}); + }); + return configService; +}; + describe('UuidService', () => { let logger: ReturnType; + let configService: ReturnType; let coreContext: CoreContext; beforeEach(() => { jest.clearAllMocks(); logger = loggingSystemMock.create(); - coreContext = mockCoreContext.create({ logger }); + configService = getConfigService(); + coreContext = mockCoreContext.create({ logger, configService }); }); describe('#setup()', () => { - it('calls resolveInstanceUuid with core configuration service', async () => { - const service = new UuidService(coreContext); + it('calls resolveInstanceUuid with correct parameters', async () => { + const service = new EnvironmentService(coreContext); await service.setup(); expect(resolveInstanceUuid).toHaveBeenCalledTimes(1); expect(resolveInstanceUuid).toHaveBeenCalledWith({ - configService: coreContext.configService, + pathConfig, + serverConfig, + logger: logger.get('uuid'), + }); + }); + + it('calls createDataFolder with correct parameters', async () => { + const service = new EnvironmentService(coreContext); + await service.setup(); + expect(createDataFolder).toHaveBeenCalledTimes(1); + expect(createDataFolder).toHaveBeenCalledWith({ + pathConfig, logger: logger.get('uuid'), }); }); it('returns the uuid resolved from resolveInstanceUuid', async () => { - const service = new UuidService(coreContext); + const service = new EnvironmentService(coreContext); const setup = await service.setup(); - expect(setup.getInstanceUuid()).toEqual('SOME_UUID'); + expect(setup.instanceUuid).toEqual('SOME_UUID'); }); }); }); diff --git a/src/core/server/uuid/uuid_service.ts b/src/core/server/environment/environment_service.ts similarity index 65% rename from src/core/server/uuid/uuid_service.ts rename to src/core/server/environment/environment_service.ts index d7c1b3331c447..6a0b1122c7053 100644 --- a/src/core/server/uuid/uuid_service.ts +++ b/src/core/server/environment/environment_service.ts @@ -17,25 +17,27 @@ * under the License. */ -import { resolveInstanceUuid } from './resolve_uuid'; +import { take } from 'rxjs/operators'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { IConfigService } from '../config'; +import { PathConfigType, config as pathConfigDef } from '../path'; +import { HttpConfigType, config as httpConfigDef } from '../http'; +import { resolveInstanceUuid } from './resolve_uuid'; +import { createDataFolder } from './create_data_folder'; /** - * APIs to access the application's instance uuid. - * - * @public + * @internal */ -export interface UuidServiceSetup { +export interface InternalEnvironmentServiceSetup { /** * Retrieve the Kibana instance uuid. */ - getInstanceUuid(): string; + instanceUuid: string; } /** @internal */ -export class UuidService { +export class EnvironmentService { private readonly log: Logger; private readonly configService: IConfigService; private uuid: string = ''; @@ -46,13 +48,21 @@ export class UuidService { } public async setup() { + const [pathConfig, serverConfig] = await Promise.all([ + this.configService.atPath(pathConfigDef.path).pipe(take(1)).toPromise(), + this.configService.atPath(httpConfigDef.path).pipe(take(1)).toPromise(), + ]); + + await createDataFolder({ pathConfig, logger: this.log }); + this.uuid = await resolveInstanceUuid({ - configService: this.configService, + pathConfig, + serverConfig, logger: this.log, }); return { - getInstanceUuid: () => this.uuid, + instanceUuid: this.uuid, }; } } diff --git a/src/core/server/uuid/fs.ts b/src/core/server/environment/fs.ts similarity index 95% rename from src/core/server/uuid/fs.ts rename to src/core/server/environment/fs.ts index f10d6370c09d1..dc040ccb73615 100644 --- a/src/core/server/uuid/fs.ts +++ b/src/core/server/environment/fs.ts @@ -22,3 +22,4 @@ import { promisify } from 'util'; export const readFile = promisify(Fs.readFile); export const writeFile = promisify(Fs.writeFile); +export const mkdir = promisify(Fs.mkdir); diff --git a/src/core/server/environment/index.ts b/src/core/server/environment/index.ts new file mode 100644 index 0000000000000..57a26d5ea3c79 --- /dev/null +++ b/src/core/server/environment/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { EnvironmentService, InternalEnvironmentServiceSetup } from './environment_service'; diff --git a/src/core/server/uuid/resolve_uuid.test.ts b/src/core/server/environment/resolve_uuid.test.ts similarity index 81% rename from src/core/server/uuid/resolve_uuid.test.ts rename to src/core/server/environment/resolve_uuid.test.ts index 3132f639e536f..d162c9d8e364b 100644 --- a/src/core/server/uuid/resolve_uuid.test.ts +++ b/src/core/server/environment/resolve_uuid.test.ts @@ -18,12 +18,11 @@ */ import { join } from 'path'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { readFile, writeFile } from './fs'; import { resolveInstanceUuid, UUID_7_6_0_BUG } from './resolve_uuid'; -import { configServiceMock } from '../config/config_service.mock'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import { BehaviorSubject } from 'rxjs'; -import { Logger } from '../logging'; +import { PathConfigType } from '../path'; +import { HttpConfigType } from '../http'; jest.mock('uuid', () => ({ v4: () => 'NEW_UUID', @@ -66,40 +65,34 @@ const mockWriteFile = (error?: object) => { }); }; -const getConfigService = (serverUuid: string | undefined) => { - const configService = configServiceMock.create(); - configService.atPath.mockImplementation((path) => { - if (path === 'path') { - return new BehaviorSubject({ - data: 'data-folder', - }); - } - if (path === 'server') { - return new BehaviorSubject({ - uuid: serverUuid, - }); - } - return new BehaviorSubject({}); - }); - return configService; +const createServerConfig = (serverUuid: string | undefined) => { + return { + uuid: serverUuid, + } as HttpConfigType; }; describe('resolveInstanceUuid', () => { - let configService: ReturnType; - let logger: jest.Mocked; + let logger: ReturnType; + let pathConfig: PathConfigType; + let serverConfig: HttpConfigType; beforeEach(() => { jest.clearAllMocks(); mockReadFile({ uuid: DEFAULT_FILE_UUID }); mockWriteFile(); - configService = getConfigService(DEFAULT_CONFIG_UUID); - logger = loggingSystemMock.create().get() as any; + + pathConfig = { + data: 'data-folder', + }; + serverConfig = createServerConfig(DEFAULT_CONFIG_UUID); + + logger = loggingSystemMock.createLogger(); }); describe('when file is present and config property is set', () => { describe('when they mismatch', () => { it('writes to file and returns the config uuid', async () => { - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -118,7 +111,7 @@ describe('resolveInstanceUuid', () => { describe('when they match', () => { it('does not write to file', async () => { mockReadFile({ uuid: DEFAULT_CONFIG_UUID }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -134,7 +127,7 @@ describe('resolveInstanceUuid', () => { describe('when file is not present and config property is set', () => { it('writes the uuid to file and returns the config uuid', async () => { mockReadFile({ error: fileNotFoundError }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -152,8 +145,8 @@ describe('resolveInstanceUuid', () => { describe('when file is present and config property is not set', () => { it('does not write to file and returns the file uuid', async () => { - configService = getConfigService(undefined); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(undefined); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_FILE_UUID); expect(writeFile).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -169,8 +162,8 @@ describe('resolveInstanceUuid', () => { describe('when config property is not set', () => { it('writes new uuid to file and returns new uuid', async () => { mockReadFile({ uuid: UUID_7_6_0_BUG }); - configService = getConfigService(undefined); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(undefined); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).not.toEqual(UUID_7_6_0_BUG); expect(uuid).toEqual('NEW_UUID'); expect(writeFile).toHaveBeenCalledWith( @@ -195,8 +188,8 @@ describe('resolveInstanceUuid', () => { describe('when config property is set', () => { it('writes config uuid to file and returns config uuid', async () => { mockReadFile({ uuid: UUID_7_6_0_BUG }); - configService = getConfigService(DEFAULT_CONFIG_UUID); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(DEFAULT_CONFIG_UUID); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).not.toEqual(UUID_7_6_0_BUG); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( @@ -221,9 +214,9 @@ describe('resolveInstanceUuid', () => { describe('when file is not present and config property is not set', () => { it('generates a new uuid and write it to file', async () => { - configService = getConfigService(undefined); + serverConfig = createServerConfig(undefined); mockReadFile({ error: fileNotFoundError }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual('NEW_UUID'); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -243,7 +236,7 @@ describe('resolveInstanceUuid', () => { it('throws an explicit error for file read errors', async () => { mockReadFile({ error: permissionError }); await expect( - resolveInstanceUuid({ configService, logger }) + resolveInstanceUuid({ pathConfig, serverConfig, logger }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to read Kibana UUID file, please check the uuid.server configuration value in kibana.yml and ensure Kibana has sufficient permissions to read / write to this file. Error was: EACCES"` ); @@ -251,7 +244,7 @@ describe('resolveInstanceUuid', () => { it('throws an explicit error for file write errors', async () => { mockWriteFile(isDirectoryError); await expect( - resolveInstanceUuid({ configService, logger }) + resolveInstanceUuid({ pathConfig, serverConfig, logger }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to write Kibana UUID file, please check the uuid.server configuration value in kibana.yml and ensure Kibana has sufficient permissions to read / write to this file. Error was: EISDIR"` ); diff --git a/src/core/server/uuid/resolve_uuid.ts b/src/core/server/environment/resolve_uuid.ts similarity index 88% rename from src/core/server/uuid/resolve_uuid.ts rename to src/core/server/environment/resolve_uuid.ts index 36f0eb73b1de7..0267e06939997 100644 --- a/src/core/server/uuid/resolve_uuid.ts +++ b/src/core/server/environment/resolve_uuid.ts @@ -19,11 +19,9 @@ import uuid from 'uuid'; import { join } from 'path'; -import { take } from 'rxjs/operators'; import { readFile, writeFile } from './fs'; -import { IConfigService } from '../config'; -import { PathConfigType, config as pathConfigDef } from '../path'; -import { HttpConfigType, config as httpConfigDef } from '../http'; +import { PathConfigType } from '../path'; +import { HttpConfigType } from '../http'; import { Logger } from '../logging'; const FILE_ENCODING = 'utf8'; @@ -35,19 +33,15 @@ const FILE_NAME = 'uuid'; export const UUID_7_6_0_BUG = `ce42b997-a913-4d58-be46-bb1937feedd6`; export async function resolveInstanceUuid({ - configService, + pathConfig, + serverConfig, logger, }: { - configService: IConfigService; + pathConfig: PathConfigType; + serverConfig: HttpConfigType; logger: Logger; }): Promise { - const [pathConfig, serverConfig] = await Promise.all([ - configService.atPath(pathConfigDef.path).pipe(take(1)).toPromise(), - configService.atPath(httpConfigDef.path).pipe(take(1)).toPromise(), - ]); - const uuidFilePath = join(pathConfig.data, FILE_NAME); - const uuidFromFile = await readUuidFromFile(uuidFilePath, logger); const uuidFromConfig = serverConfig.uuid; diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 2b9193a280aec..f30ff66ed803a 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -30,7 +30,7 @@ import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; import { InternalElasticsearchServiceStart } from '../../elasticsearch'; interface User { diff --git a/src/core/server/http_resources/integration_tests/http_resources_service.test.ts b/src/core/server/http_resources/integration_tests/http_resources_service.test.ts index eee7dc2786076..624cdbb7f9655 100644 --- a/src/core/server/http_resources/integration_tests/http_resources_service.test.ts +++ b/src/core/server/http_resources/integration_tests/http_resources_service.test.ts @@ -17,7 +17,7 @@ * under the License. */ import { schema } from '@kbn/config-schema'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; describe('http resources service', () => { describe('register', () => { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 76bcf5f7df665..5422cbc2180ef 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -60,7 +60,6 @@ import { SavedObjectsServiceStart, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; -import { UuidServiceSetup } from './uuid'; import { MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; @@ -241,6 +240,8 @@ export { SavedObjectsBulkUpdateOptions, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, SavedObjectsClient, SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, @@ -254,11 +255,13 @@ export { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportError, SavedObjectsImportMissingReferencesError, SavedObjectsImportOptions, SavedObjectsImportResponse, SavedObjectsImportRetry, + SavedObjectsImportSuccess, SavedObjectsImportUnknownError, SavedObjectsImportUnsupportedTypeError, SavedObjectMigrationContext, @@ -432,8 +435,6 @@ export interface CoreSetup; /** {@link AuditTrailSetup} */ @@ -483,7 +484,6 @@ export { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId, - UuidServiceSetup, AuditTrailStart, }; diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 4f4bf50f07b8e..6780ca6b59f4d 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -32,7 +32,7 @@ import { InternalSavedObjectsServiceStart, } from './saved_objects'; import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; -import { UuidServiceSetup } from './uuid'; +import { InternalEnvironmentServiceSetup } from './environment'; import { InternalMetricsServiceStart } from './metrics'; import { InternalRenderingServiceSetup } from './rendering'; import { InternalHttpResourcesSetup } from './http_resources'; @@ -49,7 +49,7 @@ export interface InternalCoreSetup { savedObjects: InternalSavedObjectsServiceSetup; status: InternalStatusServiceSetup; uiSettings: InternalUiSettingsServiceSetup; - uuid: UuidServiceSetup; + environment: InternalEnvironmentServiceSetup; rendering: InternalRenderingServiceSetup; httpResources: InternalHttpResourcesSetup; auditTrail: AuditTrailSetup; diff --git a/src/core/server/legacy/integration_tests/legacy_service.test.ts b/src/core/server/legacy/integration_tests/legacy_service.test.ts index 1dc8d53e7c3d6..ca3573e730d3f 100644 --- a/src/core/server/legacy/integration_tests/legacy_service.test.ts +++ b/src/core/server/legacy/integration_tests/legacy_service.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; describe('legacy service', () => { describe('http server', () => { diff --git a/src/core/server/legacy/integration_tests/logging.test.ts b/src/core/server/legacy/integration_tests/logging.test.ts index 2581c85debf26..2ebe17ea92978 100644 --- a/src/core/server/legacy/integration_tests/logging.test.ts +++ b/src/core/server/legacy/integration_tests/logging.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; import { getPlatformLogsFromMock, diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index f8f04c59766b3..45869fd12d2b4 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -45,7 +45,7 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service. import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; import { httpResourcesMock } from '../http_resources/http_resources_service.mock'; import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; -import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { environmentServiceMock } from '../environment/environment_service.mock'; import { findLegacyPluginSpecs } from './plugins'; import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; import { LegacyService } from './legacy_service'; @@ -66,13 +66,13 @@ let startDeps: LegacyServiceStartDeps; const logger = loggingSystemMock.create(); let configService: ReturnType; -let uuidSetup: ReturnType; +let environmentSetup: ReturnType; beforeEach(() => { coreId = Symbol(); env = Env.createDefault(getEnvOptions()); configService = configServiceMock.create(); - uuidSetup = uuidServiceMock.createSetupContract(); + environmentSetup = environmentServiceMock.createSetupContract(); findLegacyPluginSpecsMock.mockClear(); MockKbnServer.prototype.ready = jest.fn().mockReturnValue(Promise.resolve()); @@ -97,7 +97,7 @@ beforeEach(() => { contracts: new Map([['plugin-id', 'plugin-value']]), }, rendering: renderingServiceMock, - uuid: uuidSetup, + environment: environmentSetup, status: statusServiceMock.createInternalSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), @@ -523,7 +523,7 @@ test('Sets the server.uuid property on the legacy configuration', async () => { configService: configService as any, }); - uuidSetup.getInstanceUuid.mockImplementation(() => 'UUID_FROM_SERVICE'); + environmentSetup.instanceUuid = 'UUID_FROM_SERVICE'; const configSetMock = jest.fn(); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index f39282a6f9cb0..adfdecdd7c976 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -188,7 +188,7 @@ export class LegacyService implements CoreService { } // propagate the instance uuid to the legacy config, as it was the legacy way to access it. - this.legacyRawConfig!.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); + this.legacyRawConfig!.set('server.uuid', setupDeps.core.environment.instanceUuid); this.setupDeps = setupDeps; this.legacyInternals = new LegacyInternals( this.legacyPlugins.uiExports, @@ -327,9 +327,6 @@ export class LegacyService implements CoreService { uiSettings: { register: setupDeps.core.uiSettings.register, }, - uuid: { - getInstanceUuid: setupDeps.core.uuid.getInstanceUuid, - }, auditTrail: setupDeps.core.auditTrail, getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]), }; diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index 841c1ce15af47..7f6059567c46e 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; import { InternalCoreSetup } from '../../internal_types'; import { LoggerContextConfigInput } from '../logging_config'; import { Subject } from 'rxjs'; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index bf9dcc4abe01c..3c79706422cd4 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -33,7 +33,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { metricsServiceMock } from './metrics/metrics_service.mock'; -import { uuidServiceMock } from './uuid/uuid_service.mock'; +import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; @@ -94,6 +94,7 @@ function pluginInitializerContextMock(config: T = {} as T) { buildSha: 'buildSha', dist: false, }, + instanceUuid: 'instance-uuid', }, config: pluginInitializerContextConfigMock(config), }; @@ -130,7 +131,6 @@ function createCoreSetupMock({ savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createSetupContract(), uiSettings: uiSettingsMock, - uuid: uuidServiceMock.createSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createSetupContract(), getStartServices: jest @@ -163,7 +163,7 @@ function createInternalCoreSetupMock() { http: httpServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), - uuid: uuidServiceMock.createSetupContract(), + environment: environmentServiceMock.createSetupContract(), httpResources: httpResourcesMock.createSetupContract(), rendering: renderingMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 70413757de9da..4894f19e38df4 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -26,6 +26,7 @@ import { resolve } from 'path'; import { ConfigService, Env } from '../../config'; import { getEnvOptions } from '../../config/__mocks__/env'; import { PluginsConfig, PluginsConfigType, config } from '../plugins_config'; +import type { InstanceInfo } from '../plugin_context'; import { discover } from './plugins_discovery'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; import { CoreContext } from '../../core_context'; @@ -77,6 +78,7 @@ const manifestPath = (...pluginPath: string[]) => describe('plugins discovery system', () => { let logger: ReturnType; + let instanceInfo: InstanceInfo; let env: Env; let configService: ConfigService; let pluginConfig: PluginsConfigType; @@ -87,6 +89,10 @@ describe('plugins discovery system', () => { mockPackage.raw = packageMock; + instanceInfo = { + uuid: 'instance-uuid', + }; + env = Env.createDefault( getEnvOptions({ cliArgs: { envName: 'development' }, @@ -127,7 +133,7 @@ describe('plugins discovery system', () => { }); it('discovers plugins in the search locations', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -146,7 +152,11 @@ describe('plugins discovery system', () => { }); it('return errors when the manifest is invalid or incompatible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -184,7 +194,11 @@ describe('plugins discovery system', () => { }); it('return errors when the plugin search path is not accessible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -219,7 +233,11 @@ describe('plugins discovery system', () => { }); it('return an error when the manifest file is not accessible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -250,7 +268,11 @@ describe('plugins discovery system', () => { }); it('discovers plugins in nested directories', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -287,7 +309,7 @@ describe('plugins discovery system', () => { }); it('does not discover plugins nested inside another plugin', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -306,7 +328,7 @@ describe('plugins discovery system', () => { }); it('stops scanning when reaching `maxDepth`', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -332,7 +354,7 @@ describe('plugins discovery system', () => { }); it('works with symlinks', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); const pluginFolder = resolve(KIBANA_ROOT, '..', 'ext-plugins'); @@ -365,12 +387,16 @@ describe('plugins discovery system', () => { }) ); - discover(new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), { - coreId: Symbol(), - configService, - env, - logger, - }); + discover( + new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), + { + coreId: Symbol(), + configService, + env, + logger, + }, + instanceInfo + ); expect(loggingSystemMock.collect(logger).warn).toEqual([ [ @@ -388,12 +414,16 @@ describe('plugins discovery system', () => { }) ); - discover(new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), { - coreId: Symbol(), - configService, - env, - logger, - }); + discover( + new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), + { + coreId: Symbol(), + configService, + env, + logger, + }, + instanceInfo + ); expect(loggingSystemMock.collect(logger).warn).toEqual([]); }); diff --git a/src/core/server/plugins/discovery/plugins_discovery.ts b/src/core/server/plugins/discovery/plugins_discovery.ts index 5e765a9632e55..2b5b8ad071fb5 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.ts @@ -24,7 +24,7 @@ import { catchError, filter, map, mergeMap, shareReplay } from 'rxjs/operators'; import { CoreContext } from '../../core_context'; import { Logger } from '../../logging'; import { PluginWrapper } from '../plugin'; -import { createPluginInitializerContext } from '../plugin_context'; +import { createPluginInitializerContext, InstanceInfo } from '../plugin_context'; import { PluginsConfig } from '../plugins_config'; import { PluginDiscoveryError } from './plugin_discovery_error'; import { parseManifest } from './plugin_manifest_parser'; @@ -49,7 +49,11 @@ interface PluginSearchPathEntry { * @param coreContext Kibana core values. * @internal */ -export function discover(config: PluginsConfig, coreContext: CoreContext) { +export function discover( + config: PluginsConfig, + coreContext: CoreContext, + instanceInfo: InstanceInfo +) { const log = coreContext.logger.get('plugins-discovery'); log.debug('Discovering plugins...'); @@ -65,7 +69,7 @@ export function discover(config: PluginsConfig, coreContext: CoreContext) { ).pipe( mergeMap((pluginPathOrError) => { return typeof pluginPathOrError === 'string' - ? createPlugin$(pluginPathOrError, log, coreContext) + ? createPlugin$(pluginPathOrError, log, coreContext, instanceInfo) : [pluginPathOrError]; }), shareReplay() @@ -180,7 +184,12 @@ function mapSubdirectories( * @param log Plugin discovery logger instance. * @param coreContext Kibana core context. */ -function createPlugin$(path: string, log: Logger, coreContext: CoreContext) { +function createPlugin$( + path: string, + log: Logger, + coreContext: CoreContext, + instanceInfo: InstanceInfo +) { return from(parseManifest(path, coreContext.env.packageInfo, log)).pipe( map((manifest) => { log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`); @@ -189,7 +198,12 @@ function createPlugin$(path: string, log: Logger, coreContext: CoreContext) { path, manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); }), catchError((err) => [err]) diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index 49c129d0ae67d..5a216b75a83b9 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -28,12 +28,14 @@ import { BehaviorSubject, from } from 'rxjs'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; import { config } from '../plugins_config'; import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { environmentServiceMock } from '../../environment/environment_service.mock'; import { coreMock } from '../../mocks'; import { Plugin } from '../types'; import { PluginWrapper } from '../plugin'; describe('PluginsService', () => { const logger = loggingSystemMock.create(); + const environmentSetup = environmentServiceMock.createSetupContract(); let pluginsService: PluginsService; const createPlugin = ( @@ -158,7 +160,7 @@ describe('PluginsService', () => { } ); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const setupDeps = coreMock.createInternalSetup(); await pluginsService.setup(setupDeps); diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 4f26686e1f5e0..1108ffc248161 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -30,7 +30,11 @@ import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginWrapper } from './plugin'; import { PluginManifest } from './types'; -import { createPluginInitializerContext, createPluginSetupContext } from './plugin_context'; +import { + createPluginInitializerContext, + createPluginSetupContext, + InstanceInfo, +} from './plugin_context'; const mockPluginInitializer = jest.fn(); const logger = loggingSystemMock.create(); @@ -67,12 +71,16 @@ configService.atPath.mockReturnValue(new BehaviorSubject({ initialize: true })); let coreId: symbol; let env: Env; let coreContext: CoreContext; +let instanceInfo: InstanceInfo; const setupDeps = coreMock.createInternalSetup(); beforeEach(() => { coreId = Symbol('core'); env = Env.createDefault(getEnvOptions()); + instanceInfo = { + uuid: 'instance-uuid', + }; coreContext = { coreId, env, logger, configService: configService as any }; }); @@ -88,7 +96,12 @@ test('`constructor` correctly initializes plugin instance', () => { path: 'some-plugin-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.name).toBe('some-plugin-id'); @@ -105,7 +118,12 @@ test('`setup` fails if `plugin` initializer is not exported', async () => { path: 'plugin-without-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect( @@ -122,7 +140,12 @@ test('`setup` fails if plugin initializer is not a function', async () => { path: 'plugin-with-wrong-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect( @@ -139,7 +162,12 @@ test('`setup` fails if initializer does not return object', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); mockPluginInitializer.mockReturnValue(null); @@ -158,7 +186,12 @@ test('`setup` fails if object returned from initializer does not define `setup` path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { run: jest.fn() }; @@ -174,7 +207,12 @@ test('`setup` fails if object returned from initializer does not define `setup` test('`setup` initializes plugin and calls appropriate lifecycle hook', async () => { const manifest = createPluginManifest(); const opaqueId = Symbol(); - const initializerContext = createPluginInitializerContext(coreContext, opaqueId, manifest); + const initializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); const plugin = new PluginWrapper({ path: 'plugin-with-initializer-path', manifest, @@ -203,7 +241,12 @@ test('`start` fails if setup is not called first', async () => { path: 'some-plugin-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect(plugin.start({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot( @@ -218,7 +261,12 @@ test('`start` calls plugin.start with context and dependencies', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const context = { any: 'thing' } as any; const deps = { otherDep: 'value' }; @@ -247,7 +295,12 @@ test("`start` resolves `startDependencies` Promise after plugin's start", async path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const startContext = { any: 'thing' } as any; const pluginDeps = { someDep: 'value' }; @@ -286,7 +339,12 @@ test('`stop` fails if plugin is not set up', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { setup: jest.fn(), stop: jest.fn() }; @@ -305,7 +363,12 @@ test('`stop` does nothing if plugin does not define `stop` function', async () = path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); mockPluginInitializer.mockReturnValue({ setup: jest.fn() }); @@ -321,7 +384,12 @@ test('`stop` calls `stop` defined by the plugin instance', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { setup: jest.fn(), stop: jest.fn() }; @@ -351,7 +419,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-schema', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(configDescriptor); @@ -365,7 +438,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-no-definition', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(null); }); @@ -377,7 +455,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-no-definition', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(null); }); @@ -400,7 +483,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-invalid-schema', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(() => plugin.getConfigDescriptor()).toThrowErrorMatchingInlineSnapshot( `"Configuration schema expected to be an instance of Type"` diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index ebd068caadfb9..578c5f39d71ea 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -19,7 +19,7 @@ import { duration } from 'moment'; import { first } from 'rxjs/operators'; -import { createPluginInitializerContext } from './plugin_context'; +import { createPluginInitializerContext, InstanceInfo } from './plugin_context'; import { CoreContext } from '../core_context'; import { Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; @@ -35,6 +35,7 @@ let coreId: symbol; let env: Env; let coreContext: CoreContext; let server: Server; +let instanceInfo: InstanceInfo; function createPluginManifest(manifestProps: Partial = {}): PluginManifest { return { @@ -51,9 +52,12 @@ function createPluginManifest(manifestProps: Partial = {}): Plug }; } -describe('Plugin Context', () => { +describe('createPluginInitializerContext', () => { beforeEach(async () => { coreId = Symbol('core'); + instanceInfo = { + uuid: 'instance-uuid', + }; env = Env.createDefault(getEnvOptions()); const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); @@ -67,7 +71,8 @@ describe('Plugin Context', () => { const pluginInitializerContext = createPluginInitializerContext( coreContext, opaqueId, - manifest + manifest, + instanceInfo ); expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); @@ -90,4 +95,19 @@ describe('Plugin Context', () => { path: { data: fromRoot('data') }, }); }); + + it('allow to access the provided instance uuid', () => { + const manifest = createPluginManifest(); + const opaqueId = Symbol(); + instanceInfo = { + uuid: 'kibana-uuid', + }; + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + }); }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 62058f6d478e7..fa2659ca130a0 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -37,6 +37,10 @@ import { import { pick, deepFreeze } from '../../utils'; import { CoreSetup, CoreStart } from '..'; +export interface InstanceInfo { + uuid: string; +} + /** * This returns a facade for `CoreContext` that will be exposed to the plugin initializer. * This facade should be safe to use across entire plugin lifespan. @@ -53,7 +57,8 @@ import { CoreSetup, CoreStart } from '..'; export function createPluginInitializerContext( coreContext: CoreContext, opaqueId: PluginOpaqueId, - pluginManifest: PluginManifest + pluginManifest: PluginManifest, + instanceInfo: InstanceInfo ): PluginInitializerContext { return { opaqueId, @@ -64,6 +69,7 @@ export function createPluginInitializerContext( env: { mode: coreContext.env.mode, packageInfo: coreContext.env.packageInfo, + instanceUuid: instanceInfo.uuid, }, /** @@ -183,9 +189,6 @@ export function createPluginSetupContext( uiSettings: { register: deps.uiSettings.register, }, - uuid: { - getInstanceUuid: deps.uuid.getInstanceUuid, - }, getStartServices: () => plugin.startDependencies, auditTrail: deps.auditTrail, }; diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index aa77335991e2c..5e613343c302f 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -29,6 +29,7 @@ import { rawConfigServiceMock } from '../config/raw_config_service.mock'; import { getEnvOptions } from '../config/__mocks__/env'; import { coreMock } from '../mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; +import { environmentServiceMock } from '../environment/environment_service.mock'; import { PluginDiscoveryError } from './discovery'; import { PluginWrapper } from './plugin'; import { PluginsService } from './plugins_service'; @@ -45,6 +46,7 @@ let configService: ConfigService; let coreId: symbol; let env: Env; let mockPluginSystem: jest.Mocked; +let environmentSetup: ReturnType; const setupDeps = coreMock.createInternalSetup(); const logger = loggingSystemMock.create(); @@ -124,6 +126,8 @@ describe('PluginsService', () => { [mockPluginSystem] = MockPluginsSystem.mock.instances as any; mockPluginSystem.uiPlugins.mockReturnValue(new Map()); + + environmentSetup = environmentServiceMock.createSetupContract(); }); afterEach(() => { @@ -137,7 +141,8 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + await expect(pluginsService.discover({ environment: environmentSetup })).rejects + .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Invalid JSON (invalid-manifest, path-1)] `); @@ -158,7 +163,8 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + await expect(pluginsService.discover({ environment: environmentSetup })).rejects + .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Incompatible version (incompatible-version, path-3)] `); @@ -192,7 +198,9 @@ describe('PluginsService', () => { ]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot( + await expect( + pluginsService.discover({ environment: environmentSetup }) + ).rejects.toMatchInlineSnapshot( `[Error: Plugin with id "conflicting-id" is already registered!]` ); @@ -253,7 +261,7 @@ describe('PluginsService', () => { ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const setup = await pluginsService.setup(setupDeps); expect(setup.contracts).toBeInstanceOf(Map); @@ -300,7 +308,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - const { pluginTree } = await pluginsService.discover(); + const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -336,7 +344,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), }); - const { pluginTree } = await pluginsService.discover(); + const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -369,7 +377,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); @@ -386,7 +394,8 @@ describe('PluginsService', () => { resolve(process.cwd(), '..', 'kibana-extra'), ], }, - { coreId, env, logger, configService } + { coreId, env, logger, configService }, + { uuid: 'uuid' } ); const logs = loggingSystemMock.collect(logger); @@ -417,7 +426,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(configService.setSchema).toBeCalledWith('path', configSchema); }); @@ -448,7 +457,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(configService.addDeprecationProvider).toBeCalledWith( 'config-path', deprecationProvider @@ -496,7 +505,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); const uiConfig$ = uiPlugins.browserConfigs.get('plugin-with-expose'); expect(uiConfig$).toBeDefined(); @@ -532,7 +541,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); expect([...uiPlugins.browserConfigs.entries()]).toHaveLength(0); }); }); @@ -561,7 +570,7 @@ describe('PluginsService', () => { describe('uiPlugins.internal', () => { it('includes disabled plugins', async () => { config$.next({ plugins: { initialize: true }, plugin1: { enabled: false } }); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); expect(uiPlugins.internal).toMatchInlineSnapshot(` Map { "plugin-1" => Object { @@ -582,7 +591,7 @@ describe('PluginsService', () => { describe('plugin initialization', () => { it('does initialize if plugins.initialize is true', async () => { config$.next({ plugins: { initialize: true } }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).toHaveBeenCalled(); expect(initialized).toBe(true); @@ -590,7 +599,7 @@ describe('PluginsService', () => { it('does not initialize if plugins.initialize is false', async () => { config$.next({ plugins: { initialize: false } }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled(); expect(initialized).toBe(false); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 06de48a215881..30cd47c9d44e1 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -32,6 +32,7 @@ import { PluginsSystem } from './plugins_system'; import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; import { IConfigService } from '../config'; import { pick } from '../../utils'; +import { InternalEnvironmentServiceSetup } from '../environment'; /** @internal */ export interface PluginsServiceSetup { @@ -72,6 +73,11 @@ export type PluginsServiceSetupDeps = InternalCoreSetup; /** @internal */ export type PluginsServiceStartDeps = InternalCoreStart; +/** @internal */ +export interface PluginsServiceDiscoverDeps { + environment: InternalEnvironmentServiceSetup; +} + /** @internal */ export class PluginsService implements CoreService { private readonly log: Logger; @@ -90,12 +96,14 @@ export class PluginsService implements CoreService new PluginsConfig(rawConfig, coreContext.env))); } - public async discover() { + public async discover({ environment }: PluginsServiceDiscoverDeps) { this.log.debug('Discovering plugins'); const config = await this.config$.pipe(first()).toPromise(); - const { error$, plugin$ } = discover(config, this.coreContext); + const { error$, plugin$ } = discover(config, this.coreContext, { + uuid: environment.instanceUuid, + }); await this.handleDiscoveryErrors(error$); await this.handleDiscoveredPlugins(plugin$); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 9695c9171a771..eb2a9ca3daf5f 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -278,6 +278,7 @@ export interface PluginInitializerContext { env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; logger: LoggerFactory; config: { diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 27c0a5205ae38..85b3a281aef7f 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -665,6 +665,33 @@ describe('getSortedObjectsForExport()', () => { `); }); + test('modifies return results to redact `namespaces` attribute', async () => { + const createSavedObject = (obj: any) => ({ ...obj, attributes: {}, references: [] }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + createSavedObject({ type: 'multi', id: '1', namespaces: ['foo'] }), + createSavedObject({ type: 'multi', id: '2', namespaces: ['bar'] }), + createSavedObject({ type: 'other', id: '3' }), + ], + }); + const exportStream = await exportSavedObjectsToStream({ + exportSizeLimit: 10000, + savedObjectsClient, + objects: [ + { type: 'multi', id: '1' }, + { type: 'multi', id: '2' }, + { type: 'other', id: '3' }, + ], + }); + const response = await readStreamToCompletion(exportStream); + expect(response).toEqual([ + createSavedObject({ type: 'multi', id: '1' }), + createSavedObject({ type: 'multi', id: '2' }), + createSavedObject({ type: 'other', id: '3' }), + expect.objectContaining({ exportedCount: 3 }), + ]); + }); + test('includes nested dependencies when passed in', async () => { savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 6cfe6f1be5669..94f727e238ecf 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -151,7 +151,7 @@ export async function exportSavedObjectsToStream({ exportSizeLimit, namespace, }); - let exportedObjects = []; + let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; if (includeReferencesDeep) { diff --git a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts index a571f62e3d1c1..1d5ce5625bf48 100644 --- a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts +++ b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts @@ -20,6 +20,7 @@ import { SavedObject } from '../types'; import { savedObjectsClientMock } from '../../mocks'; import { getObjectReferencesToFetch, fetchNestedDependencies } from './inject_nested_depdendencies'; +import { SavedObjectsErrorHelpers } from '..'; describe('getObjectReferencesToFetch()', () => { test('works with no saved objects', () => { @@ -475,10 +476,8 @@ describe('injectNestedDependencies', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output + .payload, attributes: {}, references: [], }, diff --git a/src/core/server/saved_objects/import/__mocks__/index.ts b/src/core/server/saved_objects/import/__mocks__/index.ts new file mode 100644 index 0000000000000..e2c48ee483ce4 --- /dev/null +++ b/src/core/server/saved_objects/import/__mocks__/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockUuidv4 = jest.fn().mockReturnValue('uuidv4'); +jest.mock('uuid', () => ({ + v4: mockUuidv4, +})); + +export { mockUuidv4 }; diff --git a/src/core/server/saved_objects/import/check_conflicts.test.ts b/src/core/server/saved_objects/import/check_conflicts.test.ts new file mode 100644 index 0000000000000..0d58970eee2cc --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.test.ts @@ -0,0 +1,222 @@ +/* + * 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 { mockUuidv4 } from './__mocks__'; +import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectReference, SavedObjectsImportRetry } from 'kibana/public'; +import { SavedObjectsClientContract, SavedObject } from '../types'; +import { SavedObjectsErrorHelpers } from '..'; +import { checkConflicts } from './check_conflicts'; + +type SavedObjectType = SavedObject<{ title?: string }>; +type CheckConflictsParams = Parameters[0]; + +/** + * Function to create a realistic-looking import object given a type and ID + */ +const createObject = (type: string, id: string): SavedObjectType => ({ + type, + id, + attributes: { title: 'some-title' }, + references: (Symbol() as unknown) as SavedObjectReference[], +}); + +const getResultMock = { + conflict: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return { type, id, error }; + }, + unresolvableConflict: (type: string, id: string) => { + const conflictMock = getResultMock.conflict(type, id); + const metadata = { isNotOverwritable: true }; + return { ...conflictMock, error: { ...conflictMock.error, metadata } }; + }, + invalidType: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload; + return { type, id, error }; + }, +}; + +/** + * Create a variety of different objects to exercise different import / result scenarios + */ +const obj1 = createObject('type-1', 'id-1'); // -> success +const obj2 = createObject('type-2', 'id-2'); // -> conflict +const obj3 = createObject('type-3', 'id-3'); // -> unresolvable conflict +const obj4 = createObject('type-4', 'id-4'); // -> invalid type +const objects = [obj1, obj2, obj3, obj4]; +const obj2Error = getResultMock.conflict(obj2.type, obj2.id); +const obj3Error = getResultMock.unresolvableConflict(obj3.type, obj3.id); +const obj4Error = getResultMock.invalidType(obj4.type, obj4.id); + +describe('#checkConflicts', () => { + let savedObjectsClient: jest.Mocked; + let socCheckConflicts: typeof savedObjectsClient['checkConflicts']; + + const setupParams = (partial: { + objects: SavedObjectType[]; + namespace?: string; + ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; + createNewCopies?: boolean; + }): CheckConflictsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + socCheckConflicts = savedObjectsClient.checkConflicts; + socCheckConflicts.mockResolvedValue({ errors: [] }); // by default, mock to empty results + return { ...partial, savedObjectsClient }; + }; + + beforeEach(() => { + mockUuidv4.mockReset(); + mockUuidv4.mockReturnValueOnce(`new-object-id`); + }); + + it('exits early if there are no objects to check', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects: [], namespace }); + + const checkConflictsResult = await checkConflicts(params); + expect(socCheckConflicts).not.toHaveBeenCalled(); + expect(checkConflictsResult).toEqual({ + filteredObjects: [], + errors: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), + }); + }); + + it('calls checkConflicts with expected inputs', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace }); + + await checkConflicts(params); + expect(socCheckConflicts).toHaveBeenCalledTimes(1); + expect(socCheckConflicts).toHaveBeenCalledWith(objects, { namespace }); + }); + + it('returns expected result', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + expect(checkConflictsResult).toEqual({ + filteredObjects: [obj1, obj3], + errors: [ + { + ...obj2Error, + title: obj2.attributes.title, + meta: { title: obj2.attributes.title }, + error: { type: 'conflict' }, + }, + { + ...obj4Error, + title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: `new-object-id` }]]), + pendingOverwrites: new Set(), + }); + }); + + it('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace, ignoreRegularConflicts: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + expect(checkConflictsResult).toEqual( + expect.objectContaining({ + filteredObjects: [obj1, obj2, obj3], + errors: [ + { + ...obj4Error, + title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`]), + }) + ); + }); + + it('handles retries', async () => { + const namespace = 'foo-namespace'; + const obj5 = createObject('type-5', 'id-5'); + const _objects = [...objects, obj5]; + const retries = [ + { id: obj1.id, type: obj1.type }, // find no conflict for obj1 + { id: obj2.id, type: obj2.type, destinationId: 'some-object-id' }, // find a conflict for obj2, and return it with the specified destinationId + { id: obj3.id, type: obj3.type, destinationId: 'another-object-id', createNewCopy: true }, // find an unresolvable conflict for obj3, regenerate the destinationId, and then omit originId because of the createNewCopy flag + { id: obj4.id, type: obj4.type }, // get an unknown error for obj4 + { id: obj5.id, type: obj5.type, overwrite: true }, // find a conflict for obj5, but ignore it because of the overwrite flag + ] as SavedObjectsImportRetry[]; + const params = setupParams({ objects: _objects, namespace, retries }); + const obj5Error = getResultMock.conflict(obj5.type, obj5.id); + socCheckConflicts.mockResolvedValue({ + errors: [ + { ...obj2Error, id: 'some-object-id' }, + { ...obj3Error, id: 'another-object-id' }, + obj4Error, + obj5Error, + ], + }); + + const checkConflictsResult = await checkConflicts(params); + expect(checkConflictsResult).toEqual({ + filteredObjects: [obj1, obj3, obj5], + errors: [ + { + ...obj2Error, + title: obj2.attributes.title, + meta: { title: obj2.attributes.title }, + error: { type: 'conflict', destinationId: 'some-object-id' }, + }, + { + ...obj4Error, + title: obj4.attributes.title, + meta: { title: obj4.attributes.title }, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + importIdMap: new Map([ + [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + ]), + pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), + }); + }); + + it('adds `omitOriginId` field to `importIdMap` entries when createNewCopies=true', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace, createNewCopies: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + expect(checkConflictsResult).toEqual( + expect.objectContaining({ + importIdMap: new Map([ + [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + ]), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts new file mode 100644 index 0000000000000..88ef1bf0e0236 --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -0,0 +1,105 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectError, + SavedObjectsImportRetry, +} from '../types'; + +interface CheckConflictsParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + namespace?: string; + ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; + createNewCopies?: boolean; +} + +const isUnresolvableConflict = (error: SavedObjectError) => + error.statusCode === 409 && error.metadata?.isNotOverwritable; + +export async function checkConflicts({ + objects, + savedObjectsClient, + namespace, + ignoreRegularConflicts, + retries = [], + createNewCopies, +}: CheckConflictsParams) { + const filteredObjects: Array> = []; + const errors: SavedObjectsImportError[] = []; + const importIdMap = new Map(); + const pendingOverwrites = new Set(); + + // exit early if there are no objects to check + if (objects.length === 0) { + return { filteredObjects, errors, importIdMap, pendingOverwrites }; + } + + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const objectsToCheck = objects.map((x) => { + const id = retryMap.get(`${x.type}:${x.id}`)?.destinationId ?? x.id; + return { ...x, id }; + }); + const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { + namespace, + }); + const errorMap = checkConflictsResult.errors.reduce( + (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), + new Map() + ); + + objects.forEach((object) => { + const { + type, + id, + attributes: { title }, + } = object; + const { destinationId, overwrite, createNewCopy } = retryMap.get(`${type}:${id}`) || {}; + const errorObj = errorMap.get(`${type}:${destinationId ?? id}`); + if (errorObj && isUnresolvableConflict(errorObj)) { + // Any object create attempt that would result in an unresolvable conflict should have its ID regenerated. This way, when an object + // with a "multi-namespace" type is exported from one namespace and imported to another, it does not result in an error, but instead a + // new object is created. + // This code path should not be triggered for a retry, but in case the consumer is using the import APIs incorrectly and attempting to + // retry an object with a destinationId that would result in an unresolvable conflict, we regenerate the ID here as a fail-safe. + const omitOriginId = createNewCopies || createNewCopy; + importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId }); + filteredObjects.push(object); + } else if (errorObj && errorObj.statusCode !== 409) { + errors.push({ type, id, title, meta: { title }, error: { ...errorObj, type: 'unknown' } }); + } else if (errorObj?.statusCode === 409 && !ignoreRegularConflicts && !overwrite) { + const error = { type: 'conflict' as 'conflict', ...(destinationId && { destinationId }) }; + errors.push({ type, id, title, meta: { title }, error }); + } else { + filteredObjects.push(object); + if (errorObj?.statusCode === 409) { + pendingOverwrites.add(`${type}:${id}`); + } + } + }); + return { filteredObjects, errors, importIdMap, pendingOverwrites }; +} diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts new file mode 100644 index 0000000000000..ba5576bd05b73 --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts @@ -0,0 +1,584 @@ +/* + * 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 { mockUuidv4 } from './__mocks__'; +import { + SavedObjectsClientContract, + SavedObjectReference, + SavedObject, + SavedObjectsImportRetry, + SavedObjectsImportError, +} from '../types'; +import { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +import { savedObjectsClientMock } from '../../mocks'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { ISavedObjectTypeRegistry } from '..'; + +type SavedObjectType = SavedObject<{ title?: string }>; +type CheckOriginConflictsParams = Parameters[0]; + +/** + * Function to create a realistic-looking import object given a type, ID, and optional originId + */ +const createObject = (type: string, id: string, originId?: string): SavedObjectType => ({ + type, + id, + attributes: { title: `Title for ${type}:${id}` }, + references: (Symbol() as unknown) as SavedObjectReference[], + ...(originId && { originId }), +}); + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; + +beforeEach(() => { + mockUuidv4.mockClear(); +}); + +describe('#checkOriginConflicts', () => { + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + let find: typeof savedObjectsClient['find']; + + const getResultMock = (...objects: SavedObjectType[]) => ({ + page: 1, + per_page: 10, + total: objects.length, + saved_objects: objects.map((object) => ({ ...object, score: 0 })), + }); + + const setupParams = (partial: { + objects: SavedObjectType[]; + namespace?: string; + importIdMap?: Map; + ignoreRegularConflicts?: boolean; + }): CheckOriginConflictsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + find = savedObjectsClient.find; + find.mockResolvedValue(getResultMock()); // mock zero hits response by default + typeRegistry = typeRegistryMock.create(); + typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); + return { + importIdMap: new Map(), // empty by default + ...partial, + savedObjectsClient, + typeRegistry, + }; + }; + + const mockFindResult = (...objects: SavedObjectType[]) => { + find.mockResolvedValueOnce(getResultMock(...objects)); + }; + + describe('cluster calls', () => { + const multiNsObj = createObject(MULTI_NS_TYPE, 'id-1'); + const multiNsObjWithOriginId = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); + const otherObj = createObject(OTHER_TYPE, 'id-3'); + // non-multi-namespace types shouldn't have origin IDs, but we include a test case to ensure it's handled gracefully + const otherObjWithOriginId = createObject(OTHER_TYPE, 'id-4', 'originId-bar'); + + const expectFindArgs = (n: number, object: SavedObject, rawIdPrefix: string) => { + const { type, id, originId } = object; + const search = `"${rawIdPrefix}${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases + const expectedArgs = expect.objectContaining({ type, search }); + // exclude rootSearchFields, page, perPage, and fields attributes from assertion -- these are constant + // exclude namespace from assertion -- a separate test covers that + expect(find).toHaveBeenNthCalledWith(n, expectedArgs); + }; + + test('does not execute searches for non-multi-namespace objects', async () => { + const objects = [otherObj, otherObjWithOriginId]; + const params = setupParams({ objects }); + + await checkOriginConflicts(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('executes searches for multi-namespace objects', async () => { + const objects = [multiNsObj, otherObj, multiNsObjWithOriginId, otherObjWithOriginId]; + const params1 = setupParams({ objects }); + + await checkOriginConflicts(params1); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, ''); + expectFindArgs(2, multiNsObjWithOriginId, ''); + + find.mockClear(); + const params2 = setupParams({ objects, namespace: 'some-namespace' }); + await checkOriginConflicts(params2); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, 'some-namespace:'); + expectFindArgs(2, multiNsObjWithOriginId, 'some-namespace:'); + }); + + test('searches within the current `namespace`', async () => { + const objects = [multiNsObj]; + const namespace = 'some-namespace'; + const params = setupParams({ objects, namespace }); + + await checkOriginConflicts(params); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); + }); + + test('search query escapes quote and backslash characters in `id` and/or `originId`', async () => { + const weirdId = `some"weird\\id`; + const objects = [ + createObject(MULTI_NS_TYPE, weirdId), + createObject(MULTI_NS_TYPE, 'some-id', weirdId), + ]; + const params = setupParams({ objects }); + + await checkOriginConflicts(params); + const escapedId = `some\\"weird\\\\id`; + const expectedQuery = `"${MULTI_NS_TYPE}:${escapedId}" | "${escapedId}"`; + expect(find).toHaveBeenCalledTimes(2); + expect(find).toHaveBeenNthCalledWith(1, expect.objectContaining({ search: expectedQuery })); + expect(find).toHaveBeenNthCalledWith(2, expect.objectContaining({ search: expectedQuery })); + }); + }); + + describe('results', () => { + const getAmbiguousConflicts = (objects: SavedObjectType[]) => + objects.map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })); + const createAmbiguousConflictError = ( + object: SavedObjectType, + destinations: SavedObjectType[] + ): SavedObjectsImportError => ({ + type: object.type, + id: object.id, + title: object.attributes.title, + meta: { title: object.attributes.title }, + error: { + type: 'ambiguous_conflict', + destinations: getAmbiguousConflicts(destinations), + }, + }); + const createConflictError = ( + object: SavedObjectType, + destinationId?: string + ): SavedObjectsImportError => ({ + type: object.type, + id: object.id, + title: object.attributes?.title, + meta: { title: object.attributes.title }, + error: { + type: 'conflict', + ...(destinationId && { destinationId }), + }, + }); + + describe('object result without a `importIdMap` entry (no match or exact match)', () => { + test('returns object when no match is detected (0 hits)', async () => { + // no objects exist in this space + // try to import obj1, obj2, obj3, and obj4 + const obj1 = createObject(OTHER_TYPE, 'id-1'); // non-multi-namespace types are skipped when searching, so they will never have a match anyway + const obj2 = createObject(OTHER_TYPE, 'id-2', 'originId-foo'); // non-multi-namespace types are skipped when searching, so they will never have a match anyway + const obj3 = createObject(MULTI_NS_TYPE, 'id-3'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'originId-bar'); + const objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + + // don't need to mock find results for obj3 and obj4, "no match" is the default find result in this test suite + const checkOriginConflictsResult = await checkOriginConflicts(params); + + const expectedResult = { + importIdMap: new Map(), + errors: [], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object when an inexact match is detected (1 hit) with a destination that is exactly matched by another object', async () => { + // obj1 and obj3 exist in this space + // try to import obj1, obj2, obj3, and obj4; simulating a scenario where obj1 and obj3 were filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objects = [obj2, obj4]; + const params = setupParams({ + objects, + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.id}`, {}], + [`${obj4.type}:${obj4.id}`, {}], + ]), + }); + mockFindResult(obj1); // find for obj2: the result is an inexact match with one destination that is exactly matched by obj1 so it is ignored -- accordingly, obj2 has no match + mockFindResult(obj3); // find for obj4: the result is an inexact match with one destination that is exactly matched by obj3 so it is ignored -- accordingly, obj4 has no match + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map(), + errors: [], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object when an inexact match is detected (2+ hits) with destinations that are all exactly matched by another object', async () => { + // obj1 and obj2 exist in this space + // try to import obj1, obj2, and obj3; simulating a scenario where obj1 and obj2 were filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', obj1.id); + const objects = [obj3]; + const params = setupParams({ + objects, + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.id}`, {}], + ]), + }); + mockFindResult(obj1, obj2); // find for obj3: the result is an inexact match with two destinations that are exactly matched by obj1 and obj2 so they are ignored -- accordingly, obj3 has no match + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map(), + errors: [], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('object result with a `importIdMap` entry (partial match with a single destination)', () => { + describe('when an inexact match is detected (1 hit)', () => { + // objA and objB exist in this space + // try to import obj1 and obj2 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj2.originId); + const objects = [obj1, obj2]; + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ objects, ignoreRegularConflicts }); + mockFindResult(objA); // find for obj1: the result is an inexact match with one destination + mockFindResult(objB); // find for obj2: the result is an inexact match with one destination + return params; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map(), + errors: [createConflictError(obj1, objA.id), createConflictError(obj2, objB.id)], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + const params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, { id: objA.id }], + [`${obj2.type}:${obj2.id}`, { id: objB.id }], + ]), + errors: [], + pendingOverwrites: new Set([`${obj1.type}:${obj1.id}`, `${obj2.type}:${obj2.id}`]), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('when an inexact match is detected (2+ hits), with n-1 destinations that are exactly matched by another object', () => { + // obj1, obj3, objA, and objB exist in this space + // try to import obj1, obj2, obj3, and obj4; simulating a scenario where obj1 and obj3 were filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj3.originId); + const objects = [obj2, obj4]; + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ + objects, + ignoreRegularConflicts, + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.id}`, {}], + [`${obj4.type}:${obj4.id}`, {}], + ]), + }); + mockFindResult(obj1, objA); // find for obj2: the result is an inexact match with two destinations, but the first destination is exactly matched by obj1 so it is ignored -- accordingly, obj2 has an inexact match with one destination (objA) + mockFindResult(objB, obj3); // find for obj4: the result is an inexact match with two destinations, but the second destination is exactly matched by obj3 so it is ignored -- accordingly, obj4 has an inexact match with one destination (objB) + return params; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map(), + errors: [createConflictError(obj2, objA.id), createConflictError(obj4, objB.id)], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + const params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj2.type}:${obj2.id}`, { id: objA.id }], + [`${obj4.type}:${obj4.id}`, { id: objB.id }], + ]), + errors: [], + pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`, `${obj4.type}:${obj4.id}`]), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + }); + + describe('ambiguous conflicts', () => { + test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same single destination', async () => { + // objA and objB exist in this space + // try to import obj1, obj2, obj3, and obj4 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj3.originId); + const objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + mockFindResult(objA); // find for obj1: the result is an inexact match with one destination + mockFindResult(objA); // find for obj2: the result is an inexact match with one destination + mockFindResult(objB); // find for obj3: the result is an inexact match with one destination + mockFindResult(objB); // find for obj4: the result is an inexact match with one destination + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + ]), + errors: [], + pendingOverwrites: new Set(), + }; + expect(mockUuidv4).toHaveBeenCalledTimes(4); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns ambiguous_conflict error when an inexact match is detected (2+ hits)', async () => { + // objA, objB, objC, and objD exist in this space + // try to import obj1 and obj2 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj2.originId); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj2.originId); + const objects = [obj1, obj2]; + const params = setupParams({ objects }); + mockFindResult(objA, objB); // find for obj1: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj2: the result is an inexact match with two destinations + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map(), + errors: [ + createAmbiguousConflictError(obj1, [objA, objB]), + createAmbiguousConflictError(obj2, [objC, objD]), + ], + pendingOverwrites: new Set(), + }; + expect(mockUuidv4).not.toHaveBeenCalled(); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same multiple destinations', async () => { + // objA, objB, objC, and objD exist in this space + // try to import obj1, obj2, obj3, and obj4 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj3.originId); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj3.originId); + const objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + mockFindResult(objA, objB); // find for obj1: the result is an inexact match with two destinations + mockFindResult(objA, objB); // find for obj2: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj3: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj4: the result is an inexact match with two destinations + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + ]), + errors: [], + pendingOverwrites: new Set(), + }; + expect(mockUuidv4).toHaveBeenCalledTimes(4); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('mixed results', () => { + // obj3, objA, objB, objC, objD, and objE exist in this space + // try to import obj1, obj2, obj3, obj4, obj5, obj6, and obj7; simulating a scenario where obj3 was filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + // note: this test is non-exhaustive for different permutations of import objects and results, but prior tests exercise these more thoroughly + const obj1 = createObject(OTHER_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.id); + const obj5 = createObject(MULTI_NS_TYPE, 'id-5'); + const obj6 = createObject(MULTI_NS_TYPE, 'id-6'); + const obj7 = createObject(MULTI_NS_TYPE, 'id-7'); + const obj8 = createObject(MULTI_NS_TYPE, 'id-8', obj7.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj5.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj6.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj6.id); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj7.id); + const objE = createObject(MULTI_NS_TYPE, 'id-E', obj7.id); + const objects = [obj1, obj2, obj4, obj5, obj6, obj7, obj8]; + + const importIdMap = new Map([...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}])); + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ objects, importIdMap, ignoreRegularConflicts }); + // obj1 is a non-multi-namespace type, so it is skipped while searching + mockFindResult(); // find for obj2: the result is no match + mockFindResult(obj3); // find for obj4: the result is an inexact match with one destination that is exactly matched by obj3 so it is ignored -- accordingly, obj4 has no match + mockFindResult(objA); // find for obj5: the result is an inexact match with one destination + mockFindResult(objB, objC); // find for obj6: the result is an inexact match with two destinations + mockFindResult(objD, objE); // find for obj7: the result is an inexact match with two destinations + mockFindResult(objD, objE); // find for obj8: the result is an inexact match with two destinations + return params; + }; + + test('returns errors for regular conflicts when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + ]), + errors: [ + createConflictError(obj5, objA.id), + createAmbiguousConflictError(obj6, [objB, objC]), + ], + pendingOverwrites: new Set(), + }; + expect(mockUuidv4).toHaveBeenCalledTimes(2); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { + const params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importIdMap: new Map([ + [`${obj5.type}:${obj5.id}`, { id: objA.id }], + [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + ]), + errors: [createAmbiguousConflictError(obj6, [objB, objC])], + pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), + }; + expect(mockUuidv4).toHaveBeenCalledTimes(2); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + }); +}); + +describe('#getImportIdMapForRetries', () => { + const createRetry = ( + { type, id }: { type: string; id: string }, + params: { destinationId?: string; createNewCopy?: boolean } = {} + ): SavedObjectsImportRetry => { + const { destinationId, createNewCopy } = params; + return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; + }; + + test('throws an error if retry is not found for an object', async () => { + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); + const objects = [obj1, obj2]; + const retries = [createRetry(obj1)]; + const params = { objects, retries, createNewCopies: false }; + + expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( + `"Retry was expected for \\"multi:id-2\\" but not found"` + ); + }); + + test('returns expected results', async () => { + const obj1 = createObject('type-1', 'id-1'); + const obj2 = createObject('type-2', 'id-2'); + const obj3 = createObject('type-3', 'id-3'); + const obj4 = createObject('type-4', 'id-4'); + const objects = [obj1, obj2, obj3, obj4]; + const retries = [ + createRetry(obj1), // retries that do not have `destinationId` specified are ignored + createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored + createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! + createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! + ]; + const params = { objects, retries, createNewCopies: false }; + + const checkOriginConflictsResult = await getImportIdMapForRetries(params); + expect(checkOriginConflictsResult).toEqual( + new Map([ + [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], + [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], + ]) + ); + }); + + test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { + const obj = createObject('type-1', 'id-1'); + const objects = [obj]; + const retries = [createRetry(obj, { destinationId: 'id-X' })]; + const params = { objects, retries, createNewCopies: true }; + + const checkOriginConflictsResult = await getImportIdMapForRetries(params); + expect(checkOriginConflictsResult).toEqual( + new Map([[`${obj.type}:${obj.id}`, { id: 'id-X', omitOriginId: true }]]) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.ts b/src/core/server/saved_objects/import/check_origin_conflicts.ts new file mode 100644 index 0000000000000..433574fbdbf4c --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.ts @@ -0,0 +1,246 @@ +/* + * 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 pMap from 'p-map'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from '../types'; +import { ISavedObjectTypeRegistry } from '..'; + +interface CheckOriginConflictsParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + namespace?: string; + ignoreRegularConflicts?: boolean; + importIdMap: Map; +} + +type CheckOriginConflictParams = Omit & { + object: SavedObject<{ title?: string }>; +}; + +interface GetImportIdMapForRetriesParams { + objects: SavedObject[]; + retries: SavedObjectsImportRetry[]; + createNewCopies: boolean; +} + +interface InexactMatch { + object: SavedObject; + destinations: Array<{ id: string; title?: string; updatedAt?: string }>; +} +interface Left { + tag: 'left'; + value: InexactMatch; +} +interface Right { + tag: 'right'; + value: SavedObject; +} +type Either = Left | Right; +const isLeft = (object: Either): object is Left => object.tag === 'left'; + +const MAX_CONCURRENT_SEARCHES = 10; + +const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); +const createQuery = (type: string, id: string, rawIdPrefix: string) => + `"${createQueryTerm(`${rawIdPrefix}${type}:${id}`)}" | "${createQueryTerm(id)}"`; +const transformObjectsToAmbiguousConflictFields = ( + objects: Array> +) => + objects.map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })); +const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => + `${object.type}:${object.originId || object.id}`; + +/** + * Make a search request for an import object to check if any objects of this type that match this object's `originId` or `id` exist in the + * specified namespace: + * - A `Right` result indicates that no conflict destinations were found in this namespace ("no match"). + * - A `Left` result indicates that one or more conflict destinations exist in this namespace, none of which exactly match this object's ID + * ("inexact match"). We can make this assumption because any "exact match" results would have been obtained and filtered out by the + * `checkConflicts` submodule, which is called before this. + */ +const checkOriginConflict = async ( + params: CheckOriginConflictParams +): Promise> => { + const { object, savedObjectsClient, typeRegistry, namespace, importIdMap } = params; + const importIds = new Set(importIdMap.keys()); + const { type, originId } = object; + + if (!typeRegistry.isMultiNamespace(type)) { + // Skip the search request for non-multi-namespace types, since by definition they cannot have inexact matches or ambiguous conflicts. + return { tag: 'right', value: object }; + } + + const search = createQuery(type, originId || object.id, namespace ? `${namespace}:` : ''); + const findOptions = { + type, + search, + rootSearchFields: ['_id', 'originId'], + page: 1, + perPage: 10, + fields: ['title'], + sortField: 'updated_at', + sortOrder: 'desc', + ...(namespace && { namespaces: [namespace] }), + }; + const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); + const { total, saved_objects: savedObjects } = findResult; + if (total === 0) { + return { tag: 'right', value: object }; + } + // This is an "inexact match" so far; filter the conflict destination(s) to exclude any that exactly match other objects we are importing. + const objects = savedObjects.filter((obj) => !importIds.has(`${obj.type}:${obj.id}`)); + const destinations = transformObjectsToAmbiguousConflictFields(objects); + if (destinations.length === 0) { + // No conflict destinations remain after filtering, so this is a "no match" result. + return { tag: 'right', value: object }; + } + return { tag: 'left', value: { object, destinations } }; +}; + +/** + * This function takes all objects to import, and checks "multi-namespace" types for potential conflicts. An object with a multi-namespace + * type may include an `originId` field, which means that it should conflict with other objects that originate from the same source. + * Expected behavior of importing saved objects (single-namespace or multi-namespace): + * 1. The object 'foo' is exported from space A and imported to space B -- a new object 'bar' is created. + * 2. Then, the object 'bar' is exported from space B and imported to space C -- a new object 'baz' is created. + * 3. Then, the object 'baz' is exported from space C to space A -- the object conflicts with 'foo', which must be overwritten to continue. + * This behavior originated with "single-namespace" types, and this function was added to ensure importing objects of multi-namespace types + * will behave in the same way. + * + * To achieve this behavior for multi-namespace types, a search request is made for each object to determine if any objects of this type + * that match this object's `originId` or `id` exist in the specified namespace: + * - If this is a `Right` result; return the import object and allow `createSavedObjects` to handle the conflict (if any). + * - If this is a `Left` "partial match" result: + * A. If there is a single source and destination match, add the destination to the importIdMap and return the import object, which + * will allow `createSavedObjects` to modify the ID before creating the object (thus ensuring a conflict during). + * B. Otherwise, this is an "ambiguous conflict" result; return an error. + */ +export async function checkOriginConflicts({ objects, ...params }: CheckOriginConflictsParams) { + // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. + const mapper = async (object: SavedObject<{ title?: string }>) => + checkOriginConflict({ object, ...params }); + const checkOriginConflictResults = await pMap(objects, mapper, { + concurrency: MAX_CONCURRENT_SEARCHES, + }); + + // Get a map of all inexact matches that share the same destination(s). + const ambiguousConflictSourcesMap = checkOriginConflictResults + .filter(isLeft) + .reduce((acc, cur) => { + const key = getAmbiguousConflictSourceKey(cur.value); + const value = acc.get(key) ?? []; + return acc.set(key, [...value, cur.value.object]); + }, new Map>>()); + + const errors: SavedObjectsImportError[] = []; + const importIdMap = new Map(); + const pendingOverwrites = new Set(); + checkOriginConflictResults.forEach((result) => { + if (!isLeft(result)) { + return; + } + const key = getAmbiguousConflictSourceKey(result.value); + const sources = transformObjectsToAmbiguousConflictFields( + ambiguousConflictSourcesMap.get(key)! + ); + const { object, destinations } = result.value; + const { type, id, attributes } = object; + if (sources.length === 1 && destinations.length === 1) { + // This is a simple "inexact match" result -- a single import object has a single destination conflict. + if (params.ignoreRegularConflicts) { + importIdMap.set(`${type}:${id}`, { id: destinations[0].id }); + pendingOverwrites.add(`${type}:${id}`); + } else { + const { title } = attributes; + errors.push({ + type, + id, + title, + meta: { title }, + error: { + type: 'conflict', + destinationId: destinations[0].id, + }, + }); + } + return; + } + // This is an ambiguous conflict error, which is one of the following cases: + // - a single import object has 2+ destination conflicts ("ambiguous destination") + // - 2+ import objects have the same single destination conflict ("ambiguous source") + // - 2+ import objects have the same 2+ destination conflicts ("ambiguous source and destination") + if (sources.length > 1) { + // In the case of ambiguous source conflicts, don't treat them as errors; instead, regenerate the object ID and reset its origin + // (e.g., the same outcome as if `createNewCopies` was enabled for the entire import operation). + importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId: true }); + return; + } + const { title } = attributes; + errors.push({ + type, + id, + title, + meta: { title }, + error: { + type: 'ambiguous_conflict', + destinations, + }, + }); + }); + + return { errors, importIdMap, pendingOverwrites }; +} + +/** + * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). + */ +export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { + const { objects, retries, createNewCopies } = params; + + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const importIdMap = new Map(); + + objects.forEach(({ type, id }) => { + const retry = retryMap.get(`${type}:${id}`); + if (!retry) { + throw new Error(`Retry was expected for "${type}:${id}" but not found`); + } + const { destinationId } = retry; + const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); + if (destinationId && destinationId !== id) { + importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); + } + }); + + return importIdMap; +} diff --git a/src/core/server/saved_objects/import/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/collect_saved_objects.test.ts index 9cccc3942f655..f54130be326ad 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.test.ts @@ -17,121 +17,192 @@ * under the License. */ -import { Readable } from 'stream'; +import { Readable, PassThrough } from 'stream'; import { collectSavedObjects } from './collect_saved_objects'; +import { createLimitStream } from './create_limit_stream'; +import { getNonUniqueEntries } from './get_non_unique_entries'; + +jest.mock('./create_limit_stream'); +jest.mock('./get_non_unique_entries'); + +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; + +let limitStreamPush: jest.SpyInstance; + +beforeEach(() => { + jest.clearAllMocks(); + const stream = new PassThrough({ objectMode: true }); + limitStreamPush = jest.spyOn(stream, 'push'); + getMockFn(createLimitStream).mockReturnValue(stream); + getMockFn(getNonUniqueEntries).mockReturnValue([]); +}); describe('collectSavedObjects()', () => { - test('collects nothing when stream is empty', async () => { - const readStream = new Readable({ + const objectLimit = 10; + const createReadStream = (...args: any[]) => + new Readable({ objectMode: true, read() { + args.forEach((arg) => this.push(arg)); this.push(null); }, }); - const result = await collectSavedObjects({ readStream, objectLimit: 10, supportedTypes: [] }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [], - "errors": Array [], -} -`); - }); - test('collects objects from stream', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push(null); - }, + const obj1 = { type: 'a', id: '1', attributes: { title: 'my title 1' } }; + const obj2 = { type: 'b', id: '2', attributes: { title: 'my title 2' } }; + + describe('module calls', () => { + test('limit stream with empty input stream is called with null', async () => { + const readStream = createReadStream(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(createLimitStream).toHaveBeenCalledWith(objectLimit); + expect(limitStreamPush).toHaveBeenCalledTimes(1); + expect(limitStreamPush).toHaveBeenLastCalledWith(null); }); - const result = await collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], + + test('limit stream with non-empty input stream is called with all objects', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + expect(createLimitStream).toHaveBeenCalledWith(objectLimit); + expect(limitStreamPush).toHaveBeenCalledTimes(3); + expect(limitStreamPush).toHaveBeenNthCalledWith(1, obj1); + expect(limitStreamPush).toHaveBeenNthCalledWith(2, obj2); + expect(limitStreamPush).toHaveBeenLastCalledWith(null); }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [ - Object { - "foo": true, - "migrationVersion": Object {}, - "type": "a", - }, - ], - "errors": Array [], -} -`); - }); - test('throws error when object limit is reached', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push({ bar: true, type: 'a' }); - this.push(null); - }, + test('get non-unique entries with empty input stream is called with empty array', async () => { + const readStream = createReadStream(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(getNonUniqueEntries).toHaveBeenCalledWith([]); }); - await expect( - collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`); - }); - test('unsupported types return as import errors', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ id: '1', type: 'a', attributes: { title: 'my title' } }); - this.push({ id: '2', type: 'b', attributes: { title: 'my title 2' } }); - this.push(null); - }, + test('get non-unique entries with non-empty input stream is called with all entries', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + expect(getNonUniqueEntries).toHaveBeenCalledWith([ + { type: obj1.type, id: obj1.id }, + { type: obj2.type, id: obj2.id }, + ]); + }); + + test('filter with empty input stream is not called', async () => { + const readStream = createReadStream(); + const filter = jest.fn(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit, filter }); + + expect(filter).not.toHaveBeenCalled(); + }); + + test('filter with non-empty input stream is called with all objects of supported types', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn(); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit, filter }); + + expect(filter).toHaveBeenCalledTimes(1); + expect(filter).toHaveBeenCalledWith(obj2); }); - const result = await collectSavedObjects({ readStream, objectLimit: 2, supportedTypes: ['1'] }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [], - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "a", - }, - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "2", - "title": "my title 2", - "type": "b", - }, - ], -} -`); }); - test('unsupported types still count towards object limit', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push({ bar: true, type: 'b' }); - this.push(null); - }, + describe('results', () => { + test('throws Boom error if any import objects are not unique', async () => { + getMockFn(getNonUniqueEntries).mockReturnValue(['type1:id1', 'type2:id2']); + const readStream = createReadStream(); + expect.assertions(2); + try { + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique import objects detected: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('collects nothing when stream is empty', async () => { + const readStream = createReadStream(); + const result = await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(result).toEqual({ collectedObjects: [], errors: [], importIdMap: new Map() }); + }); + + test('collects objects from stream', async () => { + const readStream = createReadStream(obj1); + const supportedTypes = [obj1.type]; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const collectedObjects = [{ ...obj1, migrationVersion: {} }]; + const importIdMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); + expect(result).toEqual({ collectedObjects, errors: [], importIdMap }); + }); + + test('unsupported types return as import errors', async () => { + const readStream = createReadStream(obj1); + const supportedTypes = ['not-obj1-type']; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const error = { type: 'unsupported_type' }; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + }); + + test('returns mixed results', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const collectedObjects = [{ ...obj2, migrationVersion: {} }]; + const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const error = { type: 'unsupported_type' }; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + expect(result).toEqual({ collectedObjects, errors, importIdMap }); + }); + + describe('with optional filter', () => { + test('filters out objects when result === false', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn().mockReturnValue(false); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + filter, + }); + + const error = { type: 'unsupported_type' }; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + }); + + test('does not filter out objects when result === true', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn().mockReturnValue(true); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + filter, + }); + + const collectedObjects = [{ ...obj2, migrationVersion: {} }]; + const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const error = { type: 'unsupported_type' }; + const { title } = obj1.attributes; + const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + expect(result).toEqual({ collectedObjects, errors, importIdMap }); + }); }); - await expect( - collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`); }); }); diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 1b787c7d9dc10..f55e6bf0d2af4 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -27,6 +27,8 @@ import { import { SavedObject } from '../types'; import { createLimitStream } from './create_limit_stream'; import { SavedObjectsImportError } from './types'; +import { getNonUniqueEntries } from './get_non_unique_entries'; +import { SavedObjectsErrorHelpers } from '..'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -42,17 +44,22 @@ export async function collectSavedObjects({ supportedTypes, }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportError[] = []; - const collectedObjects: Array> = await createPromiseFromStreams([ + const entries: Array<{ type: string; id: string }> = []; + const importIdMap = new Map(); + const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), createFilterStream>((obj) => { + entries.push({ type: obj.type, id: obj.id }); if (supportedTypes.includes(obj.type)) { return true; } + const { title } = obj.attributes; errors.push({ id: obj.id, type: obj.type, - title: obj.attributes.title, + title, + meta: { title }, error: { type: 'unsupported_type', }, @@ -61,13 +68,24 @@ export async function collectSavedObjects({ }), createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { + importIdMap.set(`${obj.type}:${obj.id}`, {}); // Ensure migrations execute on every saved object return Object.assign({ migrationVersion: {} }, obj); }), createConcatStream([]), ]); + + // throw a BadRequest error if we see the same import object type/id more than once + const nonUniqueEntries = getNonUniqueEntries(entries); + if (nonUniqueEntries.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique import objects detected: [${nonUniqueEntries.join()}]` + ); + } + return { errors, collectedObjects, + importIdMap, }; } diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts new file mode 100644 index 0000000000000..6c396e58e1a28 --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -0,0 +1,309 @@ +/* + * 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 { savedObjectsClientMock } from '../../mocks'; +import { createSavedObjects } from './create_saved_objects'; +import { SavedObjectsClientContract, SavedObject, SavedObjectsImportError } from '../types'; +import { SavedObjectsErrorHelpers } from '..'; +import { extractErrors } from './extract_errors'; + +type CreateSavedObjectsParams = Parameters[0]; + +/** + * Function to create a realistic-looking import object given a type, ID, and optional originId + */ +const createObject = (type: string, id: string, originId?: string): SavedObject => ({ + type, + id, + attributes: {}, + references: [ + { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-3' }, // object that is present and has an importIdMap entry + ], + ...(originId && { originId }), +}); + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; +/** + * Create a variety of different objects to exercise different import / result scenarios + */ +const obj1 = createObject(MULTI_NS_TYPE, 'id-1', 'originId-a'); // -> success +const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-b'); // -> conflict +const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-c'); // -> conflict (with known importId and omitOriginId=true) +const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'originId-d'); // -> conflict (with known importId) +const obj5 = createObject(MULTI_NS_TYPE, 'id-5', 'originId-e'); // -> unresolvable conflict +const obj6 = createObject(MULTI_NS_TYPE, 'id-6'); // -> success +const obj7 = createObject(MULTI_NS_TYPE, 'id-7'); // -> conflict +const obj8 = createObject(MULTI_NS_TYPE, 'id-8'); // -> conflict (with known importId) +const obj9 = createObject(MULTI_NS_TYPE, 'id-9'); // -> unresolvable conflict +const obj10 = createObject(OTHER_TYPE, 'id-10', 'originId-f'); // -> success +const obj11 = createObject(OTHER_TYPE, 'id-11', 'originId-g'); // -> conflict +const obj12 = createObject(OTHER_TYPE, 'id-12'); // -> success +const obj13 = createObject(OTHER_TYPE, 'id-13'); // -> conflict +// non-multi-namespace types shouldn't have origin IDs, but we include test cases to ensure it's handled gracefully +// non-multi-namespace types by definition cannot result in an unresolvable conflict, so we don't include test cases for those +const importId3 = 'id-foo'; +const importId4 = 'id-bar'; +const importId8 = 'id-baz'; +const importIdMap = new Map([ + [`${obj3.type}:${obj3.id}`, { id: importId3, omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { id: importId4 }], + [`${obj8.type}:${obj8.id}`, { id: importId8 }], +]); + +describe('#createSavedObjects', () => { + let savedObjectsClient: jest.Mocked; + let bulkCreate: typeof savedObjectsClient['bulkCreate']; + + /** + * Creates an options object to be used as an argument for createSavedObjects + * Includes mock savedObjectsClient + */ + const setupParams = (partial: { + objects: SavedObject[]; + accumulatedErrors?: SavedObjectsImportError[]; + namespace?: string; + overwrite?: boolean; + }): CreateSavedObjectsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + bulkCreate = savedObjectsClient.bulkCreate; + return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; + }; + + const getExpectedBulkCreateArgsObjects = (objects: SavedObject[], retry?: boolean) => + objects.map(({ type, id, attributes, originId }) => ({ + type, + id: retry ? `new-id-for-${id}` : id, // if this was a retry, we regenerated the id -- this is mocked below + attributes, + references: [ + { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-foo' }, // object that is present and has an importIdMap entry + ], + // if the import object had an originId, and/or if we regenerated the id, expect an originId to be included in the create args + ...((originId || retry) && { originId: originId || id }), + })); + + const expectBulkCreateArgs = { + objects: (n: number, objects: SavedObject[], retry?: boolean) => { + const expectedObjects = getExpectedBulkCreateArgsObjects(objects, retry); + const expectedOptions = expect.any(Object); + expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedObjects, expectedOptions); + }, + options: (n: number, options: CreateSavedObjectsParams) => { + const expectedObjects = expect.any(Array); + const expectedOptions = { namespace: options.namespace, overwrite: options.overwrite }; + expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedObjects, expectedOptions); + }, + }; + + const getResultMock = { + success: ( + { type, id, attributes, references, originId }: SavedObject, + { namespace }: CreateSavedObjectsParams + ): SavedObject => ({ + type, + id, + attributes, + references, + ...(originId && { originId }), + version: 'some-version', + updated_at: 'some-date', + namespaces: [namespace ?? 'default'], + }), + conflict: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return ({ type, id, error } as unknown) as SavedObject; + }, + unresolvableConflict: (type: string, id: string) => { + const conflictMock = getResultMock.conflict(type, id); + conflictMock.error!.metadata = { isNotOverwritable: true }; + return conflictMock; + }, + }; + + /** + * Remap the bulkCreate results to ensure that each returned object reflects the ID of the imported object. + * This is needed because createSavedObjects may change the ID of the object to create, but this process is opaque to consumers of the + * API; we have to remap IDs of results so consumers can act upon them, as there is no guarantee that results will be returned in the same + * order as they were imported in. + * For the purposes of this test suite, the objects ARE guaranteed to be in the same order, so we do a simple loop to remap the IDs. + * In addition, extract the errors out of the created objects -- since we are testing with realistic objects/errors, we can use the real + * `extractErrors` module to do so. + */ + const getExpectedResults = (resultObjects: SavedObject[], objects: SavedObject[]) => { + const remappedResults = resultObjects.map((result, i) => ({ ...result, id: objects[i].id })); + return { + createdObjects: remappedResults.filter((obj) => !obj.error), + errors: extractErrors(remappedResults, objects), + }; + }; + + test('filters out objects that have errors present', async () => { + const error = { type: obj1.type, id: obj1.id } as SavedObjectsImportError; + const options = setupParams({ objects: [obj1], accumulatedErrors: [error] }); + + const createSavedObjectsResult = await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); + }); + + test('exits early if there are no objects to create', async () => { + const options = setupParams({ objects: [] }); + + const createSavedObjectsResult = await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); + }); + + const objs = [obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13]; + + const setupMockResults = (options: CreateSavedObjectsParams) => { + bulkCreate.mockResolvedValue({ + saved_objects: [ + getResultMock.success(obj1, options), + getResultMock.conflict(obj2.type, obj2.id), + getResultMock.conflict(obj3.type, importId3), + getResultMock.conflict(obj4.type, importId4), + getResultMock.unresolvableConflict(obj5.type, obj5.id), + getResultMock.success(obj6, options), + getResultMock.conflict(obj7.type, obj7.id), + getResultMock.conflict(obj8.type, importId8), + getResultMock.unresolvableConflict(obj9.type, obj9.id), + getResultMock.success(obj10, options), + getResultMock.conflict(obj11.type, obj11.id), + getResultMock.success(obj12, options), + getResultMock.conflict(obj13.type, obj13.id), + ], + }); + }; + + describe('handles accumulated errors as expected', () => { + const resolvableErrors: SavedObjectsImportError[] = [ + { type: 'foo', id: 'foo-id', error: { type: 'conflict' } } as SavedObjectsImportError, + { + type: 'bar', + id: 'bar-id', + error: { type: 'ambiguous_conflict' }, + } as SavedObjectsImportError, + { + type: 'baz', + id: 'baz-id', + error: { type: 'missing_references' }, + } as SavedObjectsImportError, + ]; + const unresolvableErrors: SavedObjectsImportError[] = [ + { type: 'qux', id: 'qux-id', error: { type: 'unsupported_type' } } as SavedObjectsImportError, + { type: 'quux', id: 'quux-id', error: { type: 'unknown' } } as SavedObjectsImportError, + ]; + + test('does not call bulkCreate when resolvable errors are present', async () => { + for (const error of resolvableErrors) { + const options = setupParams({ objects: objs, accumulatedErrors: [error] }); + await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + } + }); + + test('calls bulkCreate when unresolvable errors or no errors are present', async () => { + for (const error of unresolvableErrors) { + const options = setupParams({ objects: objs, accumulatedErrors: [error] }); + setupMockResults(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + bulkCreate.mockClear(); + } + const options = setupParams({ objects: objs }); + setupMockResults(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + }); + }); + + it('filters out version from objects before create', async () => { + const options = setupParams({ objects: [{ ...obj1, version: 'foo' }] }); + bulkCreate.mockResolvedValue({ saved_objects: [getResultMock.success(obj1, options)] }); + + await createSavedObjects(options); + expectBulkCreateArgs.objects(1, [obj1]); + }); + + const testBulkCreateObjects = async (namespace?: string) => { + const options = setupParams({ objects: objs, namespace }); + setupMockResults(options); + + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + // these three objects are transformed before being created, because they are included in the `importIdMap` + const x3 = { ...obj3, id: importId3, originId: undefined }; // this import object already has an originId, but the entry has omitOriginId=true + const x4 = { ...obj4, id: importId4 }; // this import object already has an originId + const x8 = { ...obj8, id: importId8, originId: obj8.id }; // this import object doesn't have an originId, so it is set before create + const argObjs = [obj1, obj2, x3, x4, obj5, obj6, obj7, x8, obj9, obj10, obj11, obj12, obj13]; + expectBulkCreateArgs.objects(1, argObjs); + }; + const testBulkCreateOptions = async (namespace?: string) => { + const overwrite = (Symbol() as unknown) as boolean; + const options = setupParams({ objects: objs, namespace, overwrite }); + setupMockResults(options); + + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + expectBulkCreateArgs.options(1, options); + }; + const testReturnValue = async (namespace?: string) => { + const options = setupParams({ objects: objs, namespace }); + setupMockResults(options); + + const results = await createSavedObjects(options); + const resultSavedObjects = (await bulkCreate.mock.results[0].value).saved_objects; + const [r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13] = resultSavedObjects; + // these three results are transformed before being returned, because the bulkCreate attempt used different IDs for them + const [x3, x4, x8] = [r3, r4, r8].map((x: SavedObject) => ({ ...x, destinationId: x.id })); + const transformedResults = [r1, r2, x3, x4, r5, r6, r7, x8, r9, r10, r11, r12, r13]; + const expectedResults = getExpectedResults(transformedResults, objs); + expect(results).toEqual(expectedResults); + }; + + describe('with an undefined namespace', () => { + test('calls bulkCreate once with input objects', async () => { + await testBulkCreateObjects(); + }); + test('calls bulkCreate once with input options', async () => { + await testBulkCreateOptions(); + }); + test('returns bulkCreate results that are remapped to IDs of imported objects', async () => { + await testReturnValue(); + }); + }); + + describe('with a defined namespace', () => { + const namespace = 'some-namespace'; + test('calls bulkCreate once with input objects', async () => { + await testBulkCreateObjects(namespace); + }); + test('calls bulkCreate once with input options', async () => { + await testBulkCreateOptions(namespace); + }); + test('returns bulkCreate results that are remapped to IDs of imported objects', async () => { + await testReturnValue(namespace); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts new file mode 100644 index 0000000000000..9930e9c69358a --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -0,0 +1,111 @@ +/* + * 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 { SavedObject, SavedObjectsClientContract, SavedObjectsImportError } from '../types'; +import { extractErrors } from './extract_errors'; +import { CreatedObject } from './types'; + +interface CreateSavedObjectsParams { + objects: Array>; + accumulatedErrors: SavedObjectsImportError[]; + savedObjectsClient: SavedObjectsClientContract; + importIdMap: Map; + namespace?: string; + overwrite?: boolean; +} +interface CreateSavedObjectsResult { + createdObjects: Array>; + errors: SavedObjectsImportError[]; +} + +/** + * This function abstracts the bulk creation of import objects. The main reason for this is that the import ID map should dictate the IDs of + * the objects we create, and the create results should be mapped to the original IDs that consumers will be able to understand. + */ +export const createSavedObjects = async ({ + objects, + accumulatedErrors, + savedObjectsClient, + importIdMap, + namespace, + overwrite, +}: CreateSavedObjectsParams): Promise> => { + // filter out any objects that resulted in errors + const errorSet = accumulatedErrors.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const filteredObjects = objects.filter(({ type, id }) => !errorSet.has(`${type}:${id}`)); + + // exit early if there are no objects to create + if (filteredObjects.length === 0) { + return { createdObjects: [], errors: [] }; + } + + // generate a map of the raw object IDs + const objectIdMap = filteredObjects.reduce( + (map, object) => map.set(`${object.type}:${object.id}`, object), + new Map>() + ); + + // filter out the 'version' field of each object, if it exists + const objectsToCreate = filteredObjects.map(({ version, ...object }) => { + // use the import ID map to ensure that each reference is being created with the correct ID + const references = object.references?.map((reference) => { + const { type, id } = reference; + const importIdEntry = importIdMap.get(`${type}:${id}`); + if (importIdEntry?.id) { + return { ...reference, id: importIdEntry.id }; + } + return reference; + }); + // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on + // the created object if it did not have one (or is omitted if specified) + const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); + if (importIdEntry?.id) { + objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); + const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; + return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; + } + return { ...object, ...(references && { references }) }; + }); + + const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; + let expectedResults = objectsToCreate; + if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) { + const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { + namespace, + overwrite, + }); + expectedResults = bulkCreateResponse.saved_objects; + } + + // remap results to reflect the object IDs that were submitted for import + // this ensures that consumers understand the results + const remappedResults = expectedResults.map>((result) => { + const { id } = objectIdMap.get(`${result.type}:${result.id}`)!; + // also, include a `destinationId` field if the object create attempt was made with a different ID + return { ...result, id, ...(id !== result.id && { destinationId: result.id }) }; + }); + + return { + createdObjects: remappedResults.filter((obj) => !obj.error), + errors: extractErrors(remappedResults, objects), + }; +}; diff --git a/src/core/server/saved_objects/import/extract_errors.test.ts b/src/core/server/saved_objects/import/extract_errors.test.ts index f97cc661c0bca..047c4ae36266f 100644 --- a/src/core/server/saved_objects/import/extract_errors.test.ts +++ b/src/core/server/saved_objects/import/extract_errors.test.ts @@ -19,6 +19,8 @@ import { SavedObject } from '../types'; import { extractErrors } from './extract_errors'; +import { SavedObjectsErrorHelpers } from '..'; +import { CreatedObject } from './types'; describe('extractErrors()', () => { test('returns empty array when no errors exist', () => { @@ -28,38 +30,34 @@ describe('extractErrors()', () => { }); test('extracts errors from saved objects', () => { - const savedObjects: SavedObject[] = [ + const savedObjects: Array> = [ { id: '1', type: 'dashboard', - attributes: { - title: 'My Dashboard 1', - }, + attributes: { title: 'My Dashboard 1' }, references: [], }, { id: '2', type: 'dashboard', - attributes: { - title: 'My Dashboard 2', - }, + attributes: { title: 'My Dashboard 2' }, references: [], - error: { - statusCode: 409, - message: 'Conflict', - }, + error: SavedObjectsErrorHelpers.createConflictError('dashboard', '2').output.payload, }, { id: '3', type: 'dashboard', - attributes: { - title: 'My Dashboard 3', - }, + attributes: { title: 'My Dashboard 3' }, references: [], - error: { - statusCode: 400, - message: 'Bad Request', - }, + error: SavedObjectsErrorHelpers.createBadRequestError().output.payload, + }, + { + id: '4', + type: 'dashboard', + attributes: { title: 'My Dashboard 4' }, + references: [], + error: SavedObjectsErrorHelpers.createConflictError('dashboard', '4').output.payload, + destinationId: 'foo', }, ]; const result = extractErrors(savedObjects, savedObjects); @@ -70,19 +68,38 @@ Array [ "type": "conflict", }, "id": "2", + "meta": Object { + "title": "My Dashboard 2", + }, "title": "My Dashboard 2", "type": "dashboard", }, Object { "error": Object { + "error": "Bad Request", "message": "Bad Request", "statusCode": 400, "type": "unknown", }, "id": "3", + "meta": Object { + "title": "My Dashboard 3", + }, "title": "My Dashboard 3", "type": "dashboard", }, + Object { + "error": Object { + "destinationId": "foo", + "type": "conflict", + }, + "id": "4", + "meta": Object { + "title": "My Dashboard 4", + }, + "title": "My Dashboard 4", + "type": "dashboard", + }, ] `); }); diff --git a/src/core/server/saved_objects/import/extract_errors.ts b/src/core/server/saved_objects/import/extract_errors.ts index 5728ce8b7b59f..6a7e5d4d9dfa4 100644 --- a/src/core/server/saved_objects/import/extract_errors.ts +++ b/src/core/server/saved_objects/import/extract_errors.ts @@ -17,11 +17,11 @@ * under the License. */ import { SavedObject } from '../types'; -import { SavedObjectsImportError } from './types'; +import { SavedObjectsImportError, CreatedObject } from './types'; export function extractErrors( // TODO: define saved object type - savedObjectResults: Array>, + savedObjectResults: Array>, savedObjectsToImport: Array> ) { const errors: SavedObjectsImportError[] = []; @@ -34,17 +34,17 @@ export function extractErrors( const originalSavedObject = originalSavedObjectsMap.get( `${savedObject.type}:${savedObject.id}` ); - const title = - originalSavedObject && - originalSavedObject.attributes && - originalSavedObject.attributes.title; + const title = originalSavedObject?.attributes?.title; + const { destinationId } = savedObject; if (savedObject.error.statusCode === 409) { errors.push({ id: savedObject.id, type: savedObject.type, title, + meta: { title }, error: { type: 'conflict', + ...(destinationId && { destinationId }), }, }); continue; @@ -53,6 +53,7 @@ export function extractErrors( id: savedObject.id, type: savedObject.type, title, + meta: { title }, error: { ...savedObject.error, type: 'unknown', diff --git a/src/core/server/saved_objects/import/get_non_unique_entries.test.ts b/src/core/server/saved_objects/import/get_non_unique_entries.test.ts new file mode 100644 index 0000000000000..a66fa437142d3 --- /dev/null +++ b/src/core/server/saved_objects/import/get_non_unique_entries.test.ts @@ -0,0 +1,38 @@ +/* + * 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 { getNonUniqueEntries } from './get_non_unique_entries'; + +const foo1 = { type: 'foo', id: '1' }; +const foo2 = { type: 'foo', id: '2' }; // same type as foo1, different ID +const bar1 = { type: 'bar', id: '1' }; // same ID as foo1, different type + +describe('#getNonUniqueEntries', () => { + test('returns empty array if entries are unique', () => { + const result = getNonUniqueEntries([foo1, foo2, bar1]); + expect(result).toEqual([]); + }); + + test('returns non-empty array for non-unique results', () => { + const result1 = getNonUniqueEntries([foo1, foo2, foo1]); + const result2 = getNonUniqueEntries([foo1, foo2, foo1, foo2]); + expect(result1).toEqual([`${foo1.type}:${foo1.id}`]); + expect(result2).toEqual([`${foo1.type}:${foo1.id}`, `${foo2.type}:${foo2.id}`]); + }); +}); diff --git a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.js b/src/core/server/saved_objects/import/get_non_unique_entries.ts similarity index 57% rename from src/legacy/core_plugins/elasticsearch/lib/version_health_check.js rename to src/core/server/saved_objects/import/get_non_unique_entries.ts index b1a106d2aae5d..468bf73d9b2db 100644 --- a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.js +++ b/src/core/server/saved_objects/import/get_non_unique_entries.ts @@ -17,23 +17,19 @@ * under the License. */ -export const versionHealthCheck = (esPlugin, logWithMetadata, esNodesCompatibility$) => { - esPlugin.status.yellow('Waiting for Elasticsearch'); +type Entries = Array<{ type: string; id: string }>; - return new Promise((resolve) => { - esNodesCompatibility$.subscribe(({ isCompatible, message, kibanaVersion, warningNodes }) => { - if (!isCompatible) { - esPlugin.status.red(message); - } else { - if (message) { - logWithMetadata(['warning'], message, { - kibanaVersion, - nodes: warningNodes, - }); - } - esPlugin.status.green('Ready'); - resolve(); - } - }); +export const getNonUniqueEntries = (objects: Entries) => { + const idCountMap = objects.reduce((acc, { type, id }) => { + const key = `${type}:${id}`; + const val = acc.get(key) ?? 0; + return acc.set(key, val + 1); + }, new Map()); + const nonUniqueEntries: string[] = []; + idCountMap.forEach((value, key) => { + if (value >= 2) { + nonUniqueEntries.push(key); + } }); + return nonUniqueEntries; }; diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index e204cd7bddfc7..77f49e336a7b9 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -18,602 +18,432 @@ */ import { Readable } from 'stream'; -import { SavedObject } from '../types'; -import { importSavedObjectsFromStream } from './import_saved_objects'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObjectsClientContract, + SavedObjectsType, + SavedObject, + SavedObjectsImportError, +} from '../types'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsImportOptions, ISavedObjectTypeRegistry } from '..'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { importSavedObjectsFromStream } from './import_saved_objects'; -const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, -}; -describe('importSavedObjects()', () => { - const savedObjects: SavedObject[] = [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - references: [], - }, - { - id: '2', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [], - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [], - }, - { - id: '4', - type: 'dashboard', - attributes: { - title: 'My Dashboard', - }, - references: [], - }, - ]; - const savedObjectsClient = savedObjectsClientMock.create(); +import { collectSavedObjects } from './collect_saved_objects'; +import { regenerateIds } from './regenerate_ids'; +import { validateReferences } from './validate_references'; +import { checkConflicts } from './check_conflicts'; +import { checkOriginConflicts } from './check_origin_conflicts'; +import { createSavedObjects } from './create_saved_objects'; - beforeEach(() => { - jest.resetAllMocks(); - }); +jest.mock('./collect_saved_objects'); +jest.mock('./regenerate_ids'); +jest.mock('./validate_references'); +jest.mock('./check_conflicts'); +jest.mock('./check_origin_conflicts'); +jest.mock('./create_saved_objects'); - test('returns early when no objects exist', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push(null); - }, - }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 1, - overwrite: false, - savedObjectsClient, - supportedTypes: [], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 0, - } - `); - }); +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; - test('calls bulkCreate without overwrite', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, +describe('#importSavedObjectsFromStream', () => { + beforeEach(() => { + jest.clearAllMocks(); + // mock empty output of each of these mocked modules so the import doesn't throw an error + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects: [], + importIdMap: new Map(), }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(validateReferences).mockResolvedValue([]); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); - test('uses the provided namespace when present', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, - }); - const result = await importSavedObjectsFromStream({ + let readStream: Readable; + const objectLimit = 10; + const overwrite = (Symbol() as unknown) as boolean; + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + const namespace = 'some-namespace'; + + const setupOptions = (createNewCopies: boolean = false): SavedObjectsImportOptions => { + readStream = new Readable(); + savedObjectsClient = savedObjectsClientMock.create(); + typeRegistry = typeRegistryMock.create(); + typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); + return { readStream, - objectLimit: 4, - overwrite: false, + objectLimit, + overwrite, savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - namespace: 'foo', + typeRegistry, + namespace, + createNewCopies, + }; + }; + const createObject = (): SavedObject<{ + title: string; + }> => { + return { + type: 'foo-type', + id: uuidv4(), + references: [], + attributes: { title: 'some-title' }, + }; + }; + const createError = (): SavedObjectsImportError => { + const title = 'some-title'; + return { + type: 'foo-type', + id: uuidv4(), + title: 'some-title', + meta: { title }, + error: { type: 'conflict' }, + }; + }; + + /** + * These tests use minimal mocks which don't look realistic, but are sufficient to exercise the code paths correctly. For example, for an + * object to be imported successfully it would need to be obtained from `collectSavedObjects`, passed to `validateReferences`, passed to + * `checkOriginConflicts`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the + * intermediate steps in the interest of brevity. + */ + describe('module calls', () => { + test('collects saved objects from stream', async () => { + const options = setupOptions(); + const supportedTypes = ['foo-type']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await importSavedObjectsFromStream(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + const collectSavedObjectsOptions = { readStream, objectLimit, supportedTypes }; + expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": "foo", - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test('calls bulkCreate with overwrite', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + await importSavedObjectsFromStream(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace + ); }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + + describe('with createNewCopies disabled', () => { + test('does not regenerate object IDs', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + await importSavedObjectsFromStream(options); + expect(regenerateIds).not.toHaveBeenCalled(); + }); + + test('checks conflicts', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + await importSavedObjectsFromStream(options); + const checkConflictsParams = { + objects: collectedObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, + }; + expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + }); + + test('checks origin conflicts', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + const importIdMap = new Map(); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap, + pendingOverwrites: new Set(), + }); + + await importSavedObjectsFromStream(options); + const checkOriginConflictsParams = { + objects: filteredObjects, + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIdMap, + }; + expect(checkOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); + }); + + test('creates saved objects', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + const filteredObjects = [createObject()]; + const errors = [createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects, + importIdMap: new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ]), + }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects, + importIdMap: new Map([['bar', { id: 'newId1' }]]), + pendingOverwrites: new Set(), + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + importIdMap: new Map([['baz', { id: 'newId2' }]]), + pendingOverwrites: new Set(), + }); + + await importSavedObjectsFromStream(options); + const importIdMap = new Map([ + ['foo', {}], + ['bar', { id: 'newId1' }], + ['baz', { id: 'newId2' }], + ]); + const createSavedObjectsParams = { + objects: collectedObjects, + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + overwrite, + namespace, + }; + expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: true, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + describe('with createNewCopies enabled', () => { + test('regenerates object IDs', async () => { + const options = setupOptions(true); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await importSavedObjectsFromStream(options); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + }); + + test('does not check conflicts or check origin conflicts', async () => { + const options = setupOptions(true); + getMockFn(validateReferences).mockResolvedValue([]); + + await importSavedObjectsFromStream(options); + expect(checkConflicts).not.toHaveBeenCalled(); + expect(checkOriginConflicts).not.toHaveBeenCalled(); + }); + + test('creates saved objects', async () => { + const options = setupOptions(true); + const collectedObjects = [createObject()]; + const errors = [createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects, + importIdMap: new Map([ + ['foo', {}], + ['bar', {}], + ]), + }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + // this importIdMap is not composed with the one obtained from `collectSavedObjects` + const importIdMap = new Map().set(`id1`, { id: `newId1` }); + getMockFn(regenerateIds).mockReturnValue(importIdMap); + + await importSavedObjectsFromStream(options); + const createSavedObjectsParams = { + objects: collectedObjects, + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + overwrite, + namespace, + }; + expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); }); - test('extracts errors for conflicts', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.map((savedObject) => ({ - type: savedObject.type, - id: savedObject.id, - error: { - statusCode: 409, - message: 'conflict', - }, - attributes: {}, - references: [], - })), + describe('results', () => { + test('returns success=true if no errors occurred', async () => { + const options = setupOptions(); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: true, successCount: 0 }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('returns success=false if an error occurred', async () => { + const options = setupOptions(); + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [createError()], + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: false, successCount: 0, errors: [expect.any(Object)] }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, - } - `); - }); - test('validates references', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ - id: '1', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '2', - }, - ], + describe('handles a mix of successes and errors and injects metadata', () => { + const obj1 = createObject(); + const tmp = createObject(); + const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; + const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId + const createdObjects = [obj1, obj2, obj3]; + const error1 = createError(); + const error2 = createError(); + // results + const success1 = { + type: obj1.type, + id: obj1.id, + meta: { title: obj1.attributes.title, icon: `${obj1.type}-icon` }, + }; + const success2 = { + type: obj2.type, + id: obj2.id, + meta: { title: obj2.attributes.title, icon: `${obj2.type}-icon` }, + destinationId: obj2.destinationId, + }; + const success3 = { + type: obj3.type, + id: obj3.id, + meta: { title: obj3.attributes.title, icon: `${obj3.type}-icon` }, + destinationId: obj3.destinationId, + }; + const errors = [error1, error2]; + + test('with createNewCopies disabled', async () => { + const options = setupOptions(); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set([ + `${success2.type}:${success2.id}`, // the success2 object was overwritten + `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object + ]), }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], + getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + + const result = await importSavedObjectsFromStream(options); + // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) + const successResults = [ + success1, + { ...success2, overwrite: true }, + // `createNewCopies` mode is not enabled, but obj3 ran into an ambiguous source conflict and it was created with an empty + // originId; hence, this specific object is a new copy -- we would need this information for rendering the appropriate originId + // in the client UI, and we would need it to construct a retry for this object if other objects had errors that needed to be + // resolved + { ...success3, createNewCopy: true }, + ]; + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` }, overwrite: true }, + ]; + expect(result).toEqual({ + success: false, + successCount: 3, + successResults, + errors: errorResults, }); - this.push(null); - }, - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: '2', - error: { - statusCode: 404, - message: 'Not found', - }, - attributes: {}, - references: [], - }, - ], - }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ - Object { - "id": "3", - "type": "visualization", - }, - ], - "references": Array [ - Object { - "id": "2", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + }); - test('validates supported types', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push({ id: '1', type: 'wigwags', attributes: { title: 'my title' }, references: [] }); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + test('with createNewCopies enabled', async () => { + // however, we include it here for posterity + const options = setupOptions(true); + getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + + const result = await importSavedObjectsFromStream(options); + // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) + // obj2 being created with createNewCopies mode enabled isn't a realistic test case (all objects would have originId omitted) + const successResults = [success1, success2, success3]; + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` } }, + ]; + expect(result).toEqual({ + success: false, + successCount: 3, + successResults, + errors: errorResults, + }); + }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 5, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('accumulates multiple errors', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map(), // doesn't matter + pendingOverwrites: new Set(), + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + importIdMap: new Map(), // doesn't matter + pendingOverwrites: new Set(), + }); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); + + const result = await importSavedObjectsFromStream(options); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); }); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 4956491a79aa9..4530c7ff427da 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -18,14 +18,16 @@ */ import { collectSavedObjects } from './collect_saved_objects'; -import { extractErrors } from './extract_errors'; import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsImportOptions, } from './types'; import { validateReferences } from './validate_references'; -import { SavedObject } from '../types'; +import { checkOriginConflicts } from './check_origin_conflicts'; +import { createSavedObjects } from './create_saved_objects'; +import { checkConflicts } from './check_conflicts'; +import { regenerateIds } from './regenerate_ids'; /** * Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -37,53 +39,106 @@ export async function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, + createNewCopies, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); // Get the objects to import - const { - errors: collectorErrors, - collectedObjects: objectsFromStream, - } = await collectSavedObjects({ readStream, objectLimit, supportedTypes }); - errorAccumulator = [...errorAccumulator, ...collectorErrors]; + const collectSavedObjectsResult = await collectSavedObjects({ + readStream, + objectLimit, + supportedTypes, + }); + errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; + /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ + let importIdMap = collectSavedObjectsResult.importIdMap; + let pendingOverwrites = new Set(); // Validate references - const { filteredObjects, errors: validationErrors } = await validateReferences( - objectsFromStream, + const validateReferencesResult = await validateReferences( + collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace ); - errorAccumulator = [...errorAccumulator, ...validationErrors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; - // Exit early if no objects to import - if (filteredObjects.length === 0) { - return { - success: errorAccumulator.length === 0, - successCount: 0, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + if (createNewCopies) { + importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + } else { + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsParams = { + objects: collectSavedObjectsResult.collectedObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, + }; + const checkConflictsResult = await checkConflicts(checkConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + pendingOverwrites = checkConflictsResult.pendingOverwrites; + + // Check multi-namespace object types for origin conflicts in this namespace + const checkOriginConflictsParams = { + objects: checkConflictsResult.filteredObjects, + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIdMap, }; + const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); + pendingOverwrites = new Set([ + ...pendingOverwrites, + ...checkOriginConflictsResult.pendingOverwrites, + ]); } // Create objects in bulk - const bulkCreateResult = await savedObjectsClient.bulkCreate(omitVersion(filteredObjects), { + const createSavedObjectsParams = { + objects: collectSavedObjectsResult.collectedObjects, + accumulatedErrors: errorAccumulator, + savedObjectsClient, + importIdMap, overwrite, namespace, + }; + const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); + errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; + + const successResults = createSavedObjectsResult.createdObjects.map( + ({ type, id, attributes: { title }, destinationId, originId }) => { + const meta = { title, icon: typeRegistry.getType(type)?.management?.icon }; + const attemptedOverwrite = pendingOverwrites.has(`${type}:${id}`); + return { + type, + id, + meta, + ...(attemptedOverwrite && { overwrite: true }), + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), + }; + } + ); + const errorResults = errorAccumulator.map((error) => { + const icon = typeRegistry.getType(error.type)?.management?.icon; + const attemptedOverwrite = pendingOverwrites.has(`${error.type}:${error.id}`); + return { + ...error, + meta: { ...error.meta, icon }, + ...(attemptedOverwrite && { overwrite: true }), + }; }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, filteredObjects), - ]; return { + successCount: createSavedObjectsResult.createdObjects.length, success: errorAccumulator.length === 0, - successCount: bulkCreateResult.saved_objects.filter((obj) => !obj.error).length, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + ...(successResults.length && { successResults }), + ...(errorResults.length && { errors: errorResults }), }; } - -export function omitVersion(objects: SavedObject[]): SavedObject[] { - return objects.map(({ version, ...object }) => object); -} diff --git a/src/core/server/saved_objects/import/index.ts b/src/core/server/saved_objects/import/index.ts index e268e970b94ac..ab69e4fc44197 100644 --- a/src/core/server/saved_objects/import/index.ts +++ b/src/core/server/saved_objects/import/index.ts @@ -21,9 +21,11 @@ export { importSavedObjectsFromStream } from './import_saved_objects'; export { resolveSavedObjectsImportErrors } from './resolve_import_errors'; export { SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportError, SavedObjectsImportOptions, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, SavedObjectsImportUnsupportedTypeError, diff --git a/src/core/server/saved_objects/import/regenerate_ids.test.ts b/src/core/server/saved_objects/import/regenerate_ids.test.ts new file mode 100644 index 0000000000000..1bbc2693e4f49 --- /dev/null +++ b/src/core/server/saved_objects/import/regenerate_ids.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 { mockUuidv4 } from './__mocks__'; +import { regenerateIds } from './regenerate_ids'; +import { SavedObject } from '../types'; + +describe('#regenerateIds', () => { + const objects = ([ + { type: 'foo', id: '1' }, + { type: 'bar', id: '2' }, + { type: 'baz', id: '3' }, + ] as any) as SavedObject[]; + + test('returns expected values', () => { + mockUuidv4 + .mockReturnValueOnce('uuidv4 #1') + .mockReturnValueOnce('uuidv4 #2') + .mockReturnValueOnce('uuidv4 #3'); + expect(regenerateIds(objects)).toMatchInlineSnapshot(` + Map { + "foo:1" => Object { + "id": "uuidv4 #1", + "omitOriginId": true, + }, + "bar:2" => Object { + "id": "uuidv4 #2", + "omitOriginId": true, + }, + "baz:3" => Object { + "id": "uuidv4 #3", + "omitOriginId": true, + }, + } + `); + }); +}); diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts new file mode 100644 index 0000000000000..647386ed16469 --- /dev/null +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -0,0 +1,33 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { SavedObject } from '../types'; + +/** + * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. + * + * @param objects The saved objects to generate new IDs for. + */ +export const regenerateIds = (objects: SavedObject[]) => { + const importIdMap = objects.reduce((acc, object) => { + return acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); + }, new Map()); + return importIdMap; +}; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index 54ebecc7dca70..51a48dc511e2a 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -18,567 +18,500 @@ */ import { Readable } from 'stream'; -import { SavedObject } from '../types'; -import { resolveSavedObjectsImportErrors } from './resolve_import_errors'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObjectsClientContract, + SavedObjectsType, + SavedObject, + SavedObjectsImportError, + SavedObjectsImportRetry, + SavedObjectReference, +} from '../types'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsResolveImportErrorsOptions, ISavedObjectTypeRegistry } from '..'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { resolveSavedObjectsImportErrors } from './resolve_import_errors'; -describe('resolveImportErrors()', () => { - const savedObjects: SavedObject[] = [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - references: [], - }, - { - id: '2', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [], - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [], - }, - { - id: '4', - type: 'dashboard', - attributes: { - title: 'My Dashboard', - }, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '3', - }, - ], - }, - ]; - const savedObjectsClient = savedObjectsClientMock.create(); +import { validateRetries } from './validate_retries'; +import { collectSavedObjects } from './collect_saved_objects'; +import { regenerateIds } from './regenerate_ids'; +import { validateReferences } from './validate_references'; +import { checkConflicts } from './check_conflicts'; +import { getImportIdMapForRetries } from './check_origin_conflicts'; +import { splitOverwrites } from './split_overwrites'; +import { createSavedObjects } from './create_saved_objects'; +import { createObjectsFilter } from './create_objects_filter'; - beforeEach(() => { - jest.resetAllMocks(); - }); +jest.mock('./validate_retries'); +jest.mock('./create_objects_filter'); +jest.mock('./collect_saved_objects'); +jest.mock('./regenerate_ids'); +jest.mock('./validate_references'); +jest.mock('./check_conflicts'); +jest.mock('./check_origin_conflicts'); +jest.mock('./split_overwrites'); +jest.mock('./create_saved_objects'); + +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; - test('works with empty parameters', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, +describe('#importSavedObjectsFromStream', () => { + beforeEach(() => { + jest.clearAllMocks(); + // mock empty output of each of these mocked modules so the import doesn't throw an error + getMockFn(createObjectsFilter).mockReturnValue(() => false); + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects: [], + importIdMap: new Map(), }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [], + getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(validateReferences).mockResolvedValue([]); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); + getMockFn(splitOverwrites).mockReturnValue({ + objectsToOverwrite: [], + objectsToNotOverwrite: [], }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); - test('works with retries', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: savedObjects.filter((obj) => obj.type === 'visualization' && obj.id === '3'), - }); - const result = await resolveSavedObjectsImportErrors({ + let readStream: Readable; + const objectLimit = 10; + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + const namespace = 'some-namespace'; + + const setupOptions = ( + retries: SavedObjectsImportRetry[] = [], + createNewCopies: boolean = false + ): SavedObjectsResolveImportErrorsOptions => { + readStream = new Readable(); + savedObjectsClient = savedObjectsClientMock.create(); + typeRegistry = typeRegistryMock.create(); + typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); + return { readStream, - objectLimit: 4, - retries: [ - { - type: 'visualization', - id: '3', - replaceReferences: [], - overwrite: false, - }, - ], + objectLimit, + retries, savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + typeRegistry, + // namespace and createNewCopies don't matter, as they don't change the logic in this module, they just get passed to sub-module methods + namespace, + createNewCopies, + }; + }; - test('works with overwrites', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'index-pattern' && obj.id === '1'), + const createRetry = (options?: { + id?: string; + overwrite?: boolean; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; + }) => { + const { id = uuidv4(), overwrite = false, replaceReferences = [] } = options ?? {}; + return { type: 'foo-type', id, overwrite, replaceReferences }; + }; + const createObject = ( + references?: SavedObjectReference[] + ): SavedObject<{ + title: string; + }> => { + return { + type: 'foo-type', + id: uuidv4(), + references: references || [], + attributes: { title: 'some-title' }, + }; + }; + const createError = (): SavedObjectsImportError => { + const title = 'some-title'; + return { + type: 'foo-type', + id: uuidv4(), + title: 'some-title', + meta: { title }, + error: { type: 'conflict' }, + }; + }; + + /** + * These tests use minimal mocks which don't look realistic, but are sufficient to exercise the code paths correctly. For example, for an + * object to be imported successfully it would need to be obtained from `collectSavedObjects`, passed to `validateReferences`, passed to + * `getImportIdMapForRetries`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the + * intermediate steps in the interest of brevity. + */ + describe('module calls', () => { + test('validates retries', async () => { + const retry = createRetry(); + const options = setupOptions([retry]); + + await resolveSavedObjectsImportErrors(options); + expect(validateRetries).toHaveBeenCalledWith([retry]); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'index-pattern', - id: '1', - overwrite: true, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('creates objects filter', async () => { + const retry = createRetry(); + const options = setupOptions([retry]); + + await resolveSavedObjectsImportErrors(options); + expect(createObjectsFilter).toHaveBeenCalledWith([retry]); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test('works wtih replaceReferences', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + test('collects saved objects from stream', async () => { + const options = setupOptions(); + const supportedTypes = ['foo']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await resolveSavedObjectsImportErrors(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + const filter = getMockFn(createObjectsFilter).mock.results[0].value; + const collectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; + expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'dashboard' && obj.id === '4'), + + test('validates references', async () => { + const retries = [createRetry()]; + const options = setupOptions(retries); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace, + retries + ); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'dashboard', - id: '4', - overwrite: false, - replaceReferences: [ - { - type: 'visualization', - from: '3', - to: '13', - }, - ], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('uses `retries` to replace references of collected objects before validating', async () => { + const object = createObject([{ type: 'bar-type', id: 'abc', name: 'some name' }]); + const retries = [ + createRetry({ + id: object.id, + replaceReferences: [{ type: 'bar-type', from: 'abc', to: 'def' }], + }), + ]; + const options = setupOptions(retries); + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects: [object], + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + const objectWithReplacedReferences = { + ...object, + references: [{ ...object.references[0], id: 'def' }], + }; + expect(validateReferences).toHaveBeenCalledWith( + [objectWithReplacedReferences], + savedObjectsClient, + namespace, + retries + ); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [ - Object { - "id": "13", - "name": "panel_0", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test('extracts errors for conflicts', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + test('checks conflicts', async () => { + const createNewCopies = (Symbol() as unknown) as boolean; + const retries = [createRetry()]; + const options = setupOptions(retries, createNewCopies); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + const checkConflictsParams = { + objects: collectedObjects, + savedObjectsClient, + namespace, + retries, + createNewCopies, + }; + expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.map((savedObject) => ({ - type: savedObject.type, - id: savedObject.id, - error: { - statusCode: 409, - message: 'conflict', - }, - attributes: {}, - references: [], - })), + + test('gets import ID map for retries', async () => { + const retries = [createRetry()]; + const createNewCopies = (Symbol() as unknown) as boolean; + const options = setupOptions(retries, createNewCopies); + const filteredObjects = [createObject()]; + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap: new Map(), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type + }); + + await resolveSavedObjectsImportErrors(options); + const getImportIdMapForRetriesParams = { objects: filteredObjects, retries, createNewCopies }; + expect(getImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: savedObjects.map((obj) => ({ - type: obj.type, - id: obj.id, - overwrite: false, - replaceReferences: [], - })), - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('splits objects to ovewrite from those not to overwrite', async () => { + const retries = [createRetry()]; + const options = setupOptions(retries); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(splitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, - } - `); - }); - test('validates references', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ - id: '1', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '2', - }, - ], + describe('with createNewCopies disabled', () => { + test('does not regenerate object IDs', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(regenerateIds).not.toHaveBeenCalled(); + }); + + test('creates saved objects', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + importIdMap: new Map(), // doesn't matter }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map([['foo', { id: 'someId' }]]), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type + }); + getMockFn(getImportIdMapForRetries).mockReturnValue( + new Map([ + ['foo', { id: 'newId' }], + ['bar', { id: 'anotherNewId' }], + ]) + ); + const importIdMap = new Map([ + ['foo', { id: 'someId' }], + ['bar', { id: 'anotherNewId' }], + ]); + const objectsToOverwrite = [createObject()]; + const objectsToNotOverwrite = [createObject()]; + getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + createdObjects: [], }); - this.push(null); - }, - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: '2', - error: { - statusCode: 404, - message: 'Not found', - }, - attributes: {}, - references: [], - }, - ], - }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 2, - retries: [ - { - type: 'search', - id: '1', - overwrite: false, - replaceReferences: [], - }, - { - type: 'visualization', - id: '3', - overwrite: false, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ - Object { - "id": "3", - "type": "visualization", - }, - ], - "references": Array [ - Object { - "id": "2", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test('validates object types', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push({ id: '1', type: 'wigwags', attributes: { title: 'my title' }, references: [] }); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [], + await resolveSavedObjectsImportErrors(options); + const partialCreateSavedObjectsParams = { + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + namespace, + }; + expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + ...partialCreateSavedObjectsParams, + objects: objectsToOverwrite, + overwrite: true, + }); + expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + ...partialCreateSavedObjectsParams, + objects: objectsToNotOverwrite, + }); + }); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 5, - retries: [ - { - id: 'i', - type: 'wigwags', - overwrite: false, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + describe('with createNewCopies enabled', () => { + test('regenerates object IDs', async () => { + const options = setupOptions([], true); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + }); + + test('creates saved objects', async () => { + const options = setupOptions([], true); + const errors = [createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + importIdMap: new Map(), // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(regenerateIds).mockReturnValue( + new Map([ + ['foo', { id: 'randomId1' }], + ['bar', { id: 'randomId2' }], + ['baz', { id: 'randomId3' }], + ]) + ); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map([['bar', { id: 'someId' }]]), + pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type + }); + getMockFn(getImportIdMapForRetries).mockReturnValue( + new Map([ + ['bar', { id: 'newId' }], + ['baz', { id: 'anotherNewId' }], + ]) + ); + const importIdMap = new Map([ + ['foo', { id: 'randomId1' }], + ['bar', { id: 'someId' }], + ['baz', { id: 'anotherNewId' }], + ]); + const objectsToOverwrite = [createObject()]; + const objectsToNotOverwrite = [createObject()]; + getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + createdObjects: [], + }); + + await resolveSavedObjectsImportErrors(options); + const partialCreateSavedObjectsParams = { + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + namespace, + }; + expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + ...partialCreateSavedObjectsParams, + objects: objectsToOverwrite, + overwrite: true, + }); + expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + ...partialCreateSavedObjectsParams, + objects: objectsToNotOverwrite, + }); + }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); }); - test('uses namespace when provided', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + describe('results', () => { + test('returns success=true if no errors occurred', async () => { + const options = setupOptions(); + + const result = await resolveSavedObjectsImportErrors(options); + expect(result).toEqual({ success: true, successCount: 0 }); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'index-pattern' && obj.id === '1'), + + test('returns success=false if an error occurred', async () => { + const options = setupOptions(); + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [createError()], + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + + const result = await resolveSavedObjectsImportErrors(options); + expect(result).toEqual({ success: false, successCount: 0, errors: [expect.any(Object)] }); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ + + test('handles a mix of successes and errors and injects metadata', async () => { + const error1 = createError(); + const error2 = createError(); + const options = setupOptions([ + { type: error2.type, id: error2.id, overwrite: true, replaceReferences: [] }, + ]); + const obj1 = createObject(); + const tmp = createObject(); + const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; + const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId; this is a new copy + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [error1], + createdObjects: [obj1], + }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [error2], + createdObjects: [obj2, obj3], + }); + + const result = await resolveSavedObjectsImportErrors(options); + // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) + const successResults = [ { - type: 'index-pattern', - id: '1', + type: obj1.type, + id: obj1.id, + meta: { title: obj1.attributes.title, icon: `${obj1.type}-icon` }, overwrite: true, - replaceReferences: [], }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - namespace: 'foo', - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( - [ { - attributes: { title: 'My Index Pattern' }, - id: '1', - migrationVersion: {}, - references: [], - type: 'index-pattern', + type: obj2.type, + id: obj2.id, + meta: { title: obj2.attributes.title, icon: `${obj2.type}-icon` }, + destinationId: obj2.destinationId, }, - ], - { namespace: 'foo', overwrite: true } - ); + { + type: obj3.type, + id: obj3.id, + meta: { title: obj3.attributes.title, icon: `${obj3.type}-icon` }, + destinationId: obj3.destinationId, + createNewCopy: true, + }, + ]; + const errors = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` }, overwrite: true }, + ]; + expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + }); + + test('accumulates multiple errors', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [errors[2]], + createdObjects: [], + }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [errors[3]], + createdObjects: [], + }); + + const result = await resolveSavedObjectsImportErrors(options); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); + }); }); }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index dce044a31a577..2182d9252cd51 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -18,15 +18,20 @@ */ import { collectSavedObjects } from './collect_saved_objects'; import { createObjectsFilter } from './create_objects_filter'; -import { extractErrors } from './extract_errors'; import { splitOverwrites } from './split_overwrites'; import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, + SavedObjectsImportSuccess, } from './types'; +import { regenerateIds } from './regenerate_ids'; import { validateReferences } from './validate_references'; -import { omitVersion } from './import_saved_objects'; +import { validateRetries } from './validate_retries'; +import { createSavedObjects } from './create_saved_objects'; +import { getImportIdMapForRetries } from './check_origin_conflicts'; +import { SavedObject } from '../types'; +import { checkConflicts } from './check_conflicts'; /** * Resolve and return saved object import errors. @@ -39,11 +44,17 @@ export async function resolveSavedObjectsImportErrors({ objectLimit, retries, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, + createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise { + // throw a BadRequest error if we see invalid retries + validateRetries(retries); + let successCount = 0; let errorAccumulator: SavedObjectsImportError[] = []; + let importIdMap: Map = new Map(); + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); const filter = createObjectsFilter(retries); // Get the objects to resolve errors @@ -82,43 +93,99 @@ export async function resolveSavedObjectsImportErrors({ } // Validate references - const { filteredObjects, errors: validationErrors } = await validateReferences( + const validateReferencesResult = await validateReferences( objectsToResolve, savedObjectsClient, - namespace + namespace, + retries ); - errorAccumulator = [...errorAccumulator, ...validationErrors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; + + if (createNewCopies) { + // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well + // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId + importIdMap = regenerateIds(objectsToResolve); + } + + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsParams = { + objects: objectsToResolve, + savedObjectsClient, + namespace, + retries, + createNewCopies, + }; + const checkConflictsResult = await checkConflicts(checkConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + + // Check multi-namespace object types for regular conflicts and ambiguous conflicts + const getImportIdMapForRetriesParams = { + objects: checkConflictsResult.filteredObjects, + retries, + createNewCopies, + }; + const importIdMapForRetries = getImportIdMapForRetries(getImportIdMapForRetriesParams); + importIdMap = new Map([ + ...importIdMap, + ...importIdMapForRetries, + ...checkConflictsResult.importIdMap, // this importIdMap takes precedence over the others + ]); // Bulk create in two batches, overwrites and non-overwrites - const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(filteredObjects, retries); - if (objectsToOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate(omitVersion(objectsToOverwrite), { - overwrite: true, + let successResults: SavedObjectsImportSuccess[] = []; + const accumulatedErrors = [...errorAccumulator]; + const bulkCreateObjects = async ( + objects: Array>, + overwrite?: boolean + ) => { + const createSavedObjectsParams = { + objects, + accumulatedErrors, + savedObjectsClient, + importIdMap, namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, objectsToOverwrite), - ]; - successCount += bulkCreateResult.saved_objects.filter((obj) => !obj.error).length; - } - if (objectsToNotOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate( - omitVersion(objectsToNotOverwrite), - { - namespace, - } + overwrite, + }; + const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( + createSavedObjectsParams ); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite), + errorAccumulator = [...errorAccumulator, ...bulkCreateErrors]; + successCount += createdObjects.length; + successResults = [ + ...successResults, + ...createdObjects.map(({ type, id, attributes: { title }, destinationId, originId }) => { + const meta = { title, icon: typeRegistry.getType(type)?.management?.icon }; + return { + type, + id, + meta, + ...(overwrite && { overwrite }), + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), + }; + }), ]; - successCount += bulkCreateResult.saved_objects.filter((obj) => !obj.error).length; - } + }; + const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(objectsToResolve, retries); + await bulkCreateObjects(objectsToOverwrite, true); + await bulkCreateObjects(objectsToNotOverwrite); + + const errorResults = errorAccumulator.map((error) => { + const icon = typeRegistry.getType(error.type)?.management?.icon; + const attemptedOverwrite = retries.some( + ({ type, id, overwrite }) => type === error.type && id === error.id && overwrite + ); + return { + ...error, + meta: { ...error.meta, icon }, + ...(attemptedOverwrite && { overwrite: true }), + }; + }); return { successCount, success: errorAccumulator.length === 0, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + ...(successResults.length && { successResults }), + ...(errorResults.length && { errors: errorResults }), }; } diff --git a/src/core/server/saved_objects/import/split_overwrites.ts b/src/core/server/saved_objects/import/split_overwrites.ts index be55e049a2bfc..03ae6b96e7823 100644 --- a/src/core/server/saved_objects/import/split_overwrites.ts +++ b/src/core/server/saved_objects/import/split_overwrites.ts @@ -20,9 +20,12 @@ import { SavedObject } from '../types'; import { SavedObjectsImportRetry } from './types'; -export function splitOverwrites(savedObjects: SavedObject[], retries: SavedObjectsImportRetry[]) { - const objectsToOverwrite: SavedObject[] = []; - const objectsToNotOverwrite: SavedObject[] = []; +export function splitOverwrites( + savedObjects: Array>, + retries: SavedObjectsImportRetry[] +) { + const objectsToOverwrite: Array> = []; + const objectsToNotOverwrite: Array> = []; const overwrites = retries .filter((retry) => retry.overwrite) .map((retry) => `${retry.type}:${retry.id}`); diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 067579f54edac..a242ffdf5b50f 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -18,7 +18,8 @@ */ import { Readable } from 'stream'; -import { SavedObjectsClientContract } from '../types'; +import { SavedObjectsClientContract, SavedObject } from '../types'; +import { ISavedObjectTypeRegistry } from '..'; /** * Describes a retry operation for importing a saved object. @@ -28,11 +29,24 @@ export interface SavedObjectsImportRetry { type: string; id: string; overwrite: boolean; + /** + * The object ID that will be created or overwritten. If not specified, the `id` field will be used. + */ + destinationId?: string; replaceReferences: Array<{ type: string; from: string; to: string; }>; + /** + * If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where + * `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + */ + createNewCopy?: boolean; + /** + * If `ignoreMissingReferences` is specified, reference validation will be skipped for this object. + */ + ignoreMissingReferences?: boolean; } /** @@ -41,6 +55,16 @@ export interface SavedObjectsImportRetry { */ export interface SavedObjectsImportConflictError { type: 'conflict'; + destinationId?: string; +} + +/** + * Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + * @public + */ +export interface SavedObjectsImportAmbiguousConflictError { + type: 'ambiguous_conflict'; + destinations: Array<{ id: string; title?: string; updatedAt?: string }>; } /** @@ -67,14 +91,7 @@ export interface SavedObjectsImportUnknownError { */ export interface SavedObjectsImportMissingReferencesError { type: 'missing_references'; - references: Array<{ - type: string; - id: string; - }>; - blocking: Array<{ - type: string; - id: string; - }>; + references: Array<{ type: string; id: string }>; } /** @@ -84,14 +101,51 @@ export interface SavedObjectsImportMissingReferencesError { export interface SavedObjectsImportError { id: string; type: string; + /** + * @deprecated Use `meta.title` instead + */ title?: string; + meta: { title?: string; icon?: string }; + /** + * If `overwrite` is specified, an attempt was made to overwrite an existing object. + */ + overwrite?: boolean; error: | SavedObjectsImportConflictError + | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; } +/** + * Represents a successful import. + * @public + */ +export interface SavedObjectsImportSuccess { + id: string; + type: string; + /** + * If `destinationId` is specified, the new object has a new ID that is different from the import ID. + */ + destinationId?: string; + /** + * @deprecated + * If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where + * `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, + * this field will be redundant and can be removed. + */ + createNewCopy?: boolean; + meta: { + title?: string; + icon?: string; + }; + /** + * If `overwrite` is specified, this object overwrote an existing one (or will do so, in the case of a pending resolution). + */ + overwrite?: boolean; +} + /** * The response describing the result of an import. * @public @@ -99,6 +153,7 @@ export interface SavedObjectsImportError { export interface SavedObjectsImportResponse { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: SavedObjectsImportError[]; } @@ -111,14 +166,16 @@ export interface SavedObjectsImportOptions { readStream: Readable; /** The maximum number of object to import */ objectLimit: number; - /** if true, will override existing object if present */ + /** If true, will override existing object if present. Note: this has no effect when used with the `createNewCopies` option. */ overwrite: boolean; /** {@link SavedObjectsClientContract | client} to use to perform the import operation */ savedObjectsClient: SavedObjectsClientContract; - /** the list of allowed types to import */ - supportedTypes: string[]; + /** The registry of all known saved object types */ + typeRegistry: ISavedObjectTypeRegistry; /** if specified, will import in given namespace, else will import as global object */ namespace?: string; + /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ + createNewCopies: boolean; } /** @@ -132,10 +189,14 @@ export interface SavedObjectsResolveImportErrorsOptions { objectLimit: number; /** client to use to perform the import operation */ savedObjectsClient: SavedObjectsClientContract; + /** The registry of all known saved object types */ + typeRegistry: ISavedObjectTypeRegistry; /** saved object import references to retry */ retries: SavedObjectsImportRetry[]; - /** the list of allowed types to import */ - supportedTypes: string[]; /** if specified, will import in given namespace */ namespace?: string; + /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ + createNewCopies: boolean; } + +export type CreatedObject = SavedObject & { destinationId?: string }; diff --git a/src/core/server/saved_objects/import/validate_references.test.ts b/src/core/server/saved_objects/import/validate_references.test.ts index a9dce65b97d72..6efd1b28b199d 100644 --- a/src/core/server/saved_objects/import/validate_references.test.ts +++ b/src/core/server/saved_objects/import/validate_references.test.ts @@ -19,6 +19,7 @@ import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsErrorHelpers } from '..'; describe('getNonExistingReferenceAsKeys()', () => { const savedObjectsClient = savedObjectsClientMock.create(); @@ -33,6 +34,34 @@ describe('getNonExistingReferenceAsKeys()', () => { expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); + test('skips objects when ignoreMissingReferences is included in retry', async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ]; + const retries = [ + { + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, + }, + ]; + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + retries + ); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + test('removes references that exist within savedObjects', async () => { const savedObjects = [ { @@ -164,20 +193,15 @@ describe('getNonExistingReferenceAsKeys()', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output + .payload, attributes: {}, references: [], }, { id: '3', type: 'search', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '3').output.payload, attributes: {}, references: [], }, @@ -230,12 +254,7 @@ describe('validateReferences()', () => { test('returns empty when no objects are passed in', async () => { const result = await validateReferences([], savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -245,40 +264,31 @@ describe('validateReferences()', () => { { type: 'index-pattern', id: '3', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '3').output + .payload, attributes: {}, references: [], }, { type: 'index-pattern', id: '5', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '5').output + .payload, attributes: {}, references: [], }, { type: 'index-pattern', id: '6', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '6').output + .payload, attributes: {}, references: [], }, { type: 'search', id: '7', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '7').output.payload, attributes: {}, references: [], }, @@ -343,56 +353,50 @@ describe('validateReferences()', () => { ]; const result = await validateReferences(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [], - "references": Array [ - Object { - "id": "3", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "2", + Array [ + Object { + "error": Object { + "references": Array [ + Object { + "id": "3", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "id": "2", + "meta": Object { "title": "My Visualization 2", - "type": "visualization", }, - Object { - "error": Object { - "blocking": Array [], - "references": Array [ - Object { - "id": "5", - "type": "index-pattern", - }, - Object { - "id": "6", - "type": "index-pattern", - }, - Object { - "id": "7", - "type": "search", - }, - ], - "type": "missing_references", - }, - "id": "4", - "title": "My Visualization 4", - "type": "visualization", + "title": "My Visualization 2", + "type": "visualization", + }, + Object { + "error": Object { + "references": Array [ + Object { + "id": "5", + "type": "index-pattern", + }, + Object { + "id": "6", + "type": "index-pattern", + }, + Object { + "id": "7", + "type": "search", + }, + ], + "type": "missing_references", }, - ], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "visualization", + "id": "4", + "meta": Object { + "title": "My Visualization 4", }, - ], - } + "title": "My Visualization 4", + "type": "visualization", + }, + ] `); expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` [MockFunction] { @@ -450,6 +454,29 @@ describe('validateReferences()', () => { `); }); + test(`doesn't return errors when ignoreMissingReferences is included in retry`, async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ]; + const retries = [ + { + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, retries); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + test(`doesn't return errors when references exist in Elasticsearch`, async () => { savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ @@ -476,25 +503,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); }); @@ -520,31 +529,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -569,30 +554,7 @@ describe('validateReferences()', () => { }, ]; const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [], - "filteredObjects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "ref_1", - "type": "other-type", - }, - ], - "type": "dashboard", - }, - ], - } - `); + expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -602,10 +564,7 @@ describe('validateReferences()', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 400, - message: 'Error', - }, + error: SavedObjectsErrorHelpers.createBadRequestError().output.payload, attributes: {}, references: [], }, diff --git a/src/core/server/saved_objects/import/validate_references.ts b/src/core/server/saved_objects/import/validate_references.ts index 2a30dcc96c08a..89fe8ec8c0901 100644 --- a/src/core/server/saved_objects/import/validate_references.ts +++ b/src/core/server/saved_objects/import/validate_references.ts @@ -19,22 +19,34 @@ import Boom from 'boom'; import { SavedObject, SavedObjectsClientContract } from '../types'; -import { SavedObjectsImportError } from './types'; +import { SavedObjectsImportError, SavedObjectsImportRetry } from './types'; const REF_TYPES_TO_VLIDATE = ['index-pattern', 'search']; function filterReferencesToValidate({ type }: { type: string }) { return REF_TYPES_TO_VLIDATE.includes(type); } +const getObjectsToSkip = (retries: SavedObjectsImportRetry[] = []) => + retries.reduce( + (acc, { type, id, ignoreMissingReferences }) => + ignoreMissingReferences ? acc.add(`${type}:${id}`) : acc, + new Set() + ); export async function getNonExistingReferenceAsKeys( savedObjects: SavedObject[], savedObjectsClient: SavedObjectsClientContract, - namespace?: string + namespace?: string, + retries?: SavedObjectsImportRetry[] ) { + const objectsToSkip = getObjectsToSkip(retries); const collector = new Map(); // Collect all references within objects for (const savedObject of savedObjects) { + if (objectsToSkip.has(`${savedObject.type}:${savedObject.id}`)) { + // skip objects with retries that have specified `ignoreMissingReferences` + continue; + } const filteredReferences = (savedObject.references || []).filter(filterReferencesToValidate); for (const { type, id } of filteredReferences) { collector.set(`${type}:${id}`, { type, id }); @@ -79,62 +91,44 @@ export async function getNonExistingReferenceAsKeys( export async function validateReferences( savedObjects: Array>, savedObjectsClient: SavedObjectsClientContract, - namespace?: string + namespace?: string, + retries?: SavedObjectsImportRetry[] ) { + const objectsToSkip = getObjectsToSkip(retries); const errorMap: { [key: string]: SavedObjectsImportError } = {}; const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( savedObjects, savedObjectsClient, - namespace + namespace, + retries ); // Filter out objects with missing references, add to error object - let filteredObjects = savedObjects.filter((savedObject) => { + savedObjects.forEach(({ type, id, references, attributes }) => { + if (objectsToSkip.has(`${type}:${id}`)) { + // skip objects with retries that have specified `ignoreMissingReferences` + return; + } + const missingReferences = []; - const enforcedTypeReferences = (savedObject.references || []).filter( - filterReferencesToValidate - ); + const enforcedTypeReferences = (references || []).filter(filterReferencesToValidate); for (const { type: refType, id: refId } of enforcedTypeReferences) { if (nonExistingReferenceKeys.includes(`${refType}:${refId}`)) { missingReferences.push({ type: refType, id: refId }); } } if (missingReferences.length === 0) { - return true; + return; } - errorMap[`${savedObject.type}:${savedObject.id}`] = { - id: savedObject.id, - type: savedObject.type, - title: savedObject.attributes && savedObject.attributes.title, - error: { - type: 'missing_references', - references: missingReferences, - blocking: [], - }, + const { title } = attributes; + errorMap[`${type}:${id}`] = { + id, + type, + title, + meta: { title }, + error: { type: 'missing_references', references: missingReferences }, }; - return false; - }); - - // Filter out objects that reference objects within the import but are missing_references - // For example: visualization referencing a search that is missing an index pattern needs to be filtered out - filteredObjects = filteredObjects.filter((savedObject) => { - let isBlocked = false; - for (const reference of savedObject.references || []) { - const referencedObjectError = errorMap[`${reference.type}:${reference.id}`]; - if (!referencedObjectError || referencedObjectError.error.type !== 'missing_references') { - continue; - } - referencedObjectError.error.blocking.push({ - type: savedObject.type, - id: savedObject.id, - }); - isBlocked = true; - } - return !isBlocked; }); - return { - errors: Object.values(errorMap), - filteredObjects, - }; + return Object.values(errorMap); } diff --git a/src/core/server/saved_objects/import/validate_retries.test.ts b/src/core/server/saved_objects/import/validate_retries.test.ts new file mode 100644 index 0000000000000..fd3c1e9795f9f --- /dev/null +++ b/src/core/server/saved_objects/import/validate_retries.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { validateRetries } from './validate_retries'; +import { SavedObjectsImportRetry } from '.'; + +import { getNonUniqueEntries } from './get_non_unique_entries'; +jest.mock('./get_non_unique_entries'); +const mockGetNonUniqueEntries = getNonUniqueEntries as jest.MockedFunction< + typeof getNonUniqueEntries +>; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetNonUniqueEntries.mockReturnValue([]); +}); + +describe('#validateRetries', () => { + const createRetry = (object: unknown) => object as SavedObjectsImportRetry; + + describe('module calls', () => { + test('empty retries', () => { + validateRetries([]); + expect(getNonUniqueEntries).toHaveBeenCalledTimes(2); + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(1, []); + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(2, []); + }); + + test('non-empty retries', () => { + const retry1 = createRetry({ type: 'foo', id: '1' }); + const retry2 = createRetry({ type: 'foo', id: '2', overwrite: true }); + const retry3 = createRetry({ type: 'foo', id: '3', destinationId: 'a' }); + const retry4 = createRetry({ type: 'foo', id: '4', overwrite: true, destinationId: 'b' }); + const retries = [retry1, retry2, retry3, retry4]; + validateRetries(retries); + expect(getNonUniqueEntries).toHaveBeenCalledTimes(2); + // check all retry objects for non-unique entries + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(1, retries); + // check only retry objects with `destinationId` !== undefined for non-unique entries + const retryOverwriteEntries = [ + { type: retry3.type, id: retry3.destinationId }, + { type: retry4.type, id: retry4.destinationId }, + ]; + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(2, retryOverwriteEntries); + }); + }); + + describe('results', () => { + test('throws Boom error if any retry objects are not unique', () => { + mockGetNonUniqueEntries.mockReturnValue(['type1:id1', 'type2:id2']); + expect.assertions(2); + try { + validateRetries([]); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique retry objects: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('throws Boom error if any retry destinations are not unique', () => { + mockGetNonUniqueEntries.mockReturnValueOnce([]); + mockGetNonUniqueEntries.mockReturnValue(['type1:id1', 'type2:id2']); + expect.assertions(2); + try { + validateRetries([]); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique retry destinations: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('does not throw error if retry objects and retry destinations are unique', () => { + // no need to mock return value, the mock `getNonUniqueEntries` function returns an empty array by default + expect(() => validateRetries([])).not.toThrowError(); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/validate_retries.ts b/src/core/server/saved_objects/import/validate_retries.ts new file mode 100644 index 0000000000000..f625436edb636 --- /dev/null +++ b/src/core/server/saved_objects/import/validate_retries.ts @@ -0,0 +1,41 @@ +/* + * 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 { SavedObjectsImportRetry } from './types'; +import { getNonUniqueEntries } from './get_non_unique_entries'; +import { SavedObjectsErrorHelpers } from '..'; + +export const validateRetries = (retries: SavedObjectsImportRetry[]) => { + const nonUniqueRetryObjects = getNonUniqueEntries(retries); + if (nonUniqueRetryObjects.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique retry objects: [${nonUniqueRetryObjects.join()}]` + ); + } + + const destinationEntries = retries + .filter((retry) => retry.destinationId !== undefined) + .map(({ type, destinationId }) => ({ type, id: destinationId! })); + const nonUniqueRetryDestinations = getNonUniqueEntries(destinationEntries); + if (nonUniqueRetryDestinations.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique retry destinations: [${nonUniqueRetryDestinations.join()}]` + ); + } +}; diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index bc9a66926e880..f8ef47cae8944 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -9,6 +9,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -32,6 +33,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { @@ -64,6 +68,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -91,6 +96,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 4561f4d30e104..2f4427b27b6bf 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -144,6 +144,9 @@ function defaultMapping(): IndexMapping { namespaces: { type: 'keyword', }, + originId: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index b0669774207dd..df89137a1d798 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -66,6 +66,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -76,6 +77,7 @@ describe('IndexMigrator', () => { migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { @@ -185,6 +187,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -196,6 +199,7 @@ describe('IndexMigrator', () => { migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { @@ -244,6 +248,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -255,6 +260,7 @@ describe('IndexMigrator', () => { migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 3453f3fc80310..9311292a6a0ed 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -9,6 +9,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -40,6 +41,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 8fce6f49fb850..4fac8fede0cd9 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -45,32 +45,39 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }, }, validate: { - query: schema.object({ - overwrite: schema.boolean({ defaultValue: false }), - }), + query: schema.object( + { + overwrite: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.createNewCopies) { + return 'cannot use [overwrite] with [createNewCopies]'; + } + }, + } + ), body: schema.object({ file: schema.stream(), }), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { overwrite } = req.query; + const { overwrite, createNewCopies } = req.query; const file = req.body.file as FileStream; const fileExtension = extname(file.hapi.filename).toLowerCase(); if (fileExtension !== '.ndjson') { return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((type) => type.name); - const result = await importSavedObjectsFromStream({ - supportedTypes, savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, readStream: createSavedObjectsStreamFromNdJson(file), objectLimit: maxImportExportSize, overwrite, + createNewCopies, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 61f32a420d92b..0bc03fbcf8038 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -17,42 +17,58 @@ * under the License. */ +import { mockUuidv4 } from '../../import/__mocks__'; import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; +import { SavedObjectsErrorHelpers } from '../..'; type SetupServerReturn = UnwrapPromise>; +const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; -const config = { - maxImportPayloadBytes: 10485760, - maxImportExportSize: 10000, -} as SavedObjectConfig; +const config = { maxImportPayloadBytes: 10485760, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/internal/saved_objects/_import'; -describe('POST /internal/saved_objects/_import', () => { +describe(`POST ${URL}`, () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; let handlerContext: SetupServerReturn['handlerContext']; let savedObjectsClient: ReturnType; - const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, + const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], }; beforeEach(async () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + // other attributes aren't needed for the purposes of injecting metadata + ({ management: { icon: `${type}-icon` } } as any) + ); savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.find.mockResolvedValue(emptyResponse); + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/internal/saved_objects/'); registerImportRoute(router, config); @@ -66,7 +82,7 @@ describe('POST /internal/saved_objects/_import', () => { it('formats successful response', async () => { const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') .send( [ @@ -80,29 +96,15 @@ describe('POST /internal/saved_objects/_import', () => { ) .expect(200); - expect(result.body).toEqual({ - success: true, - successCount: 0, - }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); it('defaults migrationVersion to empty object', async () => { - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -119,39 +121,30 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: true, successCount: 1, + successResults: [ + { + type: 'index-pattern', + id: 'my-pattern', + meta: { title: 'my-pattern-*', icon: 'index-pattern-icon' }, + }, + ], }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ migrationVersion: {} })], + expect.any(Object) // options + ); }); it('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { // NOTE: changes to this scenario should be reflected in the docs savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], + saved_objects: [mockIndexPattern, mockDashboard], }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -172,37 +165,84 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: true, successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present }); it('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ + const error = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern').output + .payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: mockIndexPattern.type, id: mockIndexPattern.id, error }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, + successResults: [ { - type: 'index-pattern', - id: 'my-pattern', - attributes: {}, - references: [], - error: { - statusCode: 409, - message: 'Saved object [index-pattern/my-pattern] conflict', - }, + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, }, + ], + errors: [ { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], + id: mockIndexPattern.id, + type: mockIndexPattern.type, + title: mockIndexPattern.attributes.title, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + error: { type: 'conflict' }, }, ], }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // successResults objects were not created because resolvable errors are present + }); + + it('imports an index pattern and dashboard but has a conflict on the index pattern, with overwrite=true', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern').output + .payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: mockIndexPattern.type, id: mockIndexPattern.id, error }], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [mockIndexPattern, mockDashboard], + }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(`${URL}?overwrite=true`) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -217,42 +257,172 @@ describe('POST /internal/saved_objects/_import', () => { ) .expect(200); + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + overwrite: true, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + }); + + it('imports a visualization with missing references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + expect(result.body).toEqual({ success: false, successCount: 1, errors: [ { - id: 'my-pattern', - type: 'index-pattern', - title: 'my-pattern-*', + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, error: { - type: 'conflict', + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], }, }, ], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); - it('imports a visualization with missing references', async () => { + it('imports a visualization with missing references and a conflict', async () => { // NOTE: changes to this scenario should be reflected in the docs + const error1 = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ + saved_objects: [{ ...mockIndexPattern, error: error1 }], + }); + const error2 = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern') + .output.payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: 'visualization', id: 'my-vis', error: error2 }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, + errors: [ { - id: 'my-pattern-*', - type: 'index-pattern', + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, error: { - statusCode: 404, - message: 'Not found', + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], }, - references: [], - attributes: {}, + }, + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + error: { type: 'conflict' }, + }, + ], + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, }, ], }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('imports a visualization with missing references and a conflict, with overwrite=true', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + const error1 = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error: error1 }], + }); + const error2 = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern') + .output.payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: 'visualization', id: 'my-vis', error: error2 }], + }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(`${URL}?overwrite=true`) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -260,7 +430,7 @@ describe('POST /internal/saved_objects/_import', () => { 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', 'Content-Type: application/ndjson', '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', '--EXAMPLE--', ].join('\r\n') @@ -269,55 +439,107 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: false, - successCount: 0, + successCount: 1, errors: [ { id: 'my-vis', type: 'visualization', title: 'my-vis', + meta: { title: 'my-vis', icon: 'visualization-icon' }, + overwrite: true, error: { type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: 'my-pattern-*', - }, - ], - blocking: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + references: [{ type: 'index-pattern', id: 'my-pattern' }], }, }, ], - }); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "my-pattern-*", - "type": "index-pattern", + successResults: [ + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, }, ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + describe('createNewCopies enabled', () => { + it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { + mockUuidv4.mockReturnValueOnce('new-id-1').mockReturnValueOnce('new-id-2'); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); + const obj1 = { + type: 'visualization', + id: 'new-id-1', + attributes: { title: 'Look at my visualization' }, + references: [], + }; + const obj2 = { + type: 'dashboard', + id: 'new-id-2', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2] }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?createNewCopies=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .set('x-opaque-id', uuidv4()) // prevents src/core/server/http/http_tools.ts from using our mocked uuidv4 to generate a unique ID for this request + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: obj1.type, + id: 'my-vis', + meta: { title: obj1.attributes.title, icon: 'visualization-icon' }, + destinationId: obj1.id, + }, + { + type: obj2.type, + id: 'my-dashboard', + meta: { title: obj2.attributes.title, icon: 'dashboard-icon' }, + destinationId: obj2.id, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'visualization', + id: 'new-id-1', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }], + originId: undefined, + }), + expect.objectContaining({ + type: 'dashboard', + id: 'new-id-2', + references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], + originId: undefined, + }), + ], + expect.any(Object) // options + ); + }); }); }); diff --git a/src/core/server/saved_objects/routes/integration_tests/migrate.test.ts b/src/core/server/saved_objects/routes/integration_tests/migrate.test.ts index 7a0e39b71afb8..e003d564c1ea2 100644 --- a/src/core/server/saved_objects/routes/integration_tests/migrate.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/migrate.test.ts @@ -18,7 +18,7 @@ */ import { migratorInstanceMock } from './migrate.test.mocks'; -import * as kbnTestServer from '../../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; describe('SavedObjects /_migrate endpoint', () => { let root: ReturnType; diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 6a6976b513ca1..a933838cc92e3 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { mockUuidv4 } from '../../import/__mocks__'; import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; @@ -26,25 +27,53 @@ import { SavedObjectConfig } from '../../saved_objects_config'; type SetupServerReturn = UnwrapPromise>; +const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; -const config = { - maxImportPayloadBytes: 10485760, - maxImportExportSize: 10000, -} as SavedObjectConfig; +const config = { maxImportPayloadBytes: 10485760, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/api/saved_objects/_resolve_import_errors'; -describe('POST /api/saved_objects/_resolve_import_errors', () => { +describe(`POST ${URL}`, () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; let handlerContext: SetupServerReturn['handlerContext']; let savedObjectsClient: ReturnType; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + const mockVisualization = { + type: 'visualization', + id: 'my-vis', + attributes: { title: 'Look at my visualization' }, + references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }], + }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'existing', + attributes: {}, + references: [], + }; + beforeEach(async () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + ({ + // other attributes aren't needed for the purposes of injecting metadata + management: { icon: `${type}-icon` }, + } as any) + ); savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/api/saved_objects/'); registerResolveImportErrorsRoute(router, config); @@ -58,7 +87,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { it('formats successful response', async () => { const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') .send( [ @@ -77,25 +106,14 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { .expect(200); expect(result.body).toEqual({ success: true, successCount: 0 }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); it('defaults migrationVersion to empty object', async () => { - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -113,30 +131,30 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + const { + type, + id, + attributes: { title }, + } = mockDashboard; + const meta = { title, icon: 'dashboard-icon' }; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id, meta }], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ migrationVersion: {} })], + expect.any(Object) // options + ); }); it('retries importing a dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -154,53 +172,26 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my dashboard", - }, - "id": "my-dashboard", - "migrationVersion": Object {}, - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + const { type, id, attributes } = mockDashboard; + const meta = { title: attributes.title, icon: 'dashboard-icon' }; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id, meta }], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); }); it('resolves conflicts for dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -219,70 +210,74 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my dashboard", - }, - "id": "my-dashboard", - "migrationVersion": Object {}, - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + const { type, id, attributes } = mockDashboard; + const meta = { title: attributes.title, icon: 'dashboard-icon' }; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id, meta, overwrite: true }], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, migrationVersion: {} }], + expect.objectContaining({ overwrite: true }) + ); }); - it('resolves conflicts by replacing the visualization references', async () => { + it('resolves `missing_references` errors by replacing the missing references', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockVisualization] }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + const { type, id, attributes, references } = mockVisualization; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [ { type: 'visualization', id: 'my-vis', - attributes: { - title: 'Look at my visualization', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'existing', - }, - ], - }, - ], - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'existing', - type: 'index-pattern', - attributes: {}, - references: [], + meta: { title: 'Look at my visualization', icon: 'visualization-icon' }, }, ], }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, references, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'existing', type: 'index-pattern' }], + expect.any(Object) // options + ); + }); + + it('resolves `missing_references` errors by ignoring the missing references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockVisualization] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -294,72 +289,107 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { '--EXAMPLE', 'Content-Disposition: form-data; name="retries"', '', - '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', + '[{"type":"visualization","id":"my-vis","ignoreMissingReferences":true}]', '--EXAMPLE--', ].join('\r\n') ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my visualization", - }, - "id": "my-vis", - "migrationVersion": Object {}, - "references": Array [ - Object { - "id": "existing", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, + const { type, id, attributes } = mockVisualization; + const references = [{ name: 'ref_0', type: 'index-pattern', id: 'missing' }]; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [ + { + type: 'visualization', + id: 'my-vis', + meta: { title: 'Look at my visualization', icon: 'visualization-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, references, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + + describe('createNewCopies enabled', () => { + it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { + mockUuidv4.mockReturnValue('new-id-1'); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); + const obj1 = { + type: 'visualization', + id: 'new-id-1', + attributes: { title: 'Look at my visualization' }, + references: [], + }; + const obj2 = { + type: 'dashboard', + id: 'new-id-2', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2] }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?createNewCopies=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern","to":"existing"}]},{"type":"dashboard","id":"my-dashboard","destinationId":"new-id-2"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: obj1.type, + id: 'my-vis', + meta: { title: obj1.attributes.title, icon: 'visualization-icon' }, + destinationId: obj1.id, }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "existing", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, + { + type: obj2.type, + id: 'my-dashboard', + meta: { title: obj2.attributes.title, icon: 'dashboard-icon' }, + destinationId: obj2.id, }, ], - } - `); + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'visualization', + id: 'new-id-1', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }], + originId: undefined, + }), + expect.objectContaining({ + type: 'dashboard', + id: 'new-id-2', + references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], + originId: undefined, + }), + ], + expect.any(Object) // options + ); + }); }); }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 3458e601e0fe6..93fcb6dbda0ac 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -45,6 +45,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }, }, validate: { + query: schema.object({ + createNewCopies: schema.boolean({ defaultValue: false }), + }), body: schema.object({ file: schema.stream(), retries: schema.arrayOf( @@ -52,6 +55,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO type: schema.string(), id: schema.string(), overwrite: schema.boolean({ defaultValue: false }), + destinationId: schema.maybe(schema.string()), replaceReferences: schema.arrayOf( schema.object({ type: schema.string(), @@ -60,6 +64,8 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }), { defaultValue: [] } ), + createNewCopy: schema.maybe(schema.boolean()), + ignoreMissingReferences: schema.maybe(schema.boolean()), }) ), }), @@ -72,16 +78,13 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((type) => type.name); - const result = await resolveSavedObjectsImportErrors({ - supportedTypes, + typeRegistry: context.core.savedObjects.typeRegistry, savedObjectsClient: context.core.savedObjects.client, readStream: createSavedObjectsStreamFromNdJson(file), retries: req.body.retries, objectLimit: maxImportExportSize, + createNewCopies: req.query.createNewCopies, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 1a7dfdd2d130e..e5f0e8abd3b71 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -214,6 +214,28 @@ describe('#rawToSavedObject', () => { expect(actual).not.toHaveProperty('updated_at'); }); + test('if specified it copies the _source.originId property to originId', () => { + const originId = 'baz'; + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + originId, + }, + }); + expect(actual).toHaveProperty('originId', originId); + }); + + test(`if _source.originId is unspecified it doesn't set originId`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('originId'); + }); + test('it does not pass unknown properties through', () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'universe', @@ -280,6 +302,7 @@ describe('#rawToSavedObject', () => { namespace: 'foo-namespace', updated_at: String(new Date()), references: [], + originId: 'baz', }, }; @@ -458,6 +481,26 @@ describe('#savedObjectToRaw', () => { expect(actual._source).not.toHaveProperty('updated_at'); }); + test('if specified it copies the originId property to _source.originId', () => { + const originId = 'baz'; + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + originId, + } as any); + + expect(actual._source).toHaveProperty('originId', originId); + }); + + test(`if unspecified it doesn't add originId property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('originId'); + }); + test('it copies the migrationVersion property to _source.migrationVersion', () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index c0c09b6375bdf..145dd286c1ca8 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -62,7 +62,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces } = _source; + const { type, namespace, namespaces, originId } = _source; const version = _seq_no != null || _primary_term != null @@ -74,6 +74,7 @@ export class SavedObjectsSerializer { id: this.trimIdPrefix(namespace, type, _id), ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), + ...(originId && { originId }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -93,6 +94,7 @@ export class SavedObjectsSerializer { type, namespace, namespaces, + originId, attributes, migrationVersion, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -106,6 +108,7 @@ export class SavedObjectsSerializer { references, ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), + ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), }; diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index acd2c7b5284aa..8b3eebceb2c5a 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -40,6 +40,7 @@ export interface SavedObjectsRawDocSource { migrationVersion?: SavedObjectsMigrationVersion; updated_at?: string; references?: SavedObjectReference[]; + originId?: string; [typeMapping: string]: any; } @@ -56,6 +57,7 @@ interface SavedObjectDoc { migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; + originId?: string; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index ced99361f1ea0..356ffff398343 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -19,6 +19,8 @@ import { includedFields } from './included_fields'; +const BASE_FIELD_COUNT = 9; + describe('includedFields', () => { it('returns undefined if fields are not provided', () => { expect(includedFields()).toBe(undefined); @@ -26,7 +28,7 @@ describe('includedFields', () => { it('accepts type string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('type'); }); @@ -42,6 +44,7 @@ Array [ "references", "migrationVersion", "updated_at", + "originId", "foo", ] `); @@ -49,14 +52,14 @@ Array [ it('accepts field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('config.foo'); }); it('accepts fields as an array', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(10); + expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); expect(fields).toContain('config.foo'); expect(fields).toContain('config.bar'); }); @@ -75,6 +78,7 @@ Array [ "references", "migrationVersion", "updated_at", + "originId", "foo", "bar", ] @@ -83,37 +87,43 @@ Array [ it('includes namespace', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('namespace'); }); it('includes namespaces', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('namespaces'); }); it('includes references', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('references'); }); it('includes migrationVersion', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('migrationVersion'); }); it('includes updated_at', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('updated_at'); }); + it('includes originId', () => { + const fields = includedFields('config', 'foo'); + expect(fields).toHaveLength(BASE_FIELD_COUNT); + expect(fields).toContain('originId'); + }); + it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('*.foo'); }); @@ -121,7 +131,7 @@ Array [ it('includes legacy field path', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(10); + expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); expect(fields).toContain('foo'); expect(fields).toContain('bar'); }); diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index 33bca49e3fc58..63d8f184ed2f2 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -42,5 +42,6 @@ export function includedFields(type: string | string[] = '*', fields?: string[] .concat('references') .concat('migrationVersion') .concat('updated_at') + .concat('originId') .concat(fields); // v5 compatibility } diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index afef378b7307b..c5fd260b78a9f 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -20,6 +20,7 @@ import { ISavedObjectsRepository } from './repository'; const create = (): jest.Mocked => ({ + checkConflicts: jest.fn(), create: jest.fn(), bulkCreate: jest.fn(), bulkUpdate: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 6d85223d1fc88..39433981dfd59 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -154,7 +154,7 @@ describe('SavedObjectsRepository', () => { validateDoc: jest.fn(), }); - const getMockGetResponse = ({ type, id, references, namespace }) => ({ + const getMockGetResponse = ({ type, id, references, namespace, originId }) => ({ // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these found: true, _id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`, @@ -162,6 +162,7 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace }), ...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }), + ...(originId && { originId }), type, [type]: { title: 'Testing' }, references, @@ -187,13 +188,17 @@ describe('SavedObjectsRepository', () => { }); const expectSuccess = ({ type, id }) => expect.toBeDocumentWithoutError(type, id); const expectError = ({ type, id }) => ({ type, id, error: expect.any(Object) }); - const expectErrorResult = ({ type, id }, error) => ({ type, id, error }); - const expectErrorNotFound = (obj) => - expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id)); - const expectErrorConflict = (obj) => - expectErrorResult(obj, createConflictError(obj.type, obj.id)); - const expectErrorInvalidType = (obj) => - expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id)); + const expectErrorResult = ({ type, id }, error, overrides = {}) => ({ + type, + id, + error: { ...error, ...overrides }, + }); + const expectErrorNotFound = (obj, overrides) => + expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id), overrides); + const expectErrorConflict = (obj, overrides) => + expectErrorResult(obj, createConflictError(obj.type, obj.id), overrides); + const expectErrorInvalidType = (obj, overrides) => + expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id), overrides); const expectMigrationArgs = (args, contains = true, n = 1) => { const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); @@ -411,6 +416,7 @@ describe('SavedObjectsRepository', () => { id: '6.0.0-alpha1', attributes: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }], + originId: 'some-origin-id', // only one of the object args has an originId, this is intentional to test both a positive and negative case }; const obj2 = { type: 'index-pattern', @@ -422,13 +428,14 @@ describe('SavedObjectsRepository', () => { const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, attributes, references, migrationVersion }) => ({ + items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ create: { _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, _source: { [type]: attributes, type, namespace, + ...(originId && { originId }), references, ...mockTimestampFields, migrationVersion: migrationVersion || { [type]: '1.1.1' }, @@ -440,9 +447,9 @@ describe('SavedObjectsRepository', () => { }; const bulkCreateSuccess = async (objects, options) => { - const multiNamespaceObjects = - options?.overwrite && - objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); + const multiNamespaceObjects = objects.filter( + ({ type, id }) => registry.isMultiNamespace(type) && id + ); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); client.mget.mockResolvedValue( @@ -507,9 +514,9 @@ describe('SavedObjectsRepository', () => { expect(client.bulk).toHaveBeenCalledTimes(1); }); - it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { + it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; - await bulkCreateSuccess(objects, { overwrite: true }); + await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; @@ -748,8 +755,9 @@ describe('SavedObjectsRepository', () => { expect.objectContaining({ body: body2 }), expect.anything() ); + const expectedError = expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }); expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); }); @@ -911,6 +919,7 @@ describe('SavedObjectsRepository', () => { id: '1', }, ], + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case }; const obj2 = { type: 'index-pattern', @@ -1045,6 +1054,7 @@ describe('SavedObjectsRepository', () => { type, id, namespaces: doc._source.namespaces ?? ['default'], + ...(doc._source.originId && { originId: doc._source.originId }), ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -1115,21 +1125,29 @@ describe('SavedObjectsRepository', () => { attributes: { title: 'Test Two' }, }; const references = [{ name: 'ref_0', type: 'test', id: '1' }]; + const originId = 'some-origin-id'; const namespace = 'foo-namespace'; - const getMockBulkUpdateResponse = (objects, options) => ({ + const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ update: { _id: `${ registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' }${type}:${id}`, ...mockVersionProps, + get: { + _source: { + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }, + }, result: 'updated', }, })), }); - const bulkUpdateSuccess = async (objects, options) => { + const bulkUpdateSuccess = async (objects, options, includeOriginId) => { const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); @@ -1137,7 +1155,7 @@ describe('SavedObjectsRepository', () => { elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); } - const response = getMockBulkUpdateResponse(objects, options?.namespace); + const response = getMockBulkUpdateResponse(objects, options?.namespace, includeOriginId); client.bulk.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -1443,9 +1461,10 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ + const expectSuccessResult = ({ type, id, attributes, references, namespaces, originId }) => ({ type, id, + originId, attributes, references, version: mockVersion, @@ -1496,6 +1515,133 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkUpdateSuccess([obj1, obj], {}, true); + expect(result).toEqual({ + saved_objects: [ + expect.objectContaining({ originId }), + expect.objectContaining({ originId }), + ], + }); + }); + }); + }); + + describe('#checkConflicts', () => { + const obj1 = { type: 'dashboard', id: 'one' }; + const obj2 = { type: 'dashboard', id: 'two' }; + const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; + const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; + const namespace = 'foo-namespace'; + + const checkConflicts = async (objects, options) => + savedObjectsRepository.checkConflicts( + objects.map(({ type, id }) => ({ type, id })), // checkConflicts only uses type and id + options + ); + const checkConflictsSuccess = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + const result = await checkConflicts(objects, options); + expect(client.mget).toHaveBeenCalledTimes(1); + return result; + }; + + const _expectClientCallArgs = ( + objects, + { _index = expect.any(String), getId = () => expect.any(String) } + ) => { + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }), + expect.anything() + ); + }; + + describe('cluster calls', () => { + it(`doesn't make a cluster call if the objects array is empty`, async () => { + await checkConflicts([]); + expect(client.mget).not.toHaveBeenCalled(); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await checkConflictsSuccess([obj1, obj2], { namespace }); + _expectClientCallArgs([obj1, obj2], { getId }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await checkConflictsSuccess([obj1, obj2]); + _expectClientCallArgs([obj1, obj2], { getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + // obj3 is multi-namespace, and obj6 is namespace-agnostic + await checkConflictsSuccess([obj3, obj6], { namespace }); + _expectClientCallArgs([obj3, obj6], { getId }); + }); + }); + + describe('returns', () => { + it(`expected results`, async () => { + const unknownTypeObj = { type: 'unknownType', id: 'three' }; + const hiddenTypeObj = { type: HIDDEN_TYPE, id: 'three' }; + const objects = [unknownTypeObj, hiddenTypeObj, obj1, obj2, obj3, obj4, obj5, obj6, obj7]; + const response = { + status: 200, + docs: [ + getMockGetResponse(obj1), + { found: false }, + getMockGetResponse(obj3), + getMockGetResponse({ ...obj4, namespace: 'bar-namespace' }), + { found: false }, + getMockGetResponse(obj6), + { found: false }, + ], + }; + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + + const result = await checkConflicts(objects); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + errors: [ + { ...unknownTypeObj, error: createUnsupportedTypeError(unknownTypeObj.type) }, + { ...hiddenTypeObj, error: createUnsupportedTypeError(hiddenTypeObj.type) }, + { ...obj1, error: createConflictError(obj1.type, obj1.id) }, + // obj2 was not found so it does not result in a conflict error + { ...obj3, error: createConflictError(obj3.type, obj3.id) }, + { + ...obj4, + error: { + ...createConflictError(obj4.type, obj4.id), + metadata: { isNotOverwritable: true }, + }, + }, + // obj5 was not found so it does not result in a conflict error + { ...obj6, error: createConflictError(obj6.type, obj6.id) }, + // obj7 was not found so it does not result in a conflict error + ], + }); + }); }); }); @@ -1513,6 +1659,7 @@ describe('SavedObjectsRepository', () => { const attributes = { title: 'Logstash' }; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; const references = [ { name: 'ref_0', @@ -1594,6 +1741,26 @@ describe('SavedObjectsRepository', () => { await test(null); }); + it(`defaults to no originId`, async () => { + await createSuccess(type, attributes, { id }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.not.objectContaining({ originId: expect.anything() }), + }), + expect.anything() + ); + }); + + it(`accepts custom originId`, async () => { + await createSuccess(type, attributes, { id, originId }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ originId }), + }), + expect.anything() + ); + }); + it(`defaults to a refresh setting of wait_for`, async () => { await createSuccess(type, attributes); expect(client.create).toHaveBeenCalledWith( @@ -1763,10 +1930,16 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - const result = await createSuccess(type, attributes, { id, namespace, references }); + const result = await createSuccess(type, attributes, { + id, + namespace, + references, + originId, + }); expect(result).toEqual({ type, id, + originId, ...mockTimestampFields, version: mockVersion, attributes, @@ -2063,6 +2236,7 @@ describe('SavedObjectsRepository', () => { ...mockVersionProps, _source: { namespace, + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { @@ -2188,6 +2362,7 @@ describe('SavedObjectsRepository', () => { 'references', 'migrationVersion', 'updated_at', + 'originId', 'title', ], }), @@ -2283,6 +2458,7 @@ describe('SavedObjectsRepository', () => { expect(response.saved_objects[i]).toEqual({ id: doc._id.replace(/(index-pattern|config|globalType)\:/, ''), type: doc._source.type, + originId: doc._source.originId, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2309,6 +2485,7 @@ describe('SavedObjectsRepository', () => { expect(response.saved_objects[i]).toEqual({ id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), type: doc._source.type, + originId: doc._source.originId, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2438,9 +2615,17 @@ describe('SavedObjectsRepository', () => { const type = 'index-pattern'; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; - const getSuccess = async (type, id, options) => { - const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + const getSuccess = async (type, id, options, includeOriginId) => { + const response = getMockGetResponse({ + type, + id, + namespace: options?.namespace, + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -2567,6 +2752,11 @@ describe('SavedObjectsRepository', () => { namespaces: ['default'], }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const result = await getSuccess(type, id, {}, true); + expect(result).toMatchObject({ originId }); + }); }); }); @@ -2575,6 +2765,7 @@ describe('SavedObjectsRepository', () => { const id = 'one'; const field = 'buildNum'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; const incrementCounterSuccess = async (type, id, field, options) => { const isMultiNamespace = registry.isMultiNamespace(type); @@ -2764,6 +2955,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }, }, }) @@ -2787,6 +2979,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }); }); }); @@ -3149,8 +3342,9 @@ describe('SavedObjectsRepository', () => { id: '1', }, ]; + const originId = 'some-origin-id'; - const updateSuccess = async (type, id, attributes, options) => { + const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); client.get.mockResolvedValueOnce( @@ -3167,6 +3361,10 @@ describe('SavedObjectsRepository', () => { _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace, + + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), }, }, }) @@ -3299,7 +3497,7 @@ describe('SavedObjectsRepository', () => { it(`includes _source_includes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ _source_includes: ['namespace', 'namespaces'] }), + expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() ); }); @@ -3308,7 +3506,7 @@ describe('SavedObjectsRepository', () => { await updateSuccess(type, id, attributes); expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ - _source_includes: ['namespace', 'namespaces'], + _source_includes: ['namespace', 'namespaces', 'originId'], }), expect.anything() ); @@ -3396,6 +3594,11 @@ describe('SavedObjectsRepository', () => { namespaces: ['default'], }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const result = await updateSuccess(type, id, attributes, {}, true); + expect(result).toMatchObject({ originId }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 28d409f7b65bb..dd25989725f3e 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -29,7 +29,7 @@ import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { SavedObjectsErrorHelpers } from './errors'; +import { SavedObjectsErrorHelpers, DecoratedError } from './errors'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { KibanaMigrator } from '../../migrations'; import { @@ -43,6 +43,8 @@ import { SavedObjectsBulkGetObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, SavedObjectsCreateOptions, SavedObjectsFindResponse, SavedObjectsFindResult, @@ -222,6 +224,7 @@ export class SavedObjectsRepository { overwrite = false, references = [], refresh = DEFAULT_REFRESH_SETTING, + originId, version, } = options; @@ -249,6 +252,7 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + originId, attributes, migrationVersion, updated_at: time, @@ -300,14 +304,13 @@ export class SavedObjectsRepository { error: { id: object.id, type: object.type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type)), }, }; } const method = object.id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = - method === 'index' && this._registry.isMultiNamespace(object.type); + const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); if (object.id == null) object.id = uuid.v1(); @@ -366,7 +369,10 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, }, }; } @@ -394,6 +400,7 @@ export class SavedObjectsRepository { ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, references: object.references || [], + originId: object.originId, }) as SavedObjectSanitizedDoc ), }; @@ -449,6 +456,87 @@ export class SavedObjectsRepository { }; } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + */ + async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise { + if (objects.length === 0) { + return { errors: [] }; + } + + const { namespace } = options; + + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map((object) => { + const { type, id } = object; + + if (!this._allowedTypes.includes(type)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), + }, + }; + } + + return { + tag: 'Right' as 'Right', + value: { + type, + id, + esRequestIndex: bulkGetRequestIndexCounter++, + }, + }; + }); + + const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, + }, + { ignore: [404] } + ) + : undefined; + + const errors: SavedObjectsCheckConflictsResponse['errors'] = []; + expectedBulkGetResults.forEach((expectedResult) => { + if (isLeft(expectedResult)) { + errors.push(expectedResult.error as any); + return; + } + + const { type, id, esRequestIndex } = expectedResult.value; + const doc = bulkGetResponse?.body.docs[esRequestIndex]; + if (doc.found) { + errors.push({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(!this.rawDocExistsInNamespace(doc, namespace) && { + metadata: { isNotOverwritable: true }, + }), + }, + }); + } + }); + + return { errors }; + } + /** * Deletes an object * @@ -606,6 +694,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator = 'OR', searchFields, + rootSearchFields, hasReference, page = 1, perPage = 20, @@ -669,6 +758,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator, searchFields, + rootSearchFields, type: allowedTypes, sortField, sortOrder, @@ -740,7 +830,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), }, }; } @@ -787,12 +877,11 @@ export class SavedObjectsRepository { return ({ id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), } as any) as SavedObject; } - const time = doc._source.updated_at; - + const { originId, updated_at: updatedAt } = doc._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)]; @@ -802,7 +891,8 @@ export class SavedObjectsRepository { id, type, namespaces, - ...(time && { updated_at: time }), + ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], @@ -847,7 +937,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { updated_at: updatedAt } = body._source; + const { originId, updated_at: updatedAt } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -858,6 +948,7 @@ export class SavedObjectsRepository { id, type, namespaces, + ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(body), attributes: body._source[type], @@ -912,7 +1003,7 @@ export class SavedObjectsRepository { body: { doc, }, - _source_includes: ['namespace', 'namespaces'], + _source_includes: ['namespace', 'namespaces', 'originId'], }, { ignore: [404] } ); @@ -922,6 +1013,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + const { originId } = body.get._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = body.get._source.namespaces ?? [getNamespaceString(body.get._source.namespace)]; @@ -934,6 +1026,7 @@ export class SavedObjectsRepository { // @ts-expect-error update doesn't have _seq_no, _primary_term as Record / any in LP version: encodeHitVersion(body), namespaces, + ...(originId && { originId }), references, attributes, }; @@ -1127,7 +1220,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), }, }; } @@ -1196,7 +1289,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), }, }; } @@ -1239,6 +1332,7 @@ export class SavedObjectsRepository { ? await this.client.bulk({ refresh, body: bulkUpdateParams, + _source_includes: ['originId'], }) : undefined; @@ -1250,7 +1344,9 @@ export class SavedObjectsRepository { const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; const response = bulkUpdateResponse?.body.items[esRequestIndex]; - const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( + // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the + // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. + const { error, _seq_no: seqNo, _primary_term: primaryTerm, get } = Object.values( response )[0] as any; @@ -1263,10 +1359,13 @@ export class SavedObjectsRepository { error: getBulkOperationError(error, type, id), }; } + + const { originId } = get._source; return { id, type, ...(namespaces && { namespaces }), + ...(originId && { originId }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -1291,7 +1390,7 @@ export class SavedObjectsRepository { id: string, counterFieldName: string, options: SavedObjectsIncrementCounterOptions = {} - ) { + ): Promise { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -1354,9 +1453,12 @@ export class SavedObjectsRepository { }, }); + const { originId } = body.get._source; return { id, type, + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + ...(originId && { originId }), updated_at: time, references: body.get._source.references, // @ts-expect-error @@ -1493,9 +1595,9 @@ export class SavedObjectsRepository { function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { switch (error.type) { case 'version_conflict_engine_exception': - return SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); case 'document_missing_exception': - return SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; + return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); default: return { message: error.reason || JSON.stringify(error), @@ -1547,4 +1649,9 @@ function getSavedObjectNamespaces( return [getNamespaceString(namespace)]; } +/** + * Extracts the contents of a decorated error to return the attributes for bulk operations. + */ +const errorContent = (error: DecoratedError) => error.output.payload; + const unique = (array: string[]) => [...new Set(array)]; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index f916638c5251b..85c47029e36d5 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -309,13 +309,19 @@ describe('#getQueryParams', () => { }); }); - describe('`searchFields` parameter', () => { + describe('`searchFields` and `rootSearchFields` parameters', () => { const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); }; - const test = (searchFields: string[]) => { + const test = ({ + searchFields, + rootSearchFields, + }: { + searchFields?: string[]; + rootSearchFields?: string[]; + }) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { const result = getQueryParams({ mappings, @@ -323,8 +329,12 @@ describe('#getQueryParams', () => { type: typeOrTypes, search, searchFields, + rootSearchFields, }); - const fields = getExpectedFields(searchFields, typeOrTypes); + let fields = rootSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + } expectResult(result, expect.objectContaining({ fields })); } // also test with no specified type/s @@ -334,31 +344,63 @@ describe('#getQueryParams', () => { type: undefined, search, searchFields, + rootSearchFields, }); - const fields = getExpectedFields(searchFields, ALL_TYPES); + let fields = rootSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); + } expectResult(result, expect.objectContaining({ fields })); }; - it('includes lenient flag and all fields when `searchFields` is not specified', () => { + it('throws an error if a raw search field contains a "." character', () => { + expect(() => + getQueryParams({ + mappings, + registry, + type: undefined, + search, + searchFields: undefined, + rootSearchFields: ['foo', 'bar.baz'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` + ); + }); + + it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { const result = getQueryParams({ mappings, registry, search, searchFields: undefined, + rootSearchFields: undefined, }); expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); }); it('includes specified search fields for appropriate type/s', () => { - test(['title']); + test({ searchFields: ['title'] }); }); it('supports boosting', () => { - test(['title^3']); + test({ searchFields: ['title^3'] }); + }); + + it('supports multiple search fields', () => { + test({ searchFields: ['title, title.raw'] }); + }); + + it('includes specified raw search fields', () => { + test({ rootSearchFields: ['_id'] }); + }); + + it('supports multiple raw search fields', () => { + test({ rootSearchFields: ['_id', 'originId'] }); }); - it('supports multiple fields', () => { - test(['title, title.raw']); + it('supports search fields and raw search fields', () => { + test({ searchFields: ['title'], rootSearchFields: ['_id'] }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 164756f9796a5..ad1a08187dc32 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -39,17 +39,27 @@ function getTypes(mappings: IndexMapping, type?: string | string[]) { } /** - * Get the field params based on the types and searchFields + * Get the field params based on the types, searchFields, and rootSearchFields */ -function getFieldsForTypes(types: string[], searchFields?: string[]) { - if (!searchFields || !searchFields.length) { +function getFieldsForTypes( + types: string[], + searchFields: string[] = [], + rootSearchFields: string[] = [] +) { + if (!searchFields.length && !rootSearchFields.length) { return { lenient: true, fields: ['*'], }; } - let fields: string[] = []; + let fields = [...rootSearchFields]; + fields.forEach((field) => { + if (field.indexOf('.') !== -1) { + throw new Error(`rootSearchFields entry "${field}" is invalid: cannot contain "." character`); + } + }); + for (const field of searchFields) { fields = fields.concat(types.map((prefix) => `${prefix}.${field}`)); } @@ -119,6 +129,7 @@ interface QueryParams { type?: string | string[]; search?: string; searchFields?: string[]; + rootSearchFields?: string[]; defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; @@ -134,6 +145,7 @@ export function getQueryParams({ type, search, searchFields, + rootSearchFields, defaultSearchOperator, hasReference, kueryNode, @@ -199,7 +211,7 @@ export function getQueryParams({ { simple_query_string: { query: search, - ...getFieldsForTypes(types, searchFields), + ...getFieldsForTypes(types, searchFields, rootSearchFields), ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), }, }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 08ad72397e4a2..62e629ad33cc8 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,12 +57,13 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { const opts = { namespaces: ['foo-namespace'], type: 'foo', search: 'bar', searchFields: ['baz'], + rootSearchFields: ['qux'], defaultSearchOperator: 'AND', hasReference: { type: 'bar', @@ -79,6 +80,7 @@ describe('getSearchDsl', () => { type: opts.type, search: opts.search, searchFields: opts.searchFields, + rootSearchFields: opts.rootSearchFields, defaultSearchOperator: opts.defaultSearchOperator, hasReference: opts.hasReference, }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 6de868c320240..ddf20606800c8 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -31,6 +31,7 @@ interface GetSearchDslOptions { search?: string; defaultSearchOperator?: string; searchFields?: string[]; + rootSearchFields?: string[]; sortField?: string; sortOrder?: string; namespaces?: string[]; @@ -51,6 +52,7 @@ export function getSearchDsl( search, defaultSearchOperator, searchFields, + rootSearchFields, sortField, sortOrder, namespaces, @@ -74,6 +76,7 @@ export function getSearchDsl( type, search, searchFields, + rootSearchFields, defaultSearchOperator, hasReference, kueryNode, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index c7a4b7df06547..a44b21aef5706 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -178,6 +178,20 @@ describe('searchDsl/getSortParams', () => { }); }); }); + describe('sortField is root simple property with single type', () => { + it('returns correct params', () => { + expect(getSortingParams(MAPPINGS, ['saved'], 'type', 'desc')).toEqual({ + sort: [ + { + type: { + order: 'desc', + unmapped_type: 'text', + }, + }, + ], + }); + }); + }); describe('sortField is root simple property with multiple type', () => { it('returns correct params', () => { expect(getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', 'desc')).toEqual({ diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index f850954e84323..ccf5ccd50bb75 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -67,10 +67,15 @@ export function getSortingParams( } const [typeField] = types; - const key = `${typeField}.${sortField}`; - const field = getProperty(mappings, key); + let key = `${typeField}.${sortField}`; + let field = getProperty(mappings, key); if (!field) { - throw Boom.badRequest(`Unknown sort field ${sortField}`); + // type field does not exist, try checking the root properties + key = sortField; + field = getProperty(mappings, sortField); + if (!field) { + throw Boom.badRequest(`Unknown sort field ${sortField}`); + } } return { diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index b209c9ca54f63..3b0789970cc6b 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -25,6 +25,7 @@ const create = () => errors: SavedObjectsErrorHelpers, create: jest.fn(), bulkCreate: jest.fn(), + checkConflicts: jest.fn(), bulkUpdate: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 53bb31369adbf..47011414cbc7f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -35,6 +35,21 @@ test(`#create`, async () => { expect(result).toBe(returnValue); }); +test(`#checkConflicts`, async () => { + const returnValue = Symbol(); + const mockRepository = { + checkConflicts: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const objects = Symbol(); + const options = Symbol(); + const result = await client.checkConflicts(objects, options); + + expect(mockRepository.checkConflicts).toHaveBeenCalledWith(objects, options); + expect(result).toBe(returnValue); +}); + test(`#bulkCreate`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 812669ee108a2..347c760f841bc 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -20,6 +20,7 @@ import { ISavedObjectsRepository } from './lib'; import { SavedObject, + SavedObjectError, SavedObjectReference, SavedObjectsMigrationVersion, SavedObjectsBaseOptions, @@ -47,6 +48,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** Optional ID of the original saved object, if this object's `id` was regenerated */ + originId?: string; } /** @@ -61,6 +64,8 @@ export interface SavedObjectsBulkCreateObject { references?: SavedObjectReference[]; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** Optional ID of the original saved object, if this object's `id` was regenerated */ + originId?: string; } /** @@ -111,6 +116,27 @@ export interface SavedObjectsFindResponse { page: number; } +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsObject { + id: string; + type: string; +} + +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsResponse { + errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +} + /** * * @public @@ -256,6 +282,20 @@ export class SavedObjectsClient { return await this._repository.bulkCreate(objects, options); } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param objects + * @param options + */ + async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise { + return await this._repository.checkConflicts(objects, options); + } + /** * Deletes a SavedObject * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index f9301d6598b1d..edbdbe4d16784 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -24,7 +24,9 @@ import { PropertyValidators } from './validation'; export { SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, @@ -42,6 +44,7 @@ export { SavedObjectAttribute, SavedObjectAttributeSingle, SavedObject, + SavedObjectError, SavedObjectReference, SavedObjectsMigrationVersion, } from '../../types'; @@ -79,6 +82,11 @@ export interface SavedObjectsFindOptions { search?: string; /** The fields to perform the parsed query against. See Elasticsearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** + * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not + * be modified. If used in conjunction with `searchFields`, both are concatenated together. + */ + rootSearchFields?: string[]; hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; filter?: string; @@ -172,9 +180,6 @@ export type SavedObjectsClientContract = Pick; +export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; // @public @deprecated (undocumented) export interface IndexSettingsDeprecationInfo { @@ -1778,6 +1776,7 @@ export interface PluginInitializerContext { env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; // (undocumented) logger: LoggerFactory; @@ -1861,7 +1860,7 @@ export type RequestHandlerContextProvider(handler: RequestHandler) => RequestHandler; // @public -export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsResolveImportErrorsOptions): Promise; +export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise; // @public export type ResponseError = string | Error | { @@ -1967,14 +1966,14 @@ export type SafeRouteMethod = 'get' | 'options'; // @public (undocumented) export interface SavedObject { attributes: T; + // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts + // // (undocumented) - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; id: string; migrationVersion?: SavedObjectsMigrationVersion; namespaces?: string[]; + originId?: string; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -2046,6 +2045,7 @@ export interface SavedObjectsBulkCreateObject { // (undocumented) id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; // (undocumented) references?: SavedObjectReference[]; // (undocumented) @@ -2093,6 +2093,24 @@ export interface SavedObjectsBulkUpdateResponse { saved_objects: Array>; } +// @public (undocumented) +export interface SavedObjectsCheckConflictsObject { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsCheckConflictsResponse { + // (undocumented) + errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +} + // @public (undocumented) export class SavedObjectsClient { // @internal @@ -2101,6 +2119,7 @@ export class SavedObjectsClient { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; @@ -2182,6 +2201,7 @@ export interface SavedObjectsCoreFieldMapping { export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; overwrite?: boolean; // (undocumented) references?: SavedObjectReference[]; @@ -2314,6 +2334,7 @@ export interface SavedObjectsFindOptions { // (undocumented) perPage?: number; preference?: string; + rootSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -2341,8 +2362,22 @@ export interface SavedObjectsFindResult extends SavedObject { score: number; } +// @public +export interface SavedObjectsImportAmbiguousConflictError { + // (undocumented) + destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; + // (undocumented) + type: 'ambiguous_conflict'; +} + // @public export interface SavedObjectsImportConflictError { + // (undocumented) + destinationId?: string; // (undocumented) type: 'conflict'; } @@ -2350,10 +2385,16 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // @deprecated (undocumented) title?: string; // (undocumented) type: string; @@ -2361,11 +2402,6 @@ export interface SavedObjectsImportError { // @public export interface SavedObjectsImportMissingReferencesError { - // (undocumented) - blocking: Array<{ - type: string; - id: string; - }>; // (undocumented) references: Array<{ type: string; @@ -2377,12 +2413,13 @@ export interface SavedObjectsImportMissingReferencesError { // @public export interface SavedObjectsImportOptions { + createNewCopies: boolean; namespace?: string; objectLimit: number; overwrite: boolean; readStream: Readable; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + typeRegistry: ISavedObjectTypeRegistry; } // @public @@ -2393,12 +2430,17 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + createNewCopy?: boolean; + destinationId?: string; // (undocumented) id: string; + ignoreMissingReferences?: boolean; // (undocumented) overwrite: boolean; // (undocumented) @@ -2411,6 +2453,23 @@ export interface SavedObjectsImportRetry { type: string; } +// @public +export interface SavedObjectsImportSuccess { + // @deprecated (undocumented) + createNewCopy?: boolean; + destinationId?: string; + // (undocumented) + id: string; + // (undocumented) + meta: { + title?: string; + icon?: string; + }; + overwrite?: boolean; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsImportUnknownError { // (undocumented) @@ -2512,6 +2571,7 @@ export class SavedObjectsRepository { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "KibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2521,16 +2581,9 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) - find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>; + incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2542,12 +2595,13 @@ export interface SavedObjectsRepositoryFactory { // @public export interface SavedObjectsResolveImportErrorsOptions { + createNewCopies: boolean; namespace?: string; objectLimit: number; readStream: Readable; retries: SavedObjectsImportRetry[]; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + typeRegistry: ISavedObjectTypeRegistry; } // @internal @deprecated (undocumented) @@ -2883,11 +2937,6 @@ export interface UserProvidedValues { userValue?: T; } -// @public -export interface UuidServiceSetup { - getInstanceUuid(): string; -} - // @public export const validBodyOutput: readonly ["data", "stream"]; diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 82d0c095bfe95..471e482a20e96 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -74,10 +74,10 @@ import { RenderingService, mockRenderingService } from './rendering/__mocks__/re export { mockRenderingService }; jest.doMock('./rendering/rendering_service', () => ({ RenderingService })); -import { uuidServiceMock } from './uuid/uuid_service.mock'; -export const mockUuidService = uuidServiceMock.create(); -jest.doMock('./uuid/uuid_service', () => ({ - UuidService: jest.fn(() => mockUuidService), +import { environmentServiceMock } from './environment/environment_service.mock'; +export const mockEnvironmentService = environmentServiceMock.create(); +jest.doMock('./environment/environment_service', () => ({ + EnvironmentService: jest.fn(() => mockEnvironmentService), })); import { metricsServiceMock } from './metrics/metrics_service.mock'; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index aff749ca97534..cc6d8171e7a03 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -31,7 +31,7 @@ import { PluginsService, config as pluginsConfig } from './plugins'; import { SavedObjectsService } from '../server/saved_objects'; import { MetricsService, opsConfig } from './metrics'; import { CapabilitiesService } from './capabilities'; -import { UuidService } from './uuid'; +import { EnvironmentService } from './environment'; import { StatusService } from './status/status_service'; import { config as cspConfig } from './csp'; @@ -64,7 +64,7 @@ export class Server { private readonly plugins: PluginsService; private readonly savedObjects: SavedObjectsService; private readonly uiSettings: UiSettingsService; - private readonly uuid: UuidService; + private readonly environment: EnvironmentService; private readonly metrics: MetricsService; private readonly httpResources: HttpResourcesService; private readonly status: StatusService; @@ -95,7 +95,7 @@ export class Server { this.savedObjects = new SavedObjectsService(core); this.uiSettings = new UiSettingsService(core); this.capabilities = new CapabilitiesService(core); - this.uuid = new UuidService(core); + this.environment = new EnvironmentService(core); this.metrics = new MetricsService(core); this.status = new StatusService(core); this.coreApp = new CoreApp(core); @@ -107,8 +107,12 @@ export class Server { public async setup() { this.log.debug('setting up server'); + const environmentSetup = await this.environment.setup(); + // Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph. - const { pluginTree, uiPlugins } = await this.plugins.discover(); + const { pluginTree, uiPlugins } = await this.plugins.discover({ + environment: environmentSetup, + }); const legacyPlugins = await this.legacy.discoverPlugins(); // Immediately terminate in case of invalid configuration @@ -124,7 +128,6 @@ export class Server { }); const auditTrailSetup = this.auditTrail.setup(); - const uuidSetup = await this.uuid.setup(); const httpSetup = await this.http.setup({ context: contextServiceSetup, @@ -174,11 +177,11 @@ export class Server { capabilities: capabilitiesSetup, context: contextServiceSetup, elasticsearch: elasticsearchServiceSetup, + environment: environmentSetup, http: httpSetup, savedObjects: savedObjectsSetup, status: statusSetup, uiSettings: uiSettingsSetup, - uuid: uuidSetup, rendering: renderingSetup, httpResources: httpResourcesSetup, auditTrail: auditTrailSetup, diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts index d2e31dad58e55..61b71f8c5de07 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts @@ -24,7 +24,7 @@ import { TestElasticsearchUtils, TestKibanaUtils, TestUtils, -} from '../../../../../test_utils/kbn_server'; +} from '../../../../test_helpers/kbn_server'; import { createOrUpgradeSavedConfig } from '../create_or_upgrade_saved_config'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { httpServerMock } from '../../../http/http_server.mocks'; diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index e704532ee4cdf..7353f5d3eb760 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -24,22 +24,6 @@ import { docMissingSuite } from './doc_missing'; import { docMissingAndIndexReadOnlySuite } from './doc_missing_and_index_read_only'; describe('uiSettings/routes', function () { - /** - * The "doc missing" and "index missing" tests verify how the uiSettings - * API behaves in between healthChecks, so they interact with the healthCheck - * in somewhat weird ways (can't wait until we get to https://github.com/elastic/kibana/issues/14163) - * - * To make this work we have a `waitUntilNextHealthCheck()` function in ./lib/servers.js - * that deletes the kibana index and then calls `plugins.elasticsearch.waitUntilReady()`. - * - * waitUntilReady() waits for the kibana index to exist and then for the - * elasticsearch plugin to go green. Since we have verified that the kibana index - * does not exist we know that the plugin will also turn yellow while waiting for - * it and then green once the health check is complete, ensuring that we run our - * tests right after the health check. All of this is to say that the tests are - * stupidly fragile and timing sensitive. #14163 should fix that, but until then - * this is the most stable way I've been able to get this to work. - */ jest.setTimeout(10000); beforeAll(startServers); diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index ea462291059a5..2e6c76cb99a21 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -24,7 +24,7 @@ import { TestElasticsearchUtils, TestKibanaUtils, TestUtils, -} from '../../../../../test_utils/kbn_server'; +} from '../../../../test_helpers/kbn_server'; import { LegacyAPICaller } from '../../../elasticsearch/'; import { httpServerMock } from '../../../http/http_server.mocks'; @@ -39,7 +39,6 @@ interface AllServices { savedObjectsClient: SavedObjectsClientContract; callCluster: LegacyAPICaller; uiSettings: IUiSettingsClient; - deleteKibanaIndex: typeof deleteKibanaIndex; } let services: AllServices; @@ -62,20 +61,6 @@ export async function startServers() { kbnServer = kbn.kbnServer; } -async function deleteKibanaIndex(callCluster: LegacyAPICaller) { - const kibanaIndices = await callCluster('cat.indices', { index: '.kibana*', format: 'json' }); - const indexNames = kibanaIndices.map((x: any) => x.index); - if (!indexNames.length) { - return; - } - await callCluster('indices.putSettings', { - index: indexNames, - body: { index: { blocks: { read_only: false } } }, - }); - await callCluster('indices.delete', { index: indexNames }); - return indexNames; -} - export function getServices() { if (services) { return services; @@ -97,7 +82,6 @@ export function getServices() { callCluster, savedObjectsClient, uiSettings, - deleteKibanaIndex, }; return services; diff --git a/src/core/server/ui_settings/integration_tests/routes.test.ts b/src/core/server/ui_settings/integration_tests/routes.test.ts index b18cc370fac3c..063d68e3866b7 100644 --- a/src/core/server/ui_settings/integration_tests/routes.test.ts +++ b/src/core/server/ui_settings/integration_tests/routes.test.ts @@ -18,7 +18,7 @@ */ import { schema } from '@kbn/config-schema'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; describe('ui settings service', () => { describe('routes', () => { diff --git a/src/test_utils/public/http_test_setup.ts b/src/core/test_helpers/http_test_setup.ts similarity index 85% rename from src/test_utils/public/http_test_setup.ts rename to src/core/test_helpers/http_test_setup.ts index 7c70f64887af1..50ea43fb22b5e 100644 --- a/src/test_utils/public/http_test_setup.ts +++ b/src/core/test_helpers/http_test_setup.ts @@ -17,9 +17,9 @@ * under the License. */ -import { HttpService } from '../../core/public/http'; -import { fatalErrorsServiceMock } from '../../core/public/fatal_errors/fatal_errors_service.mock'; -import { injectedMetadataServiceMock } from '../../core/public/injected_metadata/injected_metadata_service.mock'; +import { HttpService } from '../public/http'; +import { fatalErrorsServiceMock } from '../public/fatal_errors/fatal_errors_service.mock'; +import { injectedMetadataServiceMock } from '../public/injected_metadata/injected_metadata_service.mock'; export type SetupTap = ( injectedMetadata: ReturnType, diff --git a/src/test_utils/kbn_server.ts b/src/core/test_helpers/kbn_server.ts similarity index 94% rename from src/test_utils/kbn_server.ts rename to src/core/test_helpers/kbn_server.ts index e337a469f17e6..b9714884f8e1e 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -26,16 +26,15 @@ import { kibanaServerTestUser, kibanaTestUser, setupUsers, - // @ts-ignore: implicit any for JS file } from '@kbn/test'; import { defaultsDeep, get } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; import supertest from 'supertest'; -import { CliArgs, Env } from '../core/server/config'; -import { Root } from '../core/server/root'; -import KbnServer from '../legacy/server/kbn_server'; -import { CallCluster } from '../legacy/core_plugins/elasticsearch'; +import { LegacyAPICaller } from '../server/'; +import { CliArgs, Env } from '../server/config'; +import { Root } from '../server/root'; +import KbnServer from '../../legacy/server/kbn_server'; export type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; @@ -53,7 +52,7 @@ const DEFAULTS_SETTINGS = { }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { - plugins: { scanDirs: [resolve(__dirname, '../legacy/core_plugins')] }, + plugins: { scanDirs: [resolve(__dirname, '../../legacy/core_plugins')] }, elasticsearch: { hosts: [esTestConfig.getUrl()], username: kibanaServerTestUser.username, @@ -156,7 +155,7 @@ export interface TestElasticsearchServer { stop: () => Promise; cleanup: () => Promise; getClient: () => Client; - getCallCluster: () => CallCluster; + getCallCluster: () => LegacyAPICaller; getUrl: () => string; } @@ -292,7 +291,6 @@ export function createTestServers({ await root.start(); const kbnServer = getKbnServer(root); - await kbnServer.server.plugins.elasticsearch.waitUntilReady(); return { root, diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json new file mode 100644 index 0000000000000..c6a7308c1f9d1 --- /dev/null +++ b/src/core/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { +// "composite": true, + "outDir": "./target", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public", + "server", + "types", + "test_helpers", + "utils", + "index.ts", + "../../kibana.d.ts", + "../../typings/**/*" + ], + "references": [ + { "path": "../test_utils" } + ] +} diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 04aaacc3cf31a..9abc093c74fb3 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -86,10 +86,7 @@ export interface SavedObject { version?: string; /** Timestamp of the last time this document had been updated. */ updated_at?: string; - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; /** {@inheritdoc SavedObjectAttributes} */ attributes: T; /** {@inheritdoc SavedObjectReference} */ @@ -98,4 +95,18 @@ export interface SavedObject { migrationVersion?: SavedObjectsMigrationVersion; /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ namespaces?: string[]; + /** + * The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration + * from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import + * to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given + * space. + */ + originId?: string; +} + +export interface SavedObjectError { + error: string; + message: string; + statusCode: number; + metadata?: Record; } diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index cc9bfb1db04d5..1fb7c284c0dfd 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -46,7 +46,7 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ order: 3000, }, security: { - id: 'security', + id: 'securitySolution', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), diff --git a/src/dev/build/lib/fs.ts b/src/dev/build/lib/fs.ts index d86901c41e436..a91113ab2d1c4 100644 --- a/src/dev/build/lib/fs.ts +++ b/src/dev/build/lib/fs.ts @@ -273,7 +273,16 @@ export async function compressTar({ archive.pipe(output); - return archive.directory(source, name).finalize(); + let fileCount = 0; + archive.on('entry', (entry) => { + if (entry.stats?.isFile()) { + fileCount += 1; + } + }); + + await archive.directory(source, name).finalize(); + + return fileCount; } interface CompressZipOptions { @@ -294,5 +303,14 @@ export async function compressZip({ archive.pipe(output); - return archive.directory(source, name).finalize(); + let fileCount = 0; + archive.on('entry', (entry) => { + if (entry.stats?.isFile()) { + fileCount += 1; + } + }); + + await archive.directory(source, name).finalize(); + + return fileCount; } diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 7a5d84da527db..948e2357effb0 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -33,11 +33,11 @@ export const CopySource: Task = { '!src/**/{target,__tests__,__snapshots__,__mocks__}/**', '!src/test_utils/**', '!src/fixtures/**', - '!src/legacy/core_plugins/console/public/tests/**', '!src/cli/cluster/**', '!src/cli/repl/**', '!src/functional_test_runner/**', '!src/dev/**', + '!**/public/**', 'typings/**', 'config/kibana.yml', 'config/node.options', diff --git a/src/dev/build/tasks/create_archives_task.ts b/src/dev/build/tasks/create_archives_task.ts index 3ffb1afef7469..0083881e9f748 100644 --- a/src/dev/build/tasks/create_archives_task.ts +++ b/src/dev/build/tasks/create_archives_task.ts @@ -21,7 +21,7 @@ import Path from 'path'; import Fs from 'fs'; import { promisify } from 'util'; -import { CiStatsReporter } from '@kbn/dev-utils'; +import { CiStatsReporter, CiStatsMetrics } from '@kbn/dev-utils'; import { mkdirp, compressTar, compressZip, Task } from '../lib'; @@ -47,17 +47,16 @@ export const CreateArchives: Task = { archives.push({ format: 'zip', path: destination, - }); - - await compressZip({ - source, - destination, - archiverOptions: { - zlib: { - level: 9, + fileCount: await compressZip({ + source, + destination, + archiverOptions: { + zlib: { + level: 9, + }, }, - }, - createRootDirectory: true, + createRootDirectory: true, + }), }); break; @@ -65,18 +64,17 @@ export const CreateArchives: Task = { archives.push({ format: 'tar', path: destination, - }); - - await compressTar({ - source, - destination, - archiverOptions: { - gzip: true, - gzipOptions: { - level: 9, + fileCount: await compressTar({ + source, + destination, + archiverOptions: { + gzip: true, + gzipOptions: { + level: 9, + }, }, - }, - createRootDirectory: true, + createRootDirectory: true, + }), }); break; @@ -85,19 +83,22 @@ export const CreateArchives: Task = { } } - const reporter = CiStatsReporter.fromEnv(log); - if (reporter.isEnabled()) { - await reporter.metrics( - await Promise.all( - archives.map(async ({ format, path }) => { - return { - group: `${build.isOss() ? 'oss ' : ''}distributable size`, - id: format, - value: (await asyncStat(path)).size, - }; - }) - ) - ); + const metrics: CiStatsMetrics = []; + for (const { format, path, fileCount } of archives) { + metrics.push({ + group: `${build.isOss() ? 'oss ' : ''}distributable size`, + id: format, + value: (await asyncStat(path)).size, + }); + + metrics.push({ + group: `${build.isOss() ? 'oss ' : ''}distributable file count`, + id: 'total', + value: fileCount, + }); } + log.debug('archive metrics:', metrics); + + await CiStatsReporter.fromEnv(log).metrics(metrics); }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 6cf4a7af70840..362c34d416743 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -58,8 +58,8 @@ export async function runDockerGenerator( 'kibana-docker', build.isOss() ? `oss` : `default${ubiImageFlavor}` ); - const dockerOutputDir = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker.tar.gz` + const dockerTargetFilename = config.resolveFromTarget( + `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image.tar.gz` ); const scope: TemplateContext = { artifactTarball, @@ -69,7 +69,7 @@ export async function runDockerGenerator( artifactsDir, imageTag, dockerBuildDir, - dockerOutputDir, + dockerTargetFilename, baseOSImage, ubiImageFlavor, dockerBuildDate, diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index a7c40db44b87e..49fb173c5a896 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -25,7 +25,7 @@ export interface TemplateContext { artifactsDir: string; imageTag: string; dockerBuildDir: string; - dockerOutputDir: string; + dockerTargetFilename: string; baseOSImage: string; ubiImageFlavor: string; dockerBuildDate: string; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 699bba758e1c9..86a02d74dea15 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -25,7 +25,7 @@ function generator({ imageTag, imageFlavor, version, - dockerOutputDir, + dockerTargetFilename, baseOSImage, ubiImageFlavor, }: TemplateContext) { @@ -41,7 +41,7 @@ function generator({ echo "Building: kibana${imageFlavor}${ubiImageFlavor}-docker"; \\ docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} -f Dockerfile . || exit 1; - docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} | gzip -c > ${dockerOutputDir} + docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} | gzip -c > ${dockerTargetFilename} exit 0 `); diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index aabc1e75b9025..3351170c29e01 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -16,6 +16,12 @@ echo " -- TEST_ES_SNAPSHOT_VERSION='$TEST_ES_SNAPSHOT_VERSION'" echo " -- installing node.js dependencies" yarn kbn bootstrap --prefer-offline +### +### ensure Chromedriver install hook is triggered +### when modules are up-to-date +### +node node_modules/chromedriver/install.js + ### ### Download es snapshots ### diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 72ec73ad810e6..5757d72f99582 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -134,13 +134,13 @@ export CYPRESS_DOWNLOAD_MIRROR="https://us-central1-elastic-kibana-184716.cloudf export CHECKS_REPORTER_ACTIVE=false # This is mainly for release-manager builds, which run in an environment that doesn't have Chrome installed -if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then - echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" - export DETECT_CHROMEDRIVER_VERSION=true - export CHROMEDRIVER_FORCE_DOWNLOAD=true -else - echo "Chrome not detected, installing default chromedriver binary for the package version" -fi +# if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then +# echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" +# export DETECT_CHROMEDRIVER_VERSION=true +# export CHROMEDRIVER_FORCE_DOWNLOAD=true +# else +# echo "Chrome not detected, installing default chromedriver binary for the package version" +# fi ### only run on pr jobs for elastic/kibana, checks-reporter doesn't work for other repos if [[ "$ghprbPullId" && "$ghprbGhRepository" == 'elastic/kibana' ]] ; then diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 065321e355256..e18c82b5b9e96 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -24,6 +24,7 @@ import { Project } from './project'; export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'tsconfig.json')), + new Project(resolve(REPO_ROOT, 'src/test_utils/tsconfig.json')), new Project(resolve(REPO_ROOT, 'test/tsconfig.json'), { name: 'kibana/test' }), new Project(resolve(REPO_ROOT, 'x-pack/tsconfig.json')), new Project(resolve(REPO_ROOT, 'x-pack/test/tsconfig.json'), { name: 'x-pack/test' }), diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index 9eeaeb4da7042..baec29f98383f 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -79,7 +79,14 @@ export function runTypeCheckCli() { process.exit(); } - const tscArgs = ['--noEmit', '--pretty', ...(opts['skip-lib-check'] ? ['--skipLibCheck'] : [])]; + const tscArgs = [ + // composite project cannot be used with --noEmit + ...['--composite', 'false'], + ...['--emitDeclarationOnly', 'false'], + '--noEmit', + '--pretty', + ...(opts['skip-lib-check'] ? ['--skipLibCheck'] : []), + ]; const projects = filterProjectsByFlag(opts.project).filter((p) => !p.disableTypeCheck); if (!projects.length) { diff --git a/src/fixtures/agg_resp/date_histogram.js b/src/fixtures/agg_resp/date_histogram.js index 5e898697a154c..12bc4e7daf3e5 100644 --- a/src/fixtures/agg_resp/date_histogram.js +++ b/src/fixtures/agg_resp/date_histogram.js @@ -31,7 +31,7 @@ export default { hits: [], }, aggregations: { - '1': { + 1: { buckets: [ { key_as_string: '2015-01-30T01:00:00.000Z', diff --git a/src/fixtures/agg_resp/range.js b/src/fixtures/agg_resp/range.js index 6f02d9b7c7a0a..71729a06f590f 100644 --- a/src/fixtures/agg_resp/range.js +++ b/src/fixtures/agg_resp/range.js @@ -31,7 +31,7 @@ export default { hits: [], }, aggregations: { - '1': { + 1: { buckets: { '*-1024.0': { to: 1024, diff --git a/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts b/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts new file mode 100644 index 0000000000000..0ec8d2e15c34a --- /dev/null +++ b/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts @@ -0,0 +1,48 @@ +/* + * 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 { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + [key: string]: { + count_1?: number; + count_2?: number; + }; +} + +export const myCollector = makeUsageCollector({ + type: 'indexed_interface_with_not_matching_schema', + isReady: () => true, + fetch() { + if (Math.random()) { + return { something: { count_1: 1 } }; + } + return { something: { count_2: 2 } }; + }, + schema: { + something: { + count_1: { type: 'long' }, // Intentionally missing count_2 + }, + }, +}); diff --git a/src/fixtures/telemetry_collectors/working_collector.ts b/src/fixtures/telemetry_collectors/working_collector.ts index d58a89db97d74..bdf10b5e54919 100644 --- a/src/fixtures/telemetry_collectors/working_collector.ts +++ b/src/fixtures/telemetry_collectors/working_collector.ts @@ -35,6 +35,9 @@ interface Usage { my_objects: MyObject; my_array?: MyObject[]; my_str_array?: string[]; + my_index_signature_prop?: { + [key: string]: number; + }; } const SOME_NUMBER: number = 123; @@ -93,5 +96,11 @@ export const myCollector = makeUsageCollector({ type: { type: 'boolean' }, }, my_str_array: { type: 'keyword' }, + my_index_signature_prop: { + count: { type: 'number' }, + avg: { type: 'number' }, + max: { type: 'number' }, + min: { type: 'number' }, + }, }, }); diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts index 683f58b1a80ce..83e7bb19e57ba 100644 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ b/src/legacy/core_plugins/elasticsearch/index.d.ts @@ -523,6 +523,4 @@ export interface CallCluster { export interface ElasticsearchPlugin { status: { on: (status: string, cb: () => void) => void }; getCluster(name: string): Cluster; - createCluster(name: string, config: ClusterConfig): Cluster; - waitUntilReady(): Promise; } diff --git a/src/legacy/core_plugins/elasticsearch/index.js b/src/legacy/core_plugins/elasticsearch/index.js index eb502e97fb77c..599886788604b 100644 --- a/src/legacy/core_plugins/elasticsearch/index.js +++ b/src/legacy/core_plugins/elasticsearch/index.js @@ -19,14 +19,12 @@ import { first } from 'rxjs/operators'; import { Cluster } from './server/lib/cluster'; import { createProxy } from './server/lib/create_proxy'; -import { handleESError } from './server/lib/handle_es_error'; -import { versionHealthCheck } from './lib/version_health_check'; export default function (kibana) { let defaultVars; return new kibana.Plugin({ - require: ['kibana'], + require: [], uiExports: { injectDefaultVars: () => defaultVars }, @@ -61,25 +59,6 @@ export default function (kibana) { return clusters.get(name); }); - server.expose('createCluster', (name, clientConfig = {}) => { - // NOTE: Not having `admin` and `data` clients provided by the core in `clusters` - // map implicitly allows to create custom `data` and `admin` clients. This is - // allowed intentionally to support custom `admin` cluster client created by the - // x-pack/monitoring bulk uploader. We should forbid that as soon as monitoring - // bulk uploader is refactored, see https://github.com/elastic/kibana/issues/31934. - if (clusters.has(name)) { - throw new Error(`cluster '${name}' already exists`); - } - - const cluster = new Cluster( - server.newPlatform.setup.core.elasticsearch.legacy.createClient(name, clientConfig) - ); - - clusters.set(name, cluster); - - return cluster; - }); - server.events.on('stop', () => { for (const cluster of clusters.values()) { cluster.close(); @@ -88,17 +67,7 @@ export default function (kibana) { clusters.clear(); }); - server.expose('handleESError', handleESError); - createProxy(server); - - const waitUntilHealthy = versionHealthCheck( - this, - server.logWithMetadata, - server.newPlatform.__internals.elasticsearch.esNodesCompatibility$ - ); - - server.expose('waitUntilReady', () => waitUntilHealthy); }, }); } diff --git a/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts b/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts index 0331153cdf615..fbafac3c51cbd 100644 --- a/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts +++ b/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts @@ -24,7 +24,7 @@ import { TestUtils, createRootWithCorePlugins, getKbnServer, -} from '../../../../test_utils/kbn_server'; +} from '../../../../core/test_helpers/kbn_server'; import { BehaviorSubject } from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths diff --git a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js b/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js deleted file mode 100644 index 4c03c0c0105ee..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js +++ /dev/null @@ -1,71 +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 { versionHealthCheck } from './version_health_check'; -import { Subject } from 'rxjs'; - -describe('plugins/elasticsearch', () => { - describe('lib/health_version_check', function () { - let plugin; - let logWithMetadata; - - beforeEach(() => { - plugin = { - status: { - red: jest.fn(), - green: jest.fn(), - yellow: jest.fn(), - }, - }; - - logWithMetadata = jest.fn(); - jest.clearAllMocks(); - }); - - it('returned promise resolves when all nodes are compatible ', function () { - const esNodesCompatibility$ = new Subject(); - const versionHealthyPromise = versionHealthCheck( - plugin, - logWithMetadata, - esNodesCompatibility$ - ); - esNodesCompatibility$.next({ isCompatible: true, message: undefined }); - return expect(versionHealthyPromise).resolves.toBe(undefined); - }); - - it('should set elasticsearch plugin status to green when all nodes are compatible', function () { - const esNodesCompatibility$ = new Subject(); - versionHealthCheck(plugin, logWithMetadata, esNodesCompatibility$); - expect(plugin.status.yellow).toHaveBeenCalledWith('Waiting for Elasticsearch'); - expect(plugin.status.green).not.toHaveBeenCalled(); - esNodesCompatibility$.next({ isCompatible: true, message: undefined }); - expect(plugin.status.green).toHaveBeenCalledWith('Ready'); - expect(plugin.status.red).not.toHaveBeenCalled(); - }); - - it('should set elasticsearch plugin status to red when some nodes are incompatible', function () { - const esNodesCompatibility$ = new Subject(); - versionHealthCheck(plugin, logWithMetadata, esNodesCompatibility$); - expect(plugin.status.yellow).toHaveBeenCalledWith('Waiting for Elasticsearch'); - expect(plugin.status.red).not.toHaveBeenCalled(); - esNodesCompatibility$.next({ isCompatible: false, message: 'your nodes are incompatible' }); - expect(plugin.status.red).toHaveBeenCalledWith('your nodes are incompatible'); - expect(plugin.status.green).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js b/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js deleted file mode 100644 index ccab1a3b830b6..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import { handleESError } from '../handle_es_error'; -import { errors as esErrors } from 'elasticsearch'; - -describe('handleESError', function () { - it('should transform elasticsearch errors into boom errors with the same status code', function () { - const conflict = handleESError(new esErrors.Conflict()); - expect(conflict.isBoom).to.be(true); - expect(conflict.output.statusCode).to.be(409); - - const forbidden = handleESError(new esErrors[403]()); - expect(forbidden.isBoom).to.be(true); - expect(forbidden.output.statusCode).to.be(403); - - const notFound = handleESError(new esErrors.NotFound()); - expect(notFound.isBoom).to.be(true); - expect(notFound.output.statusCode).to.be(404); - - const badRequest = handleESError(new esErrors.BadRequest()); - expect(badRequest.isBoom).to.be(true); - expect(badRequest.output.statusCode).to.be(400); - }); - - it('should return an unknown error without transforming it', function () { - const unknown = new Error('mystery error'); - expect(handleESError(unknown)).to.be(unknown); - }); - - it('should return a boom 503 server timeout error for ES connection errors', function () { - expect(handleESError(new esErrors.ConnectionFault()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.ServiceUnavailable()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.NoConnections()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.RequestTimeout()).output.statusCode).to.be(503); - }); - - it('should throw an error if called with a non-error argument', function () { - expect(handleESError).withArgs('notAnError').to.throwException(); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js b/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js deleted file mode 100644 index d76b2a2aa9364..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js +++ /dev/null @@ -1,50 +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 Boom from 'boom'; -import _ from 'lodash'; -import { errors as esErrors } from 'elasticsearch'; - -export function handleESError(error) { - if (!(error instanceof Error)) { - throw new Error('Expected an instance of Error'); - } - - if ( - error instanceof esErrors.ConnectionFault || - error instanceof esErrors.ServiceUnavailable || - error instanceof esErrors.NoConnections || - error instanceof esErrors.RequestTimeout - ) { - return Boom.serverUnavailable(error); - } else if ( - error instanceof esErrors.Conflict || - _.includes(error.message, 'index_template_already_exists') - ) { - return Boom.conflict(error); - } else if (error instanceof esErrors[403]) { - return Boom.forbidden(error); - } else if (error instanceof esErrors.NotFound) { - return Boom.notFound(error); - } else if (error instanceof esErrors.BadRequest) { - return Boom.badRequest(error); - } else { - return error; - } -} diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 176c5386961a5..722d75d00f78f 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -17,13 +17,8 @@ * under the License. */ -import Fs from 'fs'; -import { promisify } from 'util'; - import { getUiSettingDefaults } from './server/ui_setting_defaults'; -const mkdirAsync = promisify(Fs.mkdir); - export default function (kibana) { return new kibana.Plugin({ id: 'kibana', @@ -40,17 +35,5 @@ export default function (kibana) { uiExports: { uiSettingDefaults: getUiSettingDefaults(), }, - - preInit: async function (server) { - try { - // Create the data directory (recursively, if the a parent dir doesn't exist). - // If it already exists, does nothing. - await mkdirAsync(server.config().get('path.data'), { recursive: true }); - } catch (err) { - server.log(['error', 'init'], err); - // Stop the server startup with a fatal error - throw err; - } - }, }); } diff --git a/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js index 2562657a71624..7de5fb581643a 100644 --- a/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js @@ -17,88 +17,7 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; - export function getUiSettingDefaults() { // wrapped in provider so that a new instance is given to each app/test - return { - 'visualization:tileMap:maxPrecision': { - name: i18n.translate('kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle', { - defaultMessage: 'Maximum tile map precision', - }), - value: 7, - description: i18n.translate('kbn.advancedSettings.visualization.tileMap.maxPrecisionText', { - defaultMessage: - 'The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, 12 is the max. {cellDimensionsLink}', - description: - 'Part of composite text: kbn.advancedSettings.visualization.tileMap.maxPrecisionText + ' + - 'kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', - values: { - cellDimensionsLink: - `` + - i18n.translate( - 'kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', - { - defaultMessage: 'Explanation of cell dimensions', - } - ) + - '', - }, - }), - category: ['visualization'], - }, - 'visualization:tileMap:WMSdefaults': { - name: i18n.translate('kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle', { - defaultMessage: 'Default WMS properties', - }), - value: JSON.stringify( - { - enabled: false, - url: undefined, - options: { - version: undefined, - layers: undefined, - format: 'image/png', - transparent: true, - attribution: undefined, - styles: undefined, - }, - }, - null, - 2 - ), - type: 'json', - description: i18n.translate('kbn.advancedSettings.visualization.tileMap.wmsDefaultsText', { - defaultMessage: - 'Default {propertiesLink} for the WMS map server support in the coordinate map', - description: - 'Part of composite text: kbn.advancedSettings.visualization.tileMap.wmsDefaultsText + ' + - 'kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', - values: { - propertiesLink: - '' + - i18n.translate( - 'kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', - { - defaultMessage: 'properties', - } - ) + - '', - }, - }), - category: ['visualization'], - }, - 'visualization:regionmap:showWarnings': { - name: i18n.translate('kbn.advancedSettings.visualization.showRegionMapWarningsTitle', { - defaultMessage: 'Show region map warning', - }), - value: true, - description: i18n.translate('kbn.advancedSettings.visualization.showRegionMapWarningsText', { - defaultMessage: - 'Whether the region map shows a warning when terms cannot be joined to a shape on the map.', - }), - category: ['visualization'], - }, - }; + return {}; } diff --git a/src/legacy/server/http/integration_tests/max_payload_size.test.js b/src/legacy/server/http/integration_tests/max_payload_size.test.js index 789a54f681ba6..2d0718dd35606 100644 --- a/src/legacy/server/http/integration_tests/max_payload_size.test.js +++ b/src/legacy/server/http/integration_tests/max_payload_size.test.js @@ -17,7 +17,7 @@ * under the License. */ -import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../../core/test_helpers/kbn_server'; let root; beforeAll(async () => { diff --git a/src/legacy/server/status/lib/metrics.test.js b/src/legacy/server/status/lib/metrics.test.js index 6a734941eb70c..cc9c2607a2b59 100644 --- a/src/legacy/server/status/lib/metrics.test.js +++ b/src/legacy/server/status/lib/metrics.test.js @@ -100,8 +100,8 @@ describe('Metrics', function () { Object.defineProperty(process, 'pid', { get: pidMock }); // const hapiEvent = { - requests: { '5603': { total: 22, disconnects: 0, statusCodes: { '200': 22 } } }, - responseTimes: { '5603': { avg: 1.8636363636363635, max: 4 } }, + requests: { 5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } } }, + responseTimes: { 5603: { avg: 1.8636363636363635, max: 4 } }, osload: [2.20751953125, 2.02294921875, 1.89794921875], osmem: { total: 17179869184, free: 102318080 }, osup: 1008991, @@ -150,9 +150,9 @@ describe('Metrics', function () { it('parses event with missing fields / NaN for responseTimes.avg', async () => { const hapiEvent = { requests: { - '5603': { total: 22, disconnects: 0, statusCodes: { '200': 22 } }, + 5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } }, }, - responseTimes: { '5603': { avg: NaN, max: 4 } }, + responseTimes: { 5603: { avg: NaN, max: 4 } }, host: 'blahblah.local', }; diff --git a/src/legacy/ui/public/kfetch/kfetch.test.mocks.ts b/src/legacy/ui/public/kfetch/kfetch.test.mocks.ts index ea066b3623f13..15cf070f19a39 100644 --- a/src/legacy/ui/public/kfetch/kfetch.test.mocks.ts +++ b/src/legacy/ui/public/kfetch/kfetch.test.mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { setup } from '../../../../test_utils/public/http_test_setup'; +import { setup } from '../../../../core/test_helpers/http_test_setup'; jest.doMock('ui/new_platform', () => ({ npSetup: { diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index b8d48b784dba7..bda755a746a2c 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -18,7 +18,7 @@ */ import sinon from 'sinon'; -import { getFieldFormatsRegistry } from '../../../../test_utils/public/stub_field_formats'; +import { getFieldFormatsRegistry } from '../../../../../src/plugins/data/public/test_utils'; import { METRIC_TYPE } from '@kbn/analytics'; import { setSetupServices, setStartServices } from './set_services'; import { diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 8cf9b9c656d8f..0e49fe17089f0 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["management"], - "requiredBundles": ["kibanaReact"] + "optionalPlugins": ["home"], + "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index abfdbaa85efdc..bbc3f3632bf64 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -114,6 +114,21 @@ export class AdvancedSettingsComponent extends Component< filteredSettings: this.mapSettings(Query.execute(query, this.settings)), }); }); + + // scrolls to setting provided in the URL hash + const { hash } = window.location; + if (hash !== '') { + setTimeout(() => { + const id = hash.replace('#', ''); + const element = document.getElementById(id); + const globalNavOffset = document.getElementById('headerGlobalNav')?.offsetHeight || 0; + + if (element) { + element.scrollIntoView(); + window.scrollBy(0, -globalNavOffset); // offsets scroll by height of the global nav + } + }, 0); + } } componentWillUnmount() { diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index da18eb70e5874..2aabacb061667 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -15,6 +15,7 @@ exports[`Field for array setting should render as read only if saving is disable } fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

{ return ( { - public setup(core: CoreSetup, { management }: AdvancedSettingsPluginSetup) { + public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ @@ -44,6 +45,21 @@ export class AdvancedSettingsPlugin }, }); + if (home) { + home.featureCatalogue.register({ + id: 'advanced_settings', + title, + description: i18n.translate('advancedSettings.featureCatalogueTitle', { + defaultMessage: + 'Customize your Kibana experience — change the date format, turn on dark mode, and more.', + }), + icon: 'gear', + path: '/app/management/kibana/settings', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + return { component: component.setup, }; diff --git a/src/plugins/advanced_settings/public/types.ts b/src/plugins/advanced_settings/public/types.ts index a233b3debab8d..cc59f52b1f30f 100644 --- a/src/plugins/advanced_settings/public/types.ts +++ b/src/plugins/advanced_settings/public/types.ts @@ -18,6 +18,8 @@ */ import { ComponentRegistry } from './component_registry'; +import { HomePublicPluginSetup } from '../../home/public'; + import { ManagementSetup } from '../../management/public'; export interface AdvancedSettingsSetup { @@ -29,6 +31,7 @@ export interface AdvancedSettingsStart { export interface AdvancedSettingsPluginSetup { management: ManagementSetup; + home?: HomePublicPluginSetup; } export { ComponentRegistry }; diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index 031aa00eb6613..ca43e4f258add 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["devTools", "home"], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils"] + "requiredPlugins": ["devTools"], + "optionalPlugins": ["usageCollection", "home"], + "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils", "home"] } diff --git a/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 0c3fcbafbe9f9..0f97416f053ee 100644 --- a/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -93,7 +93,7 @@ describe('Url autocomplete', () => { (function () { const endpoints = { - '1': { + 1: { patterns: ['a/b'], methods: ['GET'], }, @@ -125,11 +125,11 @@ describe('Url autocomplete', () => { (function () { const endpoints = { - '1': { + 1: { patterns: ['a/b', 'a/b/{p}'], methods: ['GET'], }, - '2': { + 2: { patterns: ['a/c'], methods: ['GET'], }, @@ -176,14 +176,14 @@ describe('Url autocomplete', () => { (function () { const endpoints = { - '1': { + 1: { patterns: ['a/{p}'], url_components: { p: ['a', 'b'], }, methods: ['GET'], }, - '2': { + 2: { patterns: ['a/c'], methods: ['GET'], }, @@ -230,18 +230,18 @@ describe('Url autocomplete', () => { (function () { const endpoints = { - '1': { + 1: { patterns: ['a/{p}'], url_components: { p: ['a', 'b'], }, methods: ['GET'], }, - '2': { + 2: { patterns: ['b/{p}'], methods: ['GET'], }, - '3': { + 3: { patterns: ['b/{l}/c'], methods: ['GET'], url_components: { @@ -315,7 +315,7 @@ describe('Url autocomplete', () => { (function () { const endpoints = { - '1': { + 1: { patterns: ['a/b/{p}/c/e'], methods: ['GET'], }, diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index 03b65a8bd145c..f3421aefaf38d 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -28,19 +28,21 @@ export class ConsoleUIPlugin implements Plugin { embeddable = embeddablePluginMock.mockRefOrValEmbeddable(embeddable, { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, - mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, + mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput, }); const dashboard = embeddable.getRoot() as IContainer; const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 26af13b4410fe..dc5887ee0e644 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -24,7 +24,11 @@ import _ from 'lodash'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { SavedObject } from '../../../../saved_objects/public'; -import { PanelNotFoundError, EmbeddableInput } from '../../../../embeddable/public'; +import { + PanelNotFoundError, + EmbeddableInput, + SavedObjectEmbeddableInput, +} from '../../../../embeddable/public'; import { placePanelBeside, IPanelPlacementBesideArgs, @@ -143,7 +147,7 @@ export class ClonePanelAction implements ActionByType }, { references: _.cloneDeep(savedObjectToClone.references) } ); - panelState.explicitInput.savedObjectId = clonedSavedObject.id; + (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = clonedSavedObject.id; } this.core.notifications.toasts.addSuccess({ title: i18n.translate('dashboard.panel.clonedToast', { diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 681a6a734a532..b4178fd40c768 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -30,7 +30,7 @@ import { coreMock } from '../../../../../core/public/mocks'; import { CoreStart } from 'kibana/public'; import { UnlinkFromLibraryAction } from '.'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { ViewMode } from '../../../../embeddable/public'; +import { ViewMode, SavedObjectEmbeddableInput } from '../../../../embeddable/public'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -142,7 +142,11 @@ test('Unlink unwraps all attributes from savedObject', async () => { attribute4: { nestedattribute: 'hello from the nest' }, }; - embeddable = embeddablePluginMock.mockRefOrValEmbeddable(embeddable, { + embeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + { attributes: unknown; id: string }, + SavedObjectEmbeddableInput + >(embeddable, { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, }); diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 76975fe0701db..0d20fdee07df5 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -475,7 +475,8 @@ export class DashboardAppController { : undefined; container.addOrUpdateEmbeddable( incomingEmbeddable.type, - explicitInput, + // This ugly solution is temporary - https://github.com/elastic/kibana/pull/70272 fixes this whole section + (explicitInput as unknown) as EmbeddableInput, embeddableId ); } diff --git a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts index 25ce203332422..926d5f405b384 100644 --- a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts +++ b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts @@ -23,6 +23,7 @@ import { } from './embeddable_saved_object_converters'; import { SavedDashboardPanel } from '../../types'; import { DashboardPanelState } from '../embeddable'; +import { EmbeddableInput } from '../../../../embeddable/public'; test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { @@ -93,7 +94,7 @@ test('convertPanelStateToSavedDashboardPanel', () => { something: 'hi!', id: '123', savedObjectId: 'savedObjectId', - }, + } as EmbeddableInput, type: 'search', }; @@ -127,7 +128,7 @@ test('convertPanelStateToSavedDashboardPanel will not add an undefined id when n explicitInput: { id: '123', something: 'hi!', - }, + } as EmbeddableInput, type: 'search', }; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index a788b06f91905..8b9b92faf9031 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -384,7 +384,7 @@ export class DashboardPlugin }), icon: 'dashboardApp', path: `/app/dashboards#${DashboardConstants.LANDING_PAGE_PATH}`, - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); } diff --git a/src/plugins/data/common/field_formats/errors.ts b/src/plugins/data/common/field_formats/errors.ts new file mode 100644 index 0000000000000..d72eef080923d --- /dev/null +++ b/src/plugins/data/common/field_formats/errors.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class FieldFormatNotFoundError extends Error { + public readonly formatId: string; + constructor(message: string, formatId: string) { + super(message); + this.name = 'FieldFormatNotFoundError'; + this.formatId = formatId; + } +} diff --git a/src/plugins/data/common/field_formats/field_formats_registry.ts b/src/plugins/data/common/field_formats/field_formats_registry.ts index 32f9f37b9ba53..4b46adf399363 100644 --- a/src/plugins/data/common/field_formats/field_formats_registry.ts +++ b/src/plugins/data/common/field_formats/field_formats_registry.ts @@ -34,6 +34,7 @@ import { FieldFormat } from './field_format'; import { SerializedFieldFormat } from '../../../expressions/common/types'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../kbn_field_types/types'; import { UI_SETTINGS } from '../constants'; +import { FieldFormatNotFoundError } from '../field_formats'; export class FieldFormatsRegistry { protected fieldFormats: Map = new Map(); @@ -161,7 +162,7 @@ export class FieldFormatsRegistry { const ConcreteFieldFormat = this.getType(formatId); if (!ConcreteFieldFormat) { - throw new Error(`Field Format '${formatId}' not found!`); + throw new FieldFormatNotFoundError(`Field Format '${formatId}' not found!`, formatId); } return new ConcreteFieldFormat(params, this.getConfig); diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts index d622af2f663a1..c1b1619abd247 100644 --- a/src/plugins/data/common/field_formats/index.ts +++ b/src/plugins/data/common/field_formats/index.ts @@ -55,3 +55,5 @@ export { IFieldFormat, FieldFormatsStartCommon, } from './types'; + +export * from './errors'; diff --git a/src/plugins/data/common/index_patterns/index_patterns/_fields_fetcher.ts b/src/plugins/data/common/index_patterns/index_patterns/_fields_fetcher.ts index baeb1587d57a9..4eba0576ff235 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/_fields_fetcher.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/_fields_fetcher.ts @@ -24,7 +24,7 @@ import { GetFieldsOptions, IIndexPatternsApiClient } from '../types'; export const createFieldsFetcher = ( indexPattern: IndexPattern, apiClient: IIndexPatternsApiClient, - metaFields: string + metaFields: string[] = [] ) => { const fieldFetcher = { fetch: (options: GetFieldsOptions) => { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index e4f297b29c372..09b79cae4aac2 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -32,7 +32,7 @@ import { fieldFormatsMock } from '../../field_formats/mocks'; class MockFieldFormatter {} -fieldFormatsMock.getType = jest.fn().mockImplementation(() => MockFieldFormatter); +fieldFormatsMock.getInstance = jest.fn().mockImplementation(() => new MockFieldFormatter()) as any; jest.mock('../../field_mapping', () => { const originalModule = jest.requireActual('../../field_mapping'); @@ -89,10 +89,6 @@ const patternCache = { clearAll: jest.fn(), }; -const config = { - get: jest.fn(), -}; - const apiClient = { _getUrl: jest.fn(), getFieldsForTimePattern: jest.fn(), @@ -102,14 +98,14 @@ const apiClient = { // helper function to create index patterns function create(id: string, payload?: any): Promise { const indexPattern = new IndexPattern(id, { - getConfig: (cfg: any) => config.get(cfg), savedObjectsClient: savedObjectsClient as any, apiClient, patternCache, fieldFormats: fieldFormatsMock, onNotification: () => {}, onError: () => {}, - uiSettingsValues: { shortDotsEnable: false, metaFields: [] }, + shortDotsEnable: false, + metaFields: [], }); setDocsourcePayload(id, payload); @@ -391,14 +387,14 @@ describe('IndexPattern', () => { }); // Create a normal index pattern const pattern = new IndexPattern('foo', { - getConfig: (cfg: any) => config.get(cfg), savedObjectsClient: savedObjectsClient as any, apiClient, patternCache, fieldFormats: fieldFormatsMock, onNotification: () => {}, onError: () => {}, - uiSettingsValues: { shortDotsEnable: false, metaFields: [] }, + shortDotsEnable: false, + metaFields: [], }); await pattern.init(); @@ -406,14 +402,14 @@ describe('IndexPattern', () => { // Create the same one - we're going to handle concurrency const samePattern = new IndexPattern('foo', { - getConfig: (cfg: any) => config.get(cfg), savedObjectsClient: savedObjectsClient as any, apiClient, patternCache, fieldFormats: fieldFormatsMock, onNotification: () => {}, onError: () => {}, - uiSettingsValues: { shortDotsEnable: false, metaFields: [] }, + shortDotsEnable: false, + metaFields: [], }); await samePattern.init(); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index 4e484dce7826f..e81ef1d6b2482 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -22,20 +22,19 @@ import { i18n } from '@kbn/i18n'; import { SavedObjectsClientCommon } from '../..'; import { DuplicateField, SavedObjectNotFound } from '../../../../kibana_utils/common'; -import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern } from '../../../common'; +import { + ES_FIELD_TYPES, + KBN_FIELD_TYPES, + IIndexPattern, + FieldFormatNotFoundError, +} from '../../../common'; import { findByTitle } from '../utils'; import { IndexPatternMissingIndices } from '../lib'; import { IndexPatternField, IIndexPatternFieldList, FieldList } from '../fields'; import { createFieldsFetcher } from './_fields_fetcher'; import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; -import { - OnNotification, - OnError, - UiSettingsCommon, - IIndexPatternsApiClient, - IndexPatternAttributes, -} from '../types'; +import { OnNotification, OnError, IIndexPatternsApiClient, IndexPatternAttributes } from '../types'; import { FieldFormatsStartCommon, FieldFormat } from '../../field_formats'; import { PatternCache } from './_pattern_cache'; import { expandShorthand, FieldMappingSpec, MappingObject } from '../../field_mapping'; @@ -44,21 +43,16 @@ import { SerializedFieldFormat } from '../../../../expressions/common'; const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; const savedObjectType = 'index-pattern'; -interface IUiSettingsValues { - [key: string]: any; - shortDotsEnable: any; - metaFields: any; -} interface IndexPatternDeps { - getConfig: UiSettingsCommon['get']; savedObjectsClient: SavedObjectsClientCommon; apiClient: IIndexPatternsApiClient; patternCache: PatternCache; fieldFormats: FieldFormatsStartCommon; onNotification: OnNotification; onError: OnError; - uiSettingsValues: IUiSettingsValues; + shortDotsEnable: boolean; + metaFields: string[]; } export class IndexPattern implements IIndexPattern { @@ -78,7 +72,6 @@ export class IndexPattern implements IIndexPattern { private version: string | undefined; private savedObjectsClient: SavedObjectsClientCommon; private patternCache: PatternCache; - private getConfig: UiSettingsCommon['get']; private sourceFilters?: SourceFilter[]; private originalBody: { [key: string]: any } = {}; public fieldsFetcher: any; // probably want to factor out any direct usage and change to private @@ -87,7 +80,6 @@ export class IndexPattern implements IIndexPattern { private onNotification: OnNotification; private onError: OnError; private apiClient: IIndexPatternsApiClient; - private uiSettingsValues: IUiSettingsValues; private mapping: MappingObject = expandShorthand({ title: ES_FIELD_TYPES.TEXT, @@ -114,35 +106,31 @@ export class IndexPattern implements IIndexPattern { constructor( id: string | undefined, { - getConfig, savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, - uiSettingsValues, + shortDotsEnable = false, + metaFields = [], }: IndexPatternDeps ) { this.id = id; this.savedObjectsClient = savedObjectsClient; this.patternCache = patternCache; - // instead of storing config we rather store the getter only as np uiSettingsClient has circular references - // which cause problems when being consumed from angular - this.getConfig = getConfig; this.fieldFormats = fieldFormats; this.onNotification = onNotification; this.onError = onError; - this.uiSettingsValues = uiSettingsValues; - this.shortDotsEnable = uiSettingsValues.shortDotsEnable; - this.metaFields = uiSettingsValues.metaFields; + this.shortDotsEnable = shortDotsEnable; + this.metaFields = metaFields; this.fields = new FieldList(this, [], this.shortDotsEnable, this.onUnknownType); this.apiClient = apiClient; - this.fieldsFetcher = createFieldsFetcher(this, apiClient, uiSettingsValues.metaFields); - this.flattenHit = flattenHitWrapper(this, uiSettingsValues.metaFields); + this.fieldsFetcher = createFieldsFetcher(this, apiClient, metaFields); + this.flattenHit = flattenHitWrapper(this, metaFields); this.formatHit = formatHitProvider( this, fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING) @@ -157,15 +145,15 @@ export class IndexPattern implements IIndexPattern { } private deserializeFieldFormatMap(mapping: any) { - const FieldFormatter = this.fieldFormats.getType(mapping.id); - - return ( - FieldFormatter && - new FieldFormatter( - mapping.params, - (key: string) => this.uiSettingsValues[key]?.userValue || this.uiSettingsValues[key]?.value - ) - ); + try { + return this.fieldFormats.getInstance(mapping.id, mapping.params); + } catch (err) { + if (err instanceof FieldFormatNotFoundError) { + return undefined; + } else { + throw err; + } + } } private isFieldRefreshRequired(specs?: FieldSpec[]): boolean { @@ -513,17 +501,14 @@ export class IndexPattern implements IIndexPattern { saveAttempts++ < MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS ) { const samePattern = new IndexPattern(this.id, { - getConfig: this.getConfig, savedObjectsClient: this.savedObjectsClient, apiClient: this.apiClient, patternCache: this.patternCache, fieldFormats: this.fieldFormats, onNotification: this.onNotification, onError: this.onError, - uiSettingsValues: { - shortDotsEnable: this.shortDotsEnable, - metaFields: this.metaFields, - }, + shortDotsEnable: this.shortDotsEnable, + metaFields: this.metaFields, }); return samePattern.init().then(() => { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 8874ce5f04b7c..0ad9ae8f2014f 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -185,17 +185,16 @@ export class IndexPatternsService { async specToIndexPattern(spec: IndexPatternSpec) { const shortDotsEnable = await this.config.get(UI_SETTINGS.SHORT_DOTS_ENABLE); const metaFields = await this.config.get(UI_SETTINGS.META_FIELDS); - const uiSettingsValues = await this.config.getAll(); const indexPattern = new IndexPattern(spec.id, { - getConfig: (cfg: any) => this.config.get(cfg), savedObjectsClient: this.savedObjectsClient, apiClient: this.apiClient, patternCache: indexPatternCache, fieldFormats: this.fieldFormats, onNotification: this.onNotification, onError: this.onError, - uiSettingsValues: { ...uiSettingsValues, shortDotsEnable, metaFields }, + shortDotsEnable, + metaFields, }); indexPattern.initFromSpec(spec); @@ -205,17 +204,16 @@ export class IndexPatternsService { async make(id?: string): Promise { const shortDotsEnable = await this.config.get(UI_SETTINGS.SHORT_DOTS_ENABLE); const metaFields = await this.config.get(UI_SETTINGS.META_FIELDS); - const uiSettingsValues = await this.config.getAll(); const indexPattern = new IndexPattern(id, { - getConfig: (cfg: any) => this.config.get(cfg), savedObjectsClient: this.savedObjectsClient, apiClient: this.apiClient, patternCache: indexPatternCache, fieldFormats: this.fieldFormats, onNotification: this.onNotification, onError: this.onError, - uiSettingsValues: { ...uiSettingsValues, shortDotsEnable, metaFields }, + shortDotsEnable, + metaFields, }); return indexPattern.init(); diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index a771113acd231..7a230c20f6cd0 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -96,7 +96,7 @@ export interface GetFieldsOptions { type?: string; params?: any; lookBack?: boolean; - metaFields?: string; + metaFields?: string[]; } export interface IIndexPatternsApiClient { diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index 557ab64079d16..d8184551b7f3d 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -23,9 +23,6 @@ export * from './expressions'; export * from './tabify'; export * from './types'; -import { ES_SEARCH_STRATEGY } from './es_search'; -export const DEFAULT_SEARCH_STRATEGY = ES_SEARCH_STRATEGY; - export { IEsSearchRequest, IEsSearchResponse, diff --git a/src/test_utils/public/stub_field_formats.ts b/src/plugins/data/public/field_formats/field_formats_registry.stub.ts similarity index 81% rename from src/test_utils/public/stub_field_formats.ts rename to src/plugins/data/public/field_formats/field_formats_registry.stub.ts index 589e93fd600c2..e8741ca41036b 100644 --- a/src/test_utils/public/stub_field_formats.ts +++ b/src/plugins/data/public/field_formats/field_formats_registry.stub.ts @@ -16,10 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { CoreSetup } from 'kibana/public'; -import { DataPublicPluginStart, fieldFormats } from '../../plugins/data/public'; -import { deserializeFieldFormat } from '../../plugins/data/public/field_formats/utils/deserialize'; -import { baseFormattersPublic } from '../../plugins/data/public'; + +import { CoreSetup } from 'src/core/public'; +import { deserializeFieldFormat } from './utils/deserialize'; +import { baseFormattersPublic } from './constants'; +import { DataPublicPluginStart, fieldFormats } from '..'; export const getFieldFormatsRegistry = (core: CoreSetup) => { const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); diff --git a/src/plugins/data/public/index_patterns/index_patterns/index_patterns_api_client.test.mock.ts b/src/plugins/data/public/index_patterns/index_patterns/index_patterns_api_client.test.mock.ts index 51f4fc7ce94b9..5822d269032d3 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/index_patterns_api_client.test.mock.ts +++ b/src/plugins/data/public/index_patterns/index_patterns/index_patterns_api_client.test.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { setup } from 'test_utils/http_test_setup'; +import { setup } from '../../../../../core/test_helpers/http_test_setup'; export const { http } = setup((injectedMetadata) => { injectedMetadata.getBasePath.mockReturnValue('/hola/daro/'); diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index ee0b0714febc0..3bc19a578a417 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -177,7 +177,7 @@ export class DataPublicPlugin onNotification: (toastInputFields) => { notifications.toasts.add(toastInputFields); }, - onError: notifications.toasts.addError, + onError: notifications.toasts.addError.bind(notifications.toasts), onRedirectNoIndexPattern: onRedirectNoIndexPattern( application.capabilities, application.navigateToApp, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f8a108a5a4c58..261f16229460a 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -944,7 +944,7 @@ export type IMetricAggType = MetricAggType; // @public (undocumented) export class IndexPattern implements IIndexPattern { // Warning: (ae-forgotten-export) The symbol "IndexPatternDeps" needs to be exported by the entry point index.d.ts - constructor(id: string | undefined, { getConfig, savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, uiSettingsValues, }: IndexPatternDeps); + constructor(id: string | undefined, { savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, shortDotsEnable, metaFields, }: IndexPatternDeps); // (undocumented) [key: string]: any; // (undocumented) diff --git a/src/plugins/data/public/test_utils.ts b/src/plugins/data/public/test_utils.ts new file mode 100644 index 0000000000000..f04b20e96fa1d --- /dev/null +++ b/src/plugins/data/public/test_utils.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { getFieldFormatsRegistry } from './field_formats/field_formats_registry.stub'; diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index 5163bfcb17d40..588885391262e 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -21,7 +21,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from ' import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ConfigSchema } from '../config'; import { IndexPatternsService, IndexPatternsServiceStart } from './index_patterns'; -import { ISearchSetup, ISearchStart } from './search'; +import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { SearchService } from './search/search_service'; import { QueryService } from './query/query_service'; import { ScriptsService } from './scripts'; @@ -31,9 +31,17 @@ import { AutocompleteService } from './autocomplete'; import { FieldFormatsService, FieldFormatsSetup, FieldFormatsStart } from './field_formats'; import { getUiSettings } from './ui_settings'; +export interface DataEnhancements { + search: SearchEnhancements; +} + export interface DataPluginSetup { search: ISearchSetup; fieldFormats: FieldFormatsSetup; + /** + * @internal + */ + __enhance: (enhancements: DataEnhancements) => void; } export interface DataPluginStart { @@ -87,11 +95,16 @@ export class DataServerPlugin core.uiSettings.register(getUiSettings()); + const searchSetup = this.searchService.setup(core, { + registerFunction: expressions.registerFunction, + usageCollection, + }); + return { - search: this.searchService.setup(core, { - registerFunction: expressions.registerFunction, - usageCollection, - }), + __enhance: (enhancements: DataEnhancements) => { + searchSetup.__enhance(enhancements.search); + }, + search: searchSetup, fieldFormats: this.fieldFormats.setup(), }; } diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 4a3990621ca39..02c21c3254645 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -17,7 +17,13 @@ * under the License. */ -export { ISearchStrategy, ISearchOptions, ISearchSetup, ISearchStart } from './types'; +export { + ISearchStrategy, + ISearchOptions, + ISearchSetup, + ISearchStart, + SearchEnhancements, +} from './types'; export { getDefaultSearchParams, getTotalLoaded } from './es_search'; diff --git a/src/plugins/data/server/search/mocks.ts b/src/plugins/data/server/search/mocks.ts index 578a170f468bf..0c74ecb4b2c9d 100644 --- a/src/plugins/data/server/search/mocks.ts +++ b/src/plugins/data/server/search/mocks.ts @@ -24,6 +24,7 @@ export function createSearchSetupMock(): jest.Mocked { return { aggs: searchAggsSetupMock(), registerSearchStrategy: jest.fn(), + __enhance: jest.fn(), }; } diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index cc23c455bed26..edc94961c79d8 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -25,7 +25,7 @@ import { PluginInitializerContext, RequestHandlerContext, } from '../../../../core/server'; -import { ISearchSetup, ISearchStart, ISearchStrategy } from './types'; +import { ISearchSetup, ISearchStart, ISearchStrategy, SearchEnhancements } from './types'; import { AggsService, AggsSetupDependencies } from './aggs'; @@ -57,6 +57,7 @@ export interface SearchServiceStartDependencies { export class SearchService implements Plugin { private readonly aggsService = new AggsService(); + private defaultSearchStrategyName: string = ES_SEARCH_STRATEGY; private searchStrategies: StrategyMap = {}; constructor( @@ -87,6 +88,11 @@ export class SearchService implements Plugin { registerSearchRoute(core); return { + __enhance: (enhancements: SearchEnhancements) => { + if (this.searchStrategies.hasOwnProperty(enhancements.defaultStrategy)) { + this.defaultSearchStrategyName = enhancements.defaultStrategy; + } + }, aggs: this.aggsService.setup({ registerFunction }), registerSearchStrategy: this.registerSearchStrategy, usage, @@ -98,11 +104,9 @@ export class SearchService implements Plugin { searchRequest: IEsSearchRequest, options: Record ) { - return this.getSearchStrategy(options.strategy || ES_SEARCH_STRATEGY).search( - context, - searchRequest, - { signal: options.signal } - ); + return this.getSearchStrategy( + options.strategy || this.defaultSearchStrategyName + ).search(context, searchRequest, { signal: options.signal }); } public start( diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 56f803512aa19..5ce1bb3e6b9f8 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -23,6 +23,10 @@ import { AggsSetup, AggsStart } from './aggs'; import { SearchUsage } from './collectors/usage'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; +export interface SearchEnhancements { + defaultStrategy: string; +} + export interface ISearchOptions { /** * An `AbortSignal` that allows the caller of `search` to abort a search request. @@ -49,6 +53,11 @@ export interface ISearchSetup { * Used internally for telemetry */ usage?: SearchUsage; + + /** + * @internal + */ + __enhance: (enhancements: SearchEnhancements) => void; } export interface ISearchStart< diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index f870030ae9562..9f114f2132009 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -685,6 +685,10 @@ export interface ISearchOptions { // // @public (undocumented) export interface ISearchSetup { + // Warning: (ae-forgotten-export) The symbol "SearchEnhancements" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + __enhance: (enhancements: SearchEnhancements) => void; // Warning: (ae-forgotten-export) The symbol "AggsSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -855,6 +859,7 @@ export class Plugin implements Plugin_2); // (undocumented) setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { + __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; @@ -883,6 +888,8 @@ export function plugin(initializerContext: PluginInitializerContext void; // Warning: (ae-forgotten-export) The symbol "FieldFormatsSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1090,6 +1097,7 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:240:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:244:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index b621017677c58..1de62fe5a0348 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -78,7 +78,8 @@ interface SearchEmbeddableConfig { filterManager: FilterManager; } -export class SearchEmbeddable extends Embeddable +export class SearchEmbeddable + extends Embeddable implements ISearchEmbeddable { private readonly savedSearch: SavedSearch; private $rootScope: ng.IRootScopeService; diff --git a/src/plugins/discover/public/register_feature.ts b/src/plugins/discover/public/register_feature.ts index 80e14702c6f22..5443bb261ab10 100644 --- a/src/plugins/discover/public/register_feature.ts +++ b/src/plugins/discover/public/register_feature.ts @@ -30,7 +30,7 @@ export function registerFeature(home: HomePublicPluginSetup) { }), icon: 'discoverApp', path: '/app/discover#/', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 8cf3d1a15883b..38975cc220bc2 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -35,10 +35,11 @@ import { isSavedObjectEmbeddableInput } from '../embeddables/saved_object_embedd const getKeys = (o: T): Array => Object.keys(o) as Array; export abstract class Container< - TChildInput extends Partial = {}, - TContainerInput extends ContainerInput = ContainerInput, - TContainerOutput extends ContainerOutput = ContainerOutput -> extends Embeddable + TChildInput extends Partial = {}, + TContainerInput extends ContainerInput = ContainerInput, + TContainerOutput extends ContainerOutput = ContainerOutput + > + extends Embeddable implements IContainer { public readonly isContainer: boolean = true; protected readonly children: { diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 31a7cd4f2e559..db219fa8b7314 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -25,7 +25,7 @@ import { IEmbeddable, } from '../embeddables'; -export interface PanelState { +export interface PanelState { // The type of embeddable in this panel. Will be used to find the factory in which to // load the embeddable. type: string; @@ -43,7 +43,7 @@ export interface ContainerOutput extends EmbeddableOutput { export interface ContainerInput extends EmbeddableInput { hidePanelTitles?: boolean; panels: { - [key: string]: PanelState; + [key: string]: PanelState; }; } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9c4a1b5602c49..e8aecdba0abc4 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -70,8 +70,6 @@ export interface EmbeddableInput { * Visualization filters used to narrow down results. */ filters?: Filter[]; - - [key: string]: unknown; } export interface EmbeddableOutput { diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index 621ffe4c9dad6..69c21fdf3f072 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -19,7 +19,13 @@ import * as Rx from 'rxjs'; import { skip } from 'rxjs/operators'; -import { isErrorEmbeddable, EmbeddableOutput, ContainerInput, ViewMode } from '../lib'; +import { + isErrorEmbeddable, + EmbeddableOutput, + ContainerInput, + ViewMode, + SavedObjectEmbeddableInput, +} from '../lib'; import { FilterableEmbeddableInput, FilterableEmbeddable, @@ -648,7 +654,7 @@ test('container stores ErrorEmbeddables when a saved object cannot be found', as panels: { '123': { type: 'vis', - explicitInput: { id: '123', savedObjectId: '456' }, + explicitInput: { id: '123', savedObjectId: '456' } as SavedObjectEmbeddableInput, }, }, viewMode: ViewMode.EDIT, @@ -669,7 +675,7 @@ test('ErrorEmbeddables get updated when parent does', async (done) => { panels: { '123': { type: 'vis', - explicitInput: { id: '123', savedObjectId: '456' }, + explicitInput: { id: '123', savedObjectId: '456' } as SavedObjectEmbeddableInput, }, }, viewMode: ViewMode.EDIT, diff --git a/src/plugins/expressions/common/expression_functions/specs/var.ts b/src/plugins/expressions/common/expression_functions/specs/var.ts index 4bc185a4cadfd..7d95c9816b99c 100644 --- a/src/plugins/expressions/common/expression_functions/specs/var.ts +++ b/src/plugins/expressions/common/expression_functions/specs/var.ts @@ -34,7 +34,7 @@ export type ExpressionFunctionVar = ExpressionFunctionDefinition< export const variable: ExpressionFunctionVar = { name: 'var', help: i18n.translate('expressions.functions.var.help', { - defaultMessage: 'Updates kibana global context', + defaultMessage: 'Updates the Kibana global context.', }), args: { name: { @@ -42,7 +42,7 @@ export const variable: ExpressionFunctionVar = { aliases: ['_'], required: true, help: i18n.translate('expressions.functions.var.name.help', { - defaultMessage: 'Specify name of the variable', + defaultMessage: 'Specify the name of the variable.', }), }, }, diff --git a/src/plugins/expressions/common/expression_functions/specs/var_set.ts b/src/plugins/expressions/common/expression_functions/specs/var_set.ts index 8f15bc8b90042..c45ca593f020c 100644 --- a/src/plugins/expressions/common/expression_functions/specs/var_set.ts +++ b/src/plugins/expressions/common/expression_functions/specs/var_set.ts @@ -35,7 +35,7 @@ export type ExpressionFunctionVarSet = ExpressionFunctionDefinition< export const variableSet: ExpressionFunctionVarSet = { name: 'var_set', help: i18n.translate('expressions.functions.varset.help', { - defaultMessage: 'Updates kibana global context', + defaultMessage: 'Updates the Kibana global context.', }), args: { name: { @@ -43,14 +43,14 @@ export const variableSet: ExpressionFunctionVarSet = { aliases: ['_'], required: true, help: i18n.translate('expressions.functions.varset.name.help', { - defaultMessage: 'Specify name of the variable', + defaultMessage: 'Specify the name of the variable.', }), }, value: { aliases: ['val'], help: i18n.translate('expressions.functions.varset.val.help', { defaultMessage: - 'Specify value for the variable. If not provided input context will be used', + 'Specify the value for the variable. When unspecified, the input context is used.', }), }, }, diff --git a/src/plugins/home/common/constants.ts b/src/plugins/home/common/constants.ts new file mode 100644 index 0000000000000..a9457a9d3307c --- /dev/null +++ b/src/plugins/home/common/constants.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const PLUGIN_ID = 'home'; +export const HOME_APP_BASE_PATH = `/app/${PLUGIN_ID}`; diff --git a/src/plugins/home/public/application/application.tsx b/src/plugins/home/public/application/application.tsx index 627bd10d7c2c8..5d71bf8651d88 100644 --- a/src/plugins/home/public/application/application.tsx +++ b/src/plugins/home/public/application/application.tsx @@ -35,15 +35,21 @@ export const renderApp = async ( ) => { const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const { featureCatalogue, chrome } = getServices(); + const navLinks = chrome.navLinks.getAll(); // all the directories could be get in "start" phase of plugin after all of the legacy plugins will be moved to a NP const directories = featureCatalogue.get(); + // Filters solutions by available nav links + const solutions = featureCatalogue + .getSolutions() + .filter(({ id }) => navLinks.find(({ category, hidden }) => !hidden && category?.id === id)); + chrome.setBreadcrumbs([{ text: homeTitle }]); render( - + , element ); diff --git a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap deleted file mode 100644 index 9178d0e08f3e0..0000000000000 --- a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap +++ /dev/null @@ -1,1163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`apmUiEnabled 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - APM automatically collects in-depth performance metrics and errors from inside your applications. -
- } - footer={ - - - - } - textAlign="left" - title="APM" - titleSize="xs" - /> - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - - - - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
- - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`isNewKibanaInstance 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - -
-`; - -exports[`mlEnabled 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - APM automatically collects in-depth performance metrics and errors from inside your applications. - - } - footer={ - - - - } - textAlign="left" - title="APM" - titleSize="xs" - /> - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-`; - -exports[`render 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - -
-`; diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index 4fa04bb64b177..1b10756c2975c 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -1,1066 +1,1615 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`home directories should not render directory entry when showOnHomePage is false 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+

- - - - - - + - -

- -

-
- - -
+ + + Add data + + + +
+ +

+ +
+ 0 + + + + + + -
+ `; -exports[`home directories should render ADMIN directory entry in "Manage" panel 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Manage + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home directories should render ADMIN directory entry in "Manage your data" panel 1`] = ` +
+
+
+ + -

+

-

+
- - + + - + + Add data + - - +
+ + +
+
+
+ 0 + + + + + + -
+
`; -exports[`home directories should render DATA directory entry in "Explore Data" panel 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - + + - + + Add data + - - +
+ + +
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home directories should render solutions in the "solution section" 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ + + + + + + -
+
`; -exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home header should show "Dev tools" link if console is available 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Dev tools + + + + +
+
+
+
+ 0 + + + + + + -
+
`; -exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when there are index patterns 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Manage + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ 0 + + + + + + -
+
`; -exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ 0 + + + + + + -
+
`; exports[`home should render home component 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the normal home page if loading fails 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the normal home page if welcome screen is disabled locally 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the welcome screen if enabled, and there are no index patterns defined 1`] = ` @@ -1071,116 +1620,107 @@ exports[`home welcome should show the welcome screen if enabled, and there are n `; exports[`home welcome stores skip welcome setting if skipped 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; diff --git a/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap index d757d6a8b7305..190985f70659d 100644 --- a/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap @@ -4,7 +4,7 @@ exports[`props iconType 1`] = ` `; @@ -24,7 +25,7 @@ exports[`props iconUrl 1`] = ` `; @@ -44,11 +46,12 @@ exports[`props isBeta 1`] = ` `; @@ -57,11 +60,12 @@ exports[`render 1`] = ` `; diff --git a/src/plugins/home/public/application/components/_add_data.scss b/src/plugins/home/public/application/components/_add_data.scss index 836b34227a37c..e588edfe35240 100644 --- a/src/plugins/home/public/application/components/_add_data.scss +++ b/src/plugins/home/public/application/components/_add_data.scss @@ -1,63 +1,22 @@ -.homAddData__card { - border: none; - box-shadow: none; -} - -.homAddData__cardDivider { - position: relative; - - &:after { - position: absolute; - content: ''; - width: 1px; - right: -$euiSizeS; - top: 0; - bottom: 0; - background: $euiBorderColor; - } -} - -.homAddData__icon { - width: $euiSizeXL * 2; - height: $euiSizeXL * 2; -} - -.homAddData__footerItem--highlight { - background-color: tintOrShade($euiColorPrimary, 90%, 70%); - padding: $euiSize; -} - -.homAddData__footerItem { - text-align: center; -} - -.homAddData__logo { - margin-left: $euiSize; -} - -@include euiBreakpoint('xs', 's') { - .homeAddData__flexGroup { - flex-wrap: wrap; - } -} - -@include euiBreakpoint('xs', 's', 'm') { - .homAddDat__flexTablet { - flex-direction: column; - } - - .homAddData__cardDivider:after { - display: none; - } - - .homAddData__cardDivider { - flex-grow: 0 !important; - flex-basis: 100% !important; - } -} - -@include euiBreakpoint('l', 'xl') { - .homeAddData__flexGroup { - flex-wrap: nowrap; - } +/* + * 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. + */ + +.homDataAdd__content .euiIcon__fillSecondary { + fill: $euiColorDarkestShade; } diff --git a/src/plugins/home/public/application/components/_home.scss b/src/plugins/home/public/application/components/_home.scss index 4101f6519829b..d9b7602971e8d 100644 --- a/src/plugins/home/public/application/components/_home.scss +++ b/src/plugins/home/public/application/components/_home.scss @@ -1,5 +1,73 @@ -@include euiBreakpoint('xs', 's', 'm') { - .homHome__synopsisItem { - flex-basis: 100% !important; +/* + * 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. + */ + +// Local page variables +$homePageWidth: 1200px; + +.homWrapper { + background-color: $euiColorEmptyShade; + display: flex; + flex-direction: column; + min-height: calc(100vh - #{$euiHeaderHeightCompensation}); +} + +.homHeader { + background-color: $euiPageBackgroundColor; + border-bottom: $euiBorderWidthThin solid $euiColorLightShade; +} + +.homHeader__inner { + margin: 0 auto; + max-width: $homePageWidth; + padding: $euiSizeXL $euiSize; + + .homHeader--hasSolutions & { + padding-bottom: $euiSizeXL + $euiSizeL; + } +} + +#homHeader__title { + @include euiBreakpoint('xs', 's') { + text-align: center; + } +} + +.homHeader__actionItem { + @include euiBreakpoint('xs', 's') { + margin-bottom: 0 !important; + margin-top: 0 !important; + } +} + +.homContent { + margin: 0 auto; + max-width: $homePageWidth; + padding: $euiSizeXL $euiSize; + width: 100%; +} + +.homData--expanded { + flex-direction: column; + + &, + & > * { + margin-bottom: 0 !important; + margin-top: 0 !important; } } diff --git a/src/plugins/home/public/application/components/_index.scss b/src/plugins/home/public/application/components/_index.scss index 870099ffb350e..a0547a4588561 100644 --- a/src/plugins/home/public/application/components/_index.scss +++ b/src/plugins/home/public/application/components/_index.scss @@ -5,9 +5,11 @@ // homChart__legend--small // homChart__legend-isLoading -@import 'add_data'; @import 'home'; +@import 'add_data'; +@import 'manage_data'; @import 'sample_data_set_cards'; +@import 'solutions_section'; @import 'synopsis'; @import 'welcome'; diff --git a/src/plugins/home/public/application/components/_manage_data.scss b/src/plugins/home/public/application/components/_manage_data.scss new file mode 100644 index 0000000000000..389d8c8b3bf0f --- /dev/null +++ b/src/plugins/home/public/application/components/_manage_data.scss @@ -0,0 +1,22 @@ +/* + * 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. + */ + +.homDataManage__content .euiIcon__fillSecondary { + fill: $euiColorDarkestShade; +} diff --git a/src/plugins/home/public/application/components/_solutions_section.scss b/src/plugins/home/public/application/components/_solutions_section.scss new file mode 100644 index 0000000000000..be693707e06b4 --- /dev/null +++ b/src/plugins/home/public/application/components/_solutions_section.scss @@ -0,0 +1,122 @@ +/* + * 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. + */ + +.homSolutions { + margin-top: -($euiSizeXL + $euiSizeL + $euiSizeM); +} + +.homSolutions__content { + min-height: $euiSize * 16; + + @include euiBreakpoint('xs', 's') { + flex-direction: column; + } +} + +.homSolutions__group { + max-width: 50%; + + @include euiBreakpoint('xs', 's') { + max-width: none; + } +} + +.homSolutionPanel { + border-radius: $euiBorderRadius; + color: inherit; + flex: 1; + transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + + &:hover, + &:focus { + @include euiSlightShadowHover; + transform: translateY(-2px); + + .euiTitle { + text-decoration: underline; + } + } + + &, + .euiPanel { + display: flex; + flex-direction: column; + } + + .euiPanel { + overflow: hidden; + } +} + +.homSolutionPanel__header { + color: $euiColorEmptyShade; + padding: $euiSize; +} + +.homSolutionPanel__icon { + background-color: $euiColorEmptyShade !important; + box-shadow: none !important; + margin: 0 auto $euiSizeS; + padding: $euiSizeS; +} + +.homSolutionPanel__subtitle { + margin-top: $euiSizeXS; +} + +.homSolutionPanel__content { + flex-direction: column; + justify-content: center; + padding: $euiSize; + + @include euiBreakpoint('xs', 's') { + text-align: center; + } +} + +.homSolutionPanel__header { + background-color: $euiColorPrimary; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQYAAAFjCAYAAADfDKXVAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAfiSURBVHgB7d1JblRZGobh45SrhJiWGOQ6EENLSKyABTBmOcwRq2EnTKtUdInBuEln/OkMdB3HTTjiNqd5HinA3YBUSq++24UPUkq/J+jE+/fv09HRURrD27dv0+vXr1OLfksAG4QByAgDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMAAZYQAywgBkhAHICAOQEQZg05/CAGy6FAYgIwzApnNhADLCAGy6EAZgkzAAGVclgIyTj0DGYgAyZ8IADP2ZLAZgw1n8IQzA0Hn8IQzA0EX8IQzAkEMJICMMwDURhcv44DDBlp4+fZrevHmTxvLq1av04cOHRDHO1h8IA1t7/PhxOjo6SjTrdP2BQwlg7ddiEAYgxGXK8/UnwgCEs+EnwgCEk+EnwgCE0+EnwgDEYcTF8AvCAJxufkEYgO+bXxAG6Nu1y5RrwgB9O7npi8IAfTu+6YvCAP3KrkasCQP06/i2bwgD9Ov0tm8IA/QpLlFe3PZNYYA+/bjrm8IA/YmTjqd3/YAwQH+O7/sBYYC+xHmFH/f9kDBAX/7Y5oeEAfqx1VoIwgD9+L7tDwoD9GHrtRCEAfoQ5xYutv1hYYD2PWgtBGGA9m11JWJIGKBtD14LQRigbf9POxAGaNedT1DeRRigTRGEb2lHwgBtetDlyU3CAO35mXY44TgkDNCWy9XrS9qTMEBb9jqEWBMGaEdchbj3TVi2IQzQhr2uQmwSBmjD5zTCIcSaMED94rzCaRqRMEDd4tLkaIcQa8IA9YpDh70vTd5EGKBOcb9CPCA12nmFIWGAOo16snGTMEB94mTjSZqQMEBdIgqjn2zcJAxQj7ircfIoBGGAOsTtzl/TTIQByhe/nXqSy5K3EQYoW0Rhp/dt3MdhAkoVdzV+Slf3LMxKGKBMcU5h1sOHIYcSUJ5FoxAsBijLLPcp3EcYoBxxOXKUd2DalzDA8uLk4sc08nsq7EMYYFnxINRkT0nuShhgOfEgVDwlOfvlyPsIAyyjmPMJNxEGmFccMsRKKOZ8wk2EAeZT7KHDJmGA6UUI4v6EYg8dNgkDTCued4i7GIu66nAfYYBpVLcShoQBxlflShgSBhhP1SthSBhgHNWvhCFhgP00sxKGhAF29OTJkyKfcxiDN2qBHb18+TIOH5qLQhAGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMNCTy0ePHhX/RqwlEAZ6EQ88/e/Zs2dniXt57JrWNfl+CVMTBloW6+BTavTR6CkJA62KlfAtsRNhoDWxEr788zc7EgZaYiWMRBhogZUwMmGgdlbCBISBWlkJExIGamQlTEwYqImVMBNhoBZx5+LXxCyEgdLFXYufV6/TxGyEgZLFSojzCZ6InJkwUCIrYWHCQGmshAIIA6WwEgoiDJTASiiMMLCYw8NDK6FQ3tqNxbx79+5jEoUiCQOLef78uUOHQgkDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGNjWxYsXL/5IdOFg9fo9wd3+fr+Ey8vLf63+/k8az38PDg5m/RX1q/+G+Pf/O43j++rf/yU1yPsxcBfvl9ApYeA23lWpY8LAJisBYeAaK4G/CQMhQhAr4SRBEgauYhBRsBL4RRj6ZSVwK2Hok5XAnYShL1YCWxGGflgJbE0Y2mcl8GDC0DYrgZ0IQ5usBPYiDO35uXrFE3+zPrVIW4ShHbES4nbm4wR7EoY2WAmMShjqZiUwCWGol5XAZIShPlYCkxOGulgJzEIY6mAlMCthKJ+VwOyEoVxWAosRhjJZCSxKGMpiJVAEYSiHlUAxhGF5VgLFEYZlWQkUSRiWYSVQNGGYn5VA8YRhPlYC1RCGeVgJVEUYpmUlUCVhmI6VQLWEYXxWAtUThnFZCTRBGMZhJdAUYdiflUBzhGF3VgLNEobdnK1en5KVQKOE4eFiJXxL0DBh2F6shC///A1NE4btWAl0RRjuZiXQJWG4nZVAt4QhZyXQPWG4zkqAJAxrVgIMCMPVnYtfE/BLz2GIuxY/r16nCbim1zDESojzCZeJ3sTh4lj/389To3oLg5XQuYODA4eNW+gpDFYCbKmHMFgJ8ECth8FKgB20GgYrAfbQYhisBNhTS2GwEmAkrYTBSoAR1R4GKwEmUHMYrASYSI1hsBJgYrWFwUqAGdQSBisBZlRDGKwEmFnJYbASYCGlhsFKgAWVFgYrAQpQUhisBChECWGwEqAwS4fBSoACLRUGKwEKtkQYrAQo3JxhsBKgEnOFwUqAikwdhghBrISTBFRjyjBEDCIKVgJUZoowOJcAlRs7DM4lQAPGCsPP1etbshKgCfuGIQ4bYiH8SEAzdg1DHCrEQjhODhugOQ8NgyBAB7YNgyBAR+4LgyBAh24Lg6sM0LFhGGIRfE9XdywKAnQswrBeB2fJ4QKQrsLwMQEM/JYANggDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMAAZYQAywgBkhAHICAOQEQYgIwxARhiAjDAAGWEAMg/9bdf0LX4h0XkCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACq8hdouPfUCk+KHQAAAABJRU5ErkJggg=='), + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWgAAAFKCAYAAAAwgcXoAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAjGSURBVHgB7d27ctRaFoDh1WBzsQ9wLkVsngHHfglSIOUdgJQiJSfmIUjhJUwGjiGaU7hmzoB7vLA1GPCt21vSlvR9Vao+5lAFbbf+EktbUgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUaxYAEzCfz/NldX9biTK+zGaz/0aLSv1FAYbg6v52I8r4e39rNdCXAoAqCTRApQQaoFICDVApgQaolEADVEqgASol0ACVEmiASgk0QKUEGqBSAg1QKYEGqJRAA1RKoAEqJdAAlRJogEp5ogowJV/3t/9EGV8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFjELABOMZ/Po6TZrL/sDO29rATA6Vb3t/UoY29/+1f04DDO1w63Ev59uLVGoIGz5GHi9Sjja/QU6EPZvFLv5Uu07FIAUCWBBqiUQANUSqABKiXQAJUSaIBKCTRApQQaoFICDVApgQaolEADVEqgASol0ACVEmiASgl0ANRJoAEqJdAAlfJEFeAs+RSUv6OMsg8FXNw/Ue69/BMAAAAAAAAAAAAAAAAAAAAAQCdmAcBpspNn3flzL1q4EZRAA2PRhLR5vXz4683ryk+/7+j/ixO+vqiM9t6Rr78e899ffvq9za9/FWigdhnNbNVqfA/wyuHr5fgxxqMi0EDfMq4Z3ya4K4fbaMN7XgINdKGJ8OUjW/M1JxBooKSj4c3X5mhYa5bgmwYsoxlHXA0hbo1vJnCW5gRds10Jo4lOCDTws4xvRjiPjvOoeDXohUDDtB09Or4W31dTUAE/CJiWJshNjK8E1RJoGDdBHjCBhvHJCBtZjIAfHAxf7scZ46uHr/brkfCDhGFqVllcCWOL0RJoGIajs+S16Hjfnc3K/XHzefG7cp7b0N6HQEO9qhhdbG5uxosXL6KET58+xb1796IPGef79+/Ho0ePooSXL1/Gq1evok0rAdSkiXIeJVdxgm9tbS22traihJ2dnejTxsZGsffy+vXraJtAQ/+qizJ1EGjoRzNTvhGizAkEGrqVKy56OdHH8Ag0tC9vPpRBXg9RZgECDe3IEGeU82jZOmWWItBQlhEGxQg0XFyG+Lc4CLN9imJ8mGB5ebScc+W8kMTRMsUJNCzGbJnOCDScj5UYdE6g4XR5lHwjHC3TA4GGXx299FqY6Y1Aw3fNagxjDKog0CDMVEqgmbIcX+QY43pAhQSaKXLij0EQaKZEmJfU52OqShvSexFopkCYL2B7ezsePnwYJezu7kZfMsz5FJQPHz5ECe/evYu2OSHCmAkzgybQjJEwMwoCzZgIM6Mi0IxB3ifjZhxc/QejIdAMWX5+84h5PWCEBJohcuUfk+DDzdBklPOo2WeX0fMhZyjyxN/vcTBvhkkQaGq3GgcnAK3MYHIEmlo5AcjkCTQ1MmeGsANQlxxj5DhjNQCBpgr5ObwV7ssMPxBo+macASewU9AX982AMwg0XbM6A85JoOmSi01gAQJNF9xtDpYg0LTNSUBYkp2GtuRRc44znASEJQk0bXDUDAXYgSjJUTMUJNCU4qgZCrMzcVGOmqElAs1F5LK5jLPPEbTAjsUyXA0IHRBoFuVqQOiIQLOIPGr+LZic2axcKubzefSl5PtIbb+XSwFny6Plv0KcJ2lrayv29vaKbO/fv4++ZJyfPHlS7L08fvw42rYScDonAqEnAs1JnAiEngk0x8mRxh/h2YDQK4HmZ2txcGtQIw3omUBzVIbZSAMqIdAkIw2okEDjwhOolEBPW44zbgZQJYGepjwBeGt/ux5AtQR6epqrAo00oHICPS05b/4zLKGDQRDo6TBvhoER6GmwvhkGSKDHzfpmGDCBHi8nA2HgBHqcnAyEERDo8XEyEEZCoMfFI6loRZ+PqSptSO/FP4HHI++n4cpAWrGxsRGl7OzsRF/W1tbi9u3bUcLHjx9jd3c32iTQw5c/wzwZaKUGjIxAD5uVGjBiAj1c4gwjJ9DDlOOMjLOfH4yYHXx4PDMQJsJOPiwZ51sBTIJAD4c1zjAxAj0M4gwTJND1E2eYKIGum/s4w4QJdL1cug0TJ9B1EmdAoCskzsA3Al0XcQb+T6DrIc7ADwS6DuIM/EKg+yfOwLEEul/iDJxIoPsjzsCpLgV9yCsExRk4lUB3L++t4fJt4ExGHN1y4yPg3AS6O+IMLESguyHOwMLMoNuXj6kSZ2BhAt2ua+EZgsCSBLo9q3Gw1hlgKStBGy7vb3+EGT8jsL6+Hpubm1HK27dvoy8bGxvfthJ2dna+bW0S6PIyzn8dvsLg3b17N968eRMlZNDu3LkTfZjNZvHgwYN49uxZlPD06dN4/vx5tMmIo6w8YhZnoAiBLuvPEGegEIEuJ++vcSUAChHoMtxfAyhOoC8uw+xCFKA4gb6YXOt8MwBaINDLa9Y6A7RCoJdjOR3QOoFeTl7CLc5AqwR6cbli41oAtEygF2PFBtAZgT4/KzaATgn0+VixAXROoM8n4+ykINApgT5bjjVWA6BjAn269XCPDaAnAn2yHGk4KQj8Yj6fRxc8UeV4zVNRYPJ2d3dje3s7Smj7EVFn+fz5c5H3koHO70vbPDPveHlS0MUoQK+MOH7lSkGgCgL9o3wiiisFgSoI9Hc5d/49ACoh0N+5Qx1QFYE+kHNnD3wFqiLQ5s5ApaYeaHNnoFpTD3SONsydgSpNOdB5j43rAVCpqQbafTaA6k010O6zAVRvioE2dwYGYWqBtqQOGIwpBdqSOmBQphRoow1gUKYS6OthSR0wMFMIdB413wiAgZlCoI02gEEae6CNNoDBGnOgjTaAQRtzoI02gEEba6CNNoDBG2OgZ2G0AYzA/wBP5hsF50HhogAAAABJRU5ErkJggg=='); + background-repeat: no-repeat; + background-position: top 0 left 0, bottom 0 right 0; + background-size: $euiSizeXL * 4, $euiSizeXL * 6; + + .homSolutionPanel--enterpriseSearch & { + background-color: $euiColorSecondary; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAABKCAYAAADJwhY8AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAANMSURBVHgBzZp7buIwEMYHKC1QuqsF7Up7Hs4Al+Ff4DCcAonrtPuAPjcL1B/YyA0p2JkZK580SuQm6Y+xx4+xiSqumrG+d7/z/rYx9t+WZdZ2lFhXxq4jngfkP2Ov9qquq8jnm9Zu6eBNgD6TImwsoC80ibY1NIe1sRcSVsPYHfFVN9Yy1jG2pUPbFZEUoJMP+kYHWJakAZ0AemuvrPZZJ10B8jsdHFFKWh70BSe07X1GkUoBCCHib+w1qsq1qzivLkU6JDUgTSaT7m63C4ZMCmjgaDwe4zYYMhmgB+cEyPal95IAFsA5fTGQZ4dbdcAzcO7/9wxk7bMH8AfMTvJDUsO+fG2tSfJwvp5qtdqKPgEMEYABekeBo0IEnNODgTzpI0MBfaFhnwUtAQdlBvA+X1gG0Kmw0y0J57QykE9+ASdIHo3hF29cARMO6uYDhhvFGPwfjG0E4BxPxy/gVPFR8/m8MRwOsTosPa3y9KEtigBCpmoAJwV5jGixjtp8EG3xD8noOASKjiT2V6+Jr5YLFo2hDuvkDfEErqa7EZXx4naxWKyIr9b+e6QkU0U/iBcw+2jWnM08E09NtMMqA0JNNUC0ReInlRraE9bodXBOzaoD1rQBuVku9SpmZ7eSL9wjVa864LbqgOpVzJ4bagNyv7/RBozZgymSOuAN8bRTA7RrFM4+DJRpepBbvZAqIDf3jQmrThXbxCS3i5FdduYksXOAjUp5QJt75npvK75whwwclopd4uvV3YgB2m7lG8nouK0rAojkEcnlZTZ+plUCsDEajfom9SYBB31InXA/is64h+8sl0vKsowGgwExBO/99QvKDkXISHQpFxCz2Wx/nU6nVFIniacyHkTOpGevJ2J48sR7UKgHnceCRoiSnvxVVOj2P6C6ZyjHVKljwb7a54KDKtKT2MgpPDECD/ZJSYGedEdaCqW+437BkzgcdG/zOIVKciTgDOTawL2dezfVmYUiyHV+V6lIyQAhDxJwjyHvoPv4SWmFgAiCg1JnFqLgIO6qK1SIVowS0afjUgCin/tNJZOZ2oCIUlRr6aOlWoDwFjZz2CczpQHhKXjsYv8WKilAeAwBgL0R0ZPCHECAvFirzCFb/5hyknPV7zL4DLH0CVGgAAAAAElFTkSuQmCC'), + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAA+CAYAAACbQR1vAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACBSURBVHgB7dpBDcAwDMVQdyzGH9xojEQjtbIfBEu5fGUx62WPjyEPcgVArgDIFQC5AiBXAOQKgFwBkCsAcgVATh9gsW+4vFIngFwBkCsAcgVArgDIFQC5AiBXAORahJjVm9zpCoBcAZArAHIFQK4AyBUAuQIgVwDkCoBcAZDTB/gBLrgCk8OhuvYAAAAASUVORK5CYII='); + background-position: top $euiSizeS left 0, bottom $euiSizeS right $euiSizeS; + background-size: $euiSize * 1.25, $euiSizeXL; + } + + .homSolutionPanel--observability & { + background-color: $euiColorAccent; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADNSURBVHgB7duxCcIAEEDR08IFrGyz/0pZwVYEIcYF/FWKwHtwXP/hyrvMQbZtu+3rPid3Hf4SKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgcJmDnt6WZfms6/qak/sFeswx3vs85+ScWBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgKFLx4KCqcIFEJnAAAAAElFTkSuQmCC'); + background-position: top $euiSizeS right $euiSizeS; + background-size: $euiSizeL * 1.5; + } + + .homSolutionPanel--securitySolution & { + background-color: $euiColorDarkestShade; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGIAAABiCAYAAACrpQYOAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAKRSURBVHgB7d3tSgMxEIXhMeqPQoWC9f4vte5Z3dLvTbKZzCQ5LyxqKwg+tNjdUxQR+RJmXpiOvRDDvPD/kRjGhYvPgbETZlK4+fogxDApPLiNGAaFJ7cD40NYtcKL+76FGNUKK/cRo1Ih4n5gvAtTbQ1i+R5iKBcDgYBADMViIRAxFEuBQMRQKhUCEUOhHAi0YLwJK1IuBCJGwbZAoE8hRpG2QqAFg22oBAQCxkFYdqUgEE6dEyOzkhCIGJmVhkDA4PXvxDQgEMcIiWlBIGIkpAmBiBGZNgQiRkQ1IBAxVqoFgThge1FNCMTN1JNqQyBiPMgCAnHAdpMVBOJm6iJLCA7YLrKEWH4+r3+LPYQIMeY8QKDhlyFeINDQGJ4g0LAY3iDQkBgeIdBwmymvEGgoDM8QaJgBm3cINMSArQUI1P2ArRUI1PVmqiUI1C1GaxCoywFbixCouzFCqxCIyxBPnU6nLjBafkQs7YExHdJyPUCg+WmqZYxeIBAwmv3TticItJseFYcWHxm9QaD5RV9rGD1CIGAcJ4xmztr2CoHms7atYPQMgc4Y3p+qeodAwPiZjnfPGCNAoPMgwSvGKBDINcZIEMgtxmgQyCXGiBDIHcaoEAgYx+n48IAxMgQ6v1nGGmN0COQCgxB/4feAF307KwxCXDe/9dgCgxD3mWAQ4nHAqHrplRDPq3odnBCvq4ZBiPWqYBAiLnUMQsSnikGItObtlAYGIdJTmesQIq/iGITIr+h2ihDbKradIsT2imynCFGmzdspQpRr03VwQpQtG4MQ5cvCIIROyRiE0CsJgxC6RW+nCKFf1FyHEHVaxSBEvV5upwhRv4dzHULYdIdBCLuutlOEsO18HZz/u8E+YMgvrbKfmp8y7IEAAAAASUVORK5CYII='); + background-position: top $euiSizeS left $euiSizeS; + background-size: $euiSizeL * 2; + } +} diff --git a/src/plugins/home/public/application/components/_synopsis.scss b/src/plugins/home/public/application/components/_synopsis.scss index 49e71f159fe6f..3eac2bc9705e0 100644 --- a/src/plugins/home/public/application/components/_synopsis.scss +++ b/src/plugins/home/public/application/components/_synopsis.scss @@ -5,6 +5,10 @@ box-shadow: none; } + .homSynopsis__cardTitle { + display: flex; + } + // SASSTODO: Fix in EUI .euiCard__content { padding-top: 0 !important; diff --git a/src/plugins/home/public/application/components/add_data.js b/src/plugins/home/public/application/components/add_data.js deleted file mode 100644 index c35b7b04932fb..0000000000000 --- a/src/plugins/home/public/application/components/add_data.js +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { getServices } from '../kibana_services'; - -import { - EuiButton, - EuiLink, - EuiPanel, - EuiTitle, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiCard, - EuiIcon, - EuiHorizontalRule, - EuiFlexGrid, -} from '@elastic/eui'; - -const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl, mlEnabled }) => { - const basePath = getServices().getBasePath(); - - const renderCards = () => { - const apmData = { - title: intl.formatMessage({ - id: 'home.addData.apm.nameTitle', - defaultMessage: 'APM', - }), - description: intl.formatMessage({ - id: 'home.addData.apm.nameDescription', - defaultMessage: - 'APM automatically collects in-depth performance metrics and errors from inside your applications.', - }), - ariaDescribedby: 'aria-describedby.addAmpButtonLabel', - }; - const loggingData = { - title: intl.formatMessage({ - id: 'home.addData.logging.nameTitle', - defaultMessage: 'Logs', - }), - description: intl.formatMessage({ - id: 'home.addData.logging.nameDescription', - defaultMessage: - 'Ingest logs from popular data sources and easily visualize in preconfigured dashboards.', - }), - ariaDescribedby: 'aria-describedby.addLogDataButtonLabel', - }; - const metricsData = { - title: intl.formatMessage({ - id: 'home.addData.metrics.nameTitle', - defaultMessage: 'Metrics', - }), - description: intl.formatMessage({ - id: 'home.addData.metrics.nameDescription', - defaultMessage: - 'Collect metrics from the operating system and services running on your servers.', - }), - ariaDescribedby: 'aria-describedby.addMetricsButtonLabel', - }; - const siemData = { - title: intl.formatMessage({ - id: 'home.addData.securitySolution.nameTitle', - defaultMessage: 'SIEM + Endpoint Security', - }), - description: intl.formatMessage({ - id: 'home.addData.securitySolution.nameDescription', - defaultMessage: - 'Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases.', - }), - ariaDescribedby: 'aria-describedby.addSiemButtonLabel', - }; - - const getApmCard = () => ( - - {apmData.description}} - footer={ - - - - } - /> - - ); - - return ( - - - - - - - - - -

- -

-
-
-
- - - {apmUiEnabled !== false && getApmCard()} - - - {loggingData.description} - } - footer={ - - - - } - /> - - - - {metricsData.description} - } - footer={ - - - - } - /> - - -
- - - - - - - - -

- -

-
-
-
- - {siemData.description}} - footer={ - - - - } - /> -
-
- ); - }; - - const footerItemClasses = classNames('homAddData__footerItem', { - 'homAddData__footerItem--highlight': isNewKibanaInstance, - }); - - return ( - - {renderCards()} - - - - - - - - - - - - - - - {mlEnabled !== false ? ( - - - - - - - - - - - ) : null} - - - - - - - - - - - - - ); -}; - -AddDataUi.propTypes = { - apmUiEnabled: PropTypes.bool.isRequired, - mlEnabled: PropTypes.bool.isRequired, - isNewKibanaInstance: PropTypes.bool.isRequired, -}; - -export const AddData = injectI18n(AddDataUi); diff --git a/src/plugins/home/public/application/components/add_data.test.js b/src/plugins/home/public/application/components/add_data.test.js deleted file mode 100644 index 9457f766409b8..0000000000000 --- a/src/plugins/home/public/application/components/add_data.test.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { AddData } from './add_data'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { getServices } from '../kibana_services'; - -jest.mock('../kibana_services', () => { - const mock = { - getBasePath: jest.fn(() => 'path'), - }; - return { - getServices: () => mock, - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -test('render', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('mlEnabled', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('apmUiEnabled', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('isNewKibanaInstance', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); diff --git a/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap new file mode 100644 index 0000000000000..787802e508ca7 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddData render 1`] = ` +
+ + + +

+ +

+
+
+ + + + + +
+ + + + + + + + + + + + +
+`; diff --git a/src/plugins/home/public/application/components/add_data/add_data.test.tsx b/src/plugins/home/public/application/components/add_data/add_data.test.tsx new file mode 100644 index 0000000000000..e76e834802284 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/add_data.test.tsx @@ -0,0 +1,95 @@ +/* + * 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. + */ + +/* + * 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 { AddData } from './add_data'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +jest.mock('../app_navigation_handler', () => { + return { + createAppNavigationHandler: jest.fn(() => () => {}), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const addBasePathMock = jest.fn((path: string) => (path ? path : 'path')); + +const mockFeatures = [ + { + category: 'data', + description: 'Ingest data from popular apps and services.', + homePageSection: 'add_data', + icon: 'indexOpen', + id: 'home_tutorial_directory', + order: 500, + path: '/app/home#/tutorial_directory', + title: 'Ingest data', + }, + { + category: 'admin', + description: 'Add and manage your fleet of Elastic Agents and integrations.', + homePageSection: 'add_data', + icon: 'indexManagementApp', + id: 'ingestManager', + order: 510, + path: '/app/ingestManager', + title: 'Add Elastic Agent', + }, + { + category: 'data', + description: 'Import your own CSV, NDJSON, or log file', + homePageSection: 'add_data', + icon: 'document', + id: 'ml_file_data_visualizer', + order: 520, + path: '/app/ml#/filedatavisualizer', + title: 'Upload a file', + }, +]; + +describe('AddData', () => { + test('render', () => { + const component = shallowWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/home/public/application/components/add_data/add_data.tsx b/src/plugins/home/public/application/components/add_data/add_data.tsx new file mode 100644 index 0000000000000..82f0020b1b389 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/add_data.tsx @@ -0,0 +1,95 @@ +/* + * 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, { FC } from 'react'; +import PropTypes from 'prop-types'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-expect-error untyped service +import { FeatureCatalogueEntry } from '../../services'; +import { createAppNavigationHandler } from '../app_navigation_handler'; +// @ts-expect-error untyped component +import { Synopsis } from '../synopsis'; + +interface Props { + addBasePath: (path: string) => string; + features: FeatureCatalogueEntry[]; +} + +export const AddData: FC = ({ addBasePath, features }) => ( +
+ + + +

+ +

+
+
+ + + + + + +
+ + + + + {features.map((feature) => ( + + + + ))} + +
+); + +AddData.propTypes = { + addBasePath: PropTypes.func.isRequired, + features: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + showOnHomePage: PropTypes.bool.isRequired, + category: PropTypes.string.isRequired, + order: PropTypes.number, + }) + ), +}; diff --git a/src/core/server/uuid/index.ts b/src/plugins/home/public/application/components/add_data/index.ts similarity index 92% rename from src/core/server/uuid/index.ts rename to src/plugins/home/public/application/components/add_data/index.ts index ad57041a124c9..a7d465d177636 100644 --- a/src/core/server/uuid/index.ts +++ b/src/plugins/home/public/application/components/add_data/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { UuidService, UuidServiceSetup } from './uuid_service'; +export * from './add_data'; diff --git a/src/plugins/home/public/application/components/app_navigation_handler.ts b/src/plugins/home/public/application/components/app_navigation_handler.ts index 6e78af7f42f52..61d85c033b544 100644 --- a/src/plugins/home/public/application/components/app_navigation_handler.ts +++ b/src/plugins/home/public/application/components/app_navigation_handler.ts @@ -17,6 +17,7 @@ * under the License. */ +import { MouseEvent } from 'react'; import { getServices } from '../kibana_services'; export const createAppNavigationHandler = (targetUrl: string) => (event: MouseEvent) => { diff --git a/src/plugins/home/public/application/components/feature_directory.js b/src/plugins/home/public/application/components/feature_directory.js index e9ab348f164c7..36ececcdfd8df 100644 --- a/src/plugins/home/public/application/components/feature_directory.js +++ b/src/plugins/home/public/application/components/feature_directory.js @@ -115,6 +115,7 @@ export class FeatureDirectory extends React.Component { return ( { - const { addBasePath, directories } = this.props; - return directories - .filter((directory) => { - return directory.showOnHomePage && directory.category === category; - }) - .map((directory) => { - return ( - - - - ); - }); - }; + findDirectoryById = (id) => this.props.directories.find((directory) => directory.id === id); + + getFeaturesByCategory = (category) => + this.props.directories + .filter((directory) => directory.showOnHomePage && directory.category === category) + .sort((directoryA, directoryB) => directoryA.order - directoryB.order); renderNormal() { - const { apmUiEnabled, mlEnabled } = this.props; + const { addBasePath, solutions } = this.props; + + const devTools = this.findDirectoryById('console'); + const stackManagement = this.findDirectoryById('stack-management'); + const advancedSettings = this.findDirectoryById('advanced_settings'); + + const addDataFeatures = this.getFeaturesByCategory(FeatureCatalogueCategory.DATA); + const manageDataFeatures = this.getFeaturesByCategory(FeatureCatalogueCategory.ADMIN); + + // Show card for console if none of the manage data plugins are available, most likely in OSS + if (manageDataFeatures.length < 1 && devTools) { + manageDataFeatures.push(devTools); + } return ( - - - -

- -

-
- - - - - - - - - -

- -

+
+
+
+ + + +

+ +

- - - {this.renderDirectories(FeatureCatalogueCategory.DATA)} - - +
+ + + + + + {i18n.translate('home.pageHeader.addDataButtonLabel', { + defaultMessage: 'Add data', + })} + + + + {stackManagement ? ( + + + {i18n.translate('home.pageHeader.stackManagementButtonLabel', { + defaultMessage: 'Manage', + })} + + + ) : null} + + {devTools ? ( + + + {i18n.translate('home.pageHeader.devToolsButtonLabel', { + defaultMessage: 'Dev tools', + })} + + + ) : null} + + +
+
+
+ +
+ {solutions.length && } + + + + + - - -

- -

-
- - - {this.renderDirectories(FeatureCatalogueCategory.ADMIN)} - -
+
- +
+
); } @@ -260,13 +296,23 @@ Home.propTypes = { path: PropTypes.string.isRequired, showOnHomePage: PropTypes.bool.isRequired, category: PropTypes.string.isRequired, + order: PropTypes.number, + }) + ), + solutions: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + subtitle: PropTypes.string.isRequired, + descriptions: PropTypes.arrayOf(PropTypes.string).isRequired, + icon: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + order: PropTypes.number, }) ), - apmUiEnabled: PropTypes.bool.isRequired, find: PropTypes.func.isRequired, localStorage: PropTypes.object.isRequired, urlBasePath: PropTypes.string.isRequired, - mlEnabled: PropTypes.bool.isRequired, telemetry: PropTypes.shape({ telemetryService: PropTypes.any, telemetryNotifications: PropTypes.any, diff --git a/src/plugins/home/public/application/components/home.test.js b/src/plugins/home/public/application/components/home.test.js index 3bcfce513cb12..0d7596d92a5a1 100644 --- a/src/plugins/home/public/application/components/home.test.js +++ b/src/plugins/home/public/application/components/home.test.js @@ -41,6 +41,7 @@ describe('home', () => { beforeEach(() => { defaultProps = { directories: [], + solutions: [], apmUiEnabled: true, mlEnabled: true, kibanaVersion: '99.2.1', @@ -92,8 +93,96 @@ describe('home', () => { expect(component).toMatchSnapshot(); }); + describe('header', () => { + test('render', async () => { + const component = await renderHome(); + expect(component).toMatchSnapshot(); + }); + + test('should show "Manage" link if stack management is available', async () => { + const directoryEntry = { + id: 'stack-management', + title: 'Management', + description: 'Your center console for managing the Elastic Stack.', + icon: 'managementApp', + path: 'management_landing_page', + category: FeatureCatalogueCategory.ADMIN, + showOnHomePage: false, + }; + + const component = await renderHome({ + directories: [directoryEntry], + }); + + expect(component).toMatchSnapshot(); + }); + + test('should show "Dev tools" link if console is available', async () => { + const directoryEntry = { + id: 'console', + title: 'Console', + description: 'Skip cURL and use a JSON interface to work with your data in Console.', + icon: 'consoleApp', + path: 'path-to-dev-tools', + category: FeatureCatalogueCategory.ADMIN, + showOnHomePage: false, + }; + + const component = await renderHome({ + directories: [directoryEntry], + }); + + expect(component).toMatchSnapshot(); + }); + }); + describe('directories', () => { - test('should render DATA directory entry in "Explore Data" panel', async () => { + test('should render solutions in the "solution section"', async () => { + const solutionEntry1 = { + id: 'kibana', + title: 'Kibana', + subtitle: 'Visualize & analyze', + descriptions: ['Analyze data in dashboards'], + icon: 'logoKibana', + path: 'kibana_landing_page', + order: 1, + }; + const solutionEntry2 = { + id: 'solution-2', + title: 'Solution two', + subtitle: 'Subtitle for solution two', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-two', + order: 2, + }; + const solutionEntry3 = { + id: 'solution-3', + title: 'Solution three', + subtitle: 'Subtitle for solution three', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-three', + order: 3, + }; + const solutionEntry4 = { + id: 'solution-4', + title: 'Solution four', + subtitle: 'Subtitle for solution four', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-four', + order: 4, + }; + + const component = await renderHome({ + solutions: [solutionEntry1, solutionEntry2, solutionEntry3, solutionEntry4], + }); + + expect(component).toMatchSnapshot(); + }); + + test('should render DATA directory entry in "Ingest your data" panel', async () => { const directoryEntry = { id: 'dashboard', title: 'Dashboard', @@ -111,7 +200,7 @@ describe('home', () => { expect(component).toMatchSnapshot(); }); - test('should render ADMIN directory entry in "Manage" panel', async () => { + test('should render ADMIN directory entry in "Manage your data" panel', async () => { const directoryEntry = { id: 'index_patterns', title: 'Index Patterns', @@ -148,6 +237,26 @@ describe('home', () => { }); }); + describe('change home route', () => { + test('should render a link to change the default route in advanced settings if advanced settings is enabled', async () => { + const component = await renderHome({ + directories: [ + { + description: 'Change your settings', + icon: 'gear', + id: 'advanced_settings', + path: 'path-to-advanced_settings', + showOnHomePage: false, + title: 'Advanced settings', + category: FeatureCatalogueCategory.ADMIN, + }, + ], + }); + + expect(component).toMatchSnapshot(); + }); + }); + describe('welcome', () => { test('should show the welcome screen if enabled, and there are no index patterns defined', async () => { defaultProps.localStorage.getItem = sinon.spy(() => 'true'); diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 648915b6dae0c..90e549c873436 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -38,7 +38,7 @@ const RedirectToDefaultApp = () => { return null; }; -export function HomeApp({ directories }) { +export function HomeApp({ directories, solutions }) { const { savedObjectsClient, getBasePath, @@ -48,8 +48,6 @@ export function HomeApp({ directories }) { } = getServices(); const environment = environmentService.getEnvironment(); const isCloudEnabled = environment.cloud; - const mlEnabled = environment.ml; - const apmUiEnabled = environment.apmUi; const renderTutorialDirectory = (props) => { return ( @@ -87,8 +85,7 @@ export function HomeApp({ directories }) { +
+ ), + }} + > + onChange({ createNewCopies: false })} + > + {overwriteRadio} + + + + + onChange({ createNewCopies: true })} + /> + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss new file mode 100644 index 0000000000000..4b46c1244e246 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss @@ -0,0 +1,20 @@ +.savedObjectsManagementImportSummary__row { + margin-bottom: $euiSizeXS; +} + +.savedObjectsManagementImportSummary__title { + // Constrains title to the flex item, and allows for truncation when necessary + min-width: 0; +} + +.savedObjectsManagementImportSummary__createdCount { + color: $euiColorSuccessText; +} + +.savedObjectsManagementImportSummary__errorCount { + color: $euiColorDangerText; +} + +.savedObjectsManagementImportSummary__icon { + margin-left: $euiSizeXS; +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx new file mode 100644 index 0000000000000..ed65131b0fc6b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { ImportSummary, ImportSummaryProps } from './import_summary'; +import { FailedImport } from '../../../lib'; + +// @ts-expect-error +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('ImportSummary', () => { + const errorUnsupportedType: FailedImport = { + obj: { type: 'error-obj-type', id: 'error-obj-id', meta: { title: 'Error object' } }, + error: { type: 'unsupported_type' }, + }; + const successNew = { type: 'dashboard', id: 'dashboard-id', meta: { title: 'New' } }; + const successOverwritten = { + type: 'visualization', + id: 'viz-id', + meta: { title: 'Overwritten' }, + overwrite: true, + }; + + const findHeader = (wrapper: ShallowWrapper) => wrapper.find('h3'); + const findCountCreated = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__createdCount'); + const findCountOverwritten = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__overwrittenCount'); + const findCountError = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__errorCount'); + const findObjectRow = (wrapper: ShallowWrapper) => + wrapper.find('.savedObjectsManagementImportSummary__row'); + + it('should render as expected with no results', async () => { + const props: ImportSummaryProps = { failedImports: [], successfulImports: [] }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 0 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(0); + }); + + it('should render as expected with a newly created object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successNew], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an overwritten object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an error object', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with mixed objects', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [successNew, successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 3 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(3); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx new file mode 100644 index 0000000000000..7949f7d18d350 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -0,0 +1,237 @@ +/* + * 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 './import_summary.scss'; +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiIcon, + EuiIconTip, + EuiHorizontalRule, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectsImportSuccess } from 'kibana/public'; +import { FailedImport } from '../../..'; +import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; + +const DEFAULT_ICON = 'apps'; + +export interface ImportSummaryProps { + failedImports: FailedImport[]; + successfulImports: SavedObjectsImportSuccess[]; +} + +interface ImportItem { + type: string; + id: string; + title: string; + icon: string; + outcome: 'created' | 'overwritten' | 'error'; + errorMessage?: string; +} + +const unsupportedTypeErrorMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.importSummary.unsupportedTypeError', + { defaultMessage: 'Unsupported object type' } +); + +const getErrorMessage = ({ error }: FailedImport) => { + if (error.type === 'unknown') { + return error.message; + } else if (error.type === 'unsupported_type') { + return unsupportedTypeErrorMessage; + } +}; + +const mapFailedImport = (failure: FailedImport): ImportItem => { + const { obj } = failure; + const { type, id, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const errorMessage = getErrorMessage(failure); + return { type, id, title, icon, outcome: 'error', errorMessage }; +}; + +const mapImportSuccess = (obj: SavedObjectsImportSuccess): ImportItem => { + const { type, id, meta, overwrite } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const outcome = overwrite ? 'overwritten' : 'created'; + return { type, id, title, icon, outcome }; +}; + +const getCountIndicators = (importItems: ImportItem[]) => { + if (!importItems.length) { + return null; + } + + const outcomeCounts = importItems.reduce( + (acc, { outcome }) => acc.set(outcome, (acc.get(outcome) ?? 0) + 1), + new Map() + ); + const createdCount = outcomeCounts.get('created'); + const overwrittenCount = outcomeCounts.get('overwritten'); + const errorCount = outcomeCounts.get('error'); + + return ( + + {createdCount && ( + + +

+ +

+
+
+ )} + {overwrittenCount && ( + + +

+ +

+
+
+ )} + {errorCount && ( + + +

+ +

+
+
+ )} +
+ ); +}; + +const getStatusIndicator = ({ outcome, errorMessage }: ImportItem) => { + switch (outcome) { + case 'created': + return ( + + ); + case 'overwritten': + return ( + + ); + case 'error': + return ( + + ); + } +}; + +export const ImportSummary = ({ failedImports, successfulImports }: ImportSummaryProps) => { + const importItems: ImportItem[] = _.sortBy( + [ + ...failedImports.map((x) => mapFailedImport(x)), + ...successfulImports.map((x) => mapImportSuccess(x)), + ], + ['type', 'title'] + ); + + return ( + + +

+ +

+
+ + {getCountIndicators(importItems)} + + {importItems.map((item, index) => { + const { type, title, icon } = item; + return ( + + + + + + + + +

+ {title} +

+
+
+ +
{getStatusIndicator(item)}
+
+
+ ); + })} +
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx new file mode 100644 index 0000000000000..c93bc9e5038df --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { shallowWithI18nProvider, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { OverwriteModalProps, OverwriteModal } from './overwrite_modal'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('OverwriteModal', () => { + const obj = { type: 'foo', id: 'bar', meta: { title: 'baz' } }; + const onFinish = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('with a regular conflict', () => { + const props: OverwriteModalProps = { + conflict: { obj, error: { type: 'conflict', destinationId: 'qux' } }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with an existing object, are you sure you want to overwrite it?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(0); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); + + describe('with an ambiguous conflict', () => { + const props: OverwriteModalProps = { + conflict: { + obj, + error: { + type: 'ambiguous_conflict', + destinations: [ + // TODO: change one of these to have an actual `updatedAt` date string, and mock Moment for the snapshot below + { id: 'qux', title: 'some title', updatedAt: undefined }, + { id: 'quux', title: 'another title', updatedAt: undefined }, + ], + }, + }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with multiple existing objects, do you want to overwrite one of them?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(1); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + // first destination is selected by default + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx new file mode 100644 index 0000000000000..dbe95161cbeae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useState, Fragment, ReactNode } from 'react'; +import { + EuiOverlayMask, + EuiConfirmModal, + EUI_MODAL_CONFIRM_BUTTON, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { FailedImportConflict } from '../../../lib/resolve_import_errors'; +import { getDefaultTitle } from '../../../lib'; + +export interface OverwriteModalProps { + conflict: FailedImportConflict; + onFinish: (overwrite: boolean, destinationId?: string) => void; +} + +export const OverwriteModal = ({ conflict, onFinish }: OverwriteModalProps) => { + const { obj, error } = conflict; + let initialDestinationId: string | undefined; + let selectControl: ReactNode = null; + if (error.type === 'conflict') { + initialDestinationId = error.destinationId; + } else { + // ambiguous conflict must have at least two destinations; default to the first one + initialDestinationId = error.destinations[0].id; + } + const [destinationId, setDestinationId] = useState(initialDestinationId); + + if (error.type === 'ambiguous_conflict') { + const selectProps = { + options: error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + const idText = `ID: ${destination.id}`; + const lastUpdatedText = `Last updated: ${lastUpdated}`; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ {idText} +
+ {lastUpdatedText} +

+
+
+ ), + }; + }), + onChange: (value: string) => { + setDestinationId(value); + }, + }; + selectControl = ( + + ); + } + + const { type, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const bodyText = + error.type === 'conflict' + ? i18n.translate('savedObjectsManagement.objectsTable.overwriteModal.body.conflict', { + defaultMessage: + '"{title}" conflicts with an existing object, are you sure you want to overwrite it?', + values: { title }, + }) + : i18n.translate( + 'savedObjectsManagement.objectsTable.overwriteModal.body.ambiguousConflict', + { + defaultMessage: + '"{title}" conflicts with multiple existing objects, do you want to overwrite one of them?', + values: { title }, + } + ); + return ( + + onFinish(false)} + onConfirm={() => onFinish(true, destinationId)} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + maxWidth="500px" + > +

{bodyText}

+ {selectControl} +
+
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 2e545b372f781..ead2738973074 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -87,7 +87,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -154,7 +154,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -221,7 +221,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -288,7 +288,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index cc654f9717bd6..194733433ce29 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -26,7 +26,7 @@ import { EuiLink, EuiIcon, EuiCallOut, - EuiLoadingKibana, + EuiLoadingElastic, EuiInMemoryTable, EuiToolTip, EuiText, @@ -119,7 +119,7 @@ export class Relationships extends Component; + return ; } const columns = [ diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 0c7bf64ca011d..7733a587ca9a7 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -23,11 +23,14 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { keys } from '@elastic/eui'; import { httpServiceMock } from '../../../../../../core/public/mocks'; import { actionServiceMock } from '../../../services/action_service.mock'; +import { columnServiceMock } from '../../../services/column_service.mock'; +import { SavedObjectsManagementAction } from '../../..'; import { Table, TableProps } from './table'; const defaultProps: TableProps = { basePath: httpServiceMock.createSetupContract().basePath, actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), selectedSavedObjects: [ { id: '1', @@ -50,6 +53,7 @@ const defaultProps: TableProps = { }, filterOptions: [{ value: 2 }], onDelete: () => {}, + onActionRefresh: () => {}, onExport: () => {}, goInspectObject: () => {}, canGoInApp: () => true, @@ -122,4 +126,32 @@ describe('Table', () => { expect(component).toMatchSnapshot(); }); + + it(`allows for automatic refreshing after an action`, () => { + const actionRegistry = actionServiceMock.createStart(); + actionRegistry.getAll.mockReturnValue([ + { + // minimal action mock to exercise this test case + id: 'someAction', + render: () =>
action!
, + refreshOnFinish: () => true, + euiAction: { name: 'foo', description: 'bar', icon: 'beaker', type: 'icon' }, + registerOnFinishCallback: (callback: Function) => callback(), // call the callback immediately for this test + } as SavedObjectsManagementAction, + ]); + const onActionRefresh = jest.fn(); + const customizedProps = { ...defaultProps, actionRegistry, onActionRefresh }; + const component = shallowWithI18nProvider(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const actionColumn = columns.find((x) => x.hasOwnProperty('actions')) as { actions: any[] }; + const someAction = actionColumn.actions.find( + (x) => x['data-test-subj'] === 'savedObjectsTableAction-someAction' + ); + + expect(onActionRefresh).not.toHaveBeenCalled(); + someAction.onClick(); + expect(onActionRefresh).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 719729cee2602..0ce7e6e38962a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -42,11 +42,13 @@ import { SavedObjectWithMetadata } from '../../../types'; import { SavedObjectsManagementActionServiceStart, SavedObjectsManagementAction, + SavedObjectsManagementColumnServiceStart, } from '../../../services'; export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -54,6 +56,7 @@ export interface TableProps { filterOptions: any[]; canDelete: boolean; onDelete: () => void; + onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; @@ -74,6 +77,7 @@ interface TableState { isExportPopoverOpen: boolean; isIncludeReferencesDeepChecked: boolean; activeAction?: SavedObjectsManagementAction; + isColumnDataLoaded: boolean; } export class Table extends PureComponent { @@ -83,12 +87,22 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, + isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } + componentDidMount() { + this.loadColumnData(); + } + + loadColumnData = async () => { + await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); + this.setState({ isColumnDataLoaded: true }); + }; + onChange = ({ query, error }: any) => { if (error) { this.setState({ @@ -139,12 +153,14 @@ export class Table extends PureComponent { filterOptions, selectionConfig: selection, onDelete, + onActionRefresh, selectedSavedObjects, onTableChange, goInspectObject, onShowRelationships, basePath, actionRegistry, + columnRegistry, } = this.props; const pagination = { @@ -224,10 +240,18 @@ export class Table extends PureComponent { ); }, } as EuiTableFieldDataColumnType>, + ...columnRegistry.getAll().map((column) => { + return { + ...column.euiColumn, + sortable: false, + 'data-test-subj': `savedObjectsTableColumn-${column.id}`, + }; + }), { name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { defaultMessage: 'Actions', }), + width: '80px', actions: [ { name: i18n.translate( @@ -274,6 +298,10 @@ export class Table extends PureComponent { this.setState({ activeAction: undefined, }); + const { refreshOnFinish = () => false } = action; + if (refreshOnFinish()) { + onActionRefresh(object); + } }); if (action.euiAction.onClick) { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 3719dac24e6e7..1bc3dc8066520 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -41,6 +41,7 @@ import { import { dataPluginMock } from '../../../../data/public/mocks'; import { serviceRegistryMock } from '../../services/service_registry.mock'; import { actionServiceMock } from '../../services/action_service.mock'; +import { columnServiceMock } from '../../services/column_service.mock'; import { SavedObjectsTable, SavedObjectsTableProps, @@ -134,6 +135,7 @@ describe('SavedObjectsTable', () => { allowedTypes, serviceRegistry: serviceRegistryMock.create(), actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 340c0e3237f91..d879a71cc2269 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -27,7 +27,7 @@ import { EuiInMemoryTable, EuiIcon, EuiConfirmModal, - EuiLoadingKibana, + EuiLoadingElastic, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON, EuiCheckboxGroup, @@ -65,6 +65,7 @@ import { fetchExportObjects, fetchExportByTypeAndSearch, findObjects, + findObject, extractExportDetails, SavedObjectsExportResultDetails, } from '../../lib'; @@ -72,6 +73,7 @@ import { SavedObjectWithMetadata } from '../../types'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnServiceStart, } from '../../services'; import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; @@ -85,6 +87,7 @@ export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; @@ -157,7 +160,7 @@ export class SavedObjectsTable extends Component { @@ -202,15 +205,14 @@ export class SavedObjectsTable extends Component { - this.setState( - { - isSearching: true, - }, - this.debouncedFetch - ); + this.setState({ isSearching: true }, this.debouncedFetchObjects); }; - debouncedFetch = debounce(async () => { + fetchSavedObject = (type: string, id: string) => { + this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); + }; + + debouncedFetchObjects = debounce(async () => { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes } = this.props; const { queryText, visibleTypes } = parseQuery(query); @@ -261,10 +263,48 @@ export class SavedObjectsTable extends Component { + debouncedFetchObject = debounce(async (type: string, id: string) => { + const { notifications, http } = this.props; + try { + const resp = await findObject(http, type, id); + if (!this._isMounted) { + return; + } + + this.setState(({ savedObjects, filteredItemCount }) => { + const refreshedSavedObjects = savedObjects.map((object) => + object.type === type && object.id === id ? resp : object + ); + return { + savedObjects: refreshedSavedObjects, + filteredItemCount, + isSearching: false, + }; + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + isSearching: false, + }); + } + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage', + { defaultMessage: 'Unable to find saved object' } + ), + text: `${error}`, + }); + } + }, 300); + + refreshObjects = async () => { await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); }; + refreshObject = async ({ type, id }: SavedObjectWithMetadata) => { + await this.fetchSavedObject(type, id); + }; + onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { this.setState({ selectedSavedObjects: selection }); }; @@ -505,7 +545,7 @@ export class SavedObjectsTable extends Component; + modal = ; } else { const onCancel = () => { this.setState({ isShowingDeleteConfirmModal: false }); @@ -731,7 +771,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} - onRefresh={this.refreshData} + onRefresh={this.refreshObjects} filteredCount={filteredItemCount} /> @@ -740,6 +780,7 @@ export class SavedObjectsTable extends Component void; }) => { const capabilities = coreStart.application.capabilities; @@ -62,6 +65,7 @@ const SavedObjectsTablePage = ({ allowedTypes={allowedTypes} serviceRegistry={serviceRegistry} actionRegistry={actionRegistry} + columnRegistry={columnRegistry} savedObjectsClient={coreStart.savedObjects.client} indexPatterns={dataStart.indexPatterns} search={dataStart.search} diff --git a/src/plugins/saved_objects_management/public/mocks.ts b/src/plugins/saved_objects_management/public/mocks.ts index 1de3de8e85302..3bd5a70884d85 100644 --- a/src/plugins/saved_objects_management/public/mocks.ts +++ b/src/plugins/saved_objects_management/public/mocks.ts @@ -18,12 +18,14 @@ */ import { actionServiceMock } from './services/action_service.mock'; +import { columnServiceMock } from './services/column_service.mock'; import { serviceRegistryMock } from './services/service_registry.mock'; import { SavedObjectsManagementPluginSetup, SavedObjectsManagementPluginStart } from './plugin'; const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createSetup(), + columns: columnServiceMock.createSetup(), serviceRegistry: serviceRegistryMock.create(), }; return mock; @@ -32,6 +34,7 @@ const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createStart(), + columns: columnServiceMock.createStart(), }; return mock; }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 47d445e63b942..907352f52699e 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,6 +29,9 @@ import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, } from './services'; @@ -36,16 +39,18 @@ import { registerServices } from './register_services'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; + columns: SavedObjectsManagementColumnServiceSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; } export interface SavedObjectsManagementPluginStart { actions: SavedObjectsManagementActionServiceStart; + columns: SavedObjectsManagementColumnServiceStart; } export interface SetupDependencies { management: ManagementSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; } export interface StartDependencies { @@ -64,6 +69,7 @@ export class SavedObjectsManagementPlugin StartDependencies > { private actionService = new SavedObjectsManagementActionService(); + private columnService = new SavedObjectsManagementColumnService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); public setup( @@ -71,21 +77,24 @@ export class SavedObjectsManagementPlugin { home, management }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); + const columnSetup = this.columnService.setup(); - home.featureCatalogue.register({ - id: 'saved_objects', - title: i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { - defaultMessage: 'Saved Objects', - }), - description: i18n.translate('savedObjectsManagement.objects.savedObjectsDescription', { - defaultMessage: - 'Import, export, and manage your saved searches, visualizations, and dashboards.', - }), - icon: 'savedObjectsApp', - path: '/app/management/kibana/objects', - showOnHomePage: true, - category: FeatureCatalogueCategory.ADMIN, - }); + if (home) { + home.featureCatalogue.register({ + id: 'saved_objects', + title: i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { + defaultMessage: 'Saved Objects', + }), + description: i18n.translate('savedObjectsManagement.objects.savedObjectsDescription', { + defaultMessage: + 'Import, export, and manage your saved searches, visualizations, and dashboards.', + }), + icon: 'savedObjectsApp', + path: '/app/management/kibana/objects', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ @@ -109,15 +118,18 @@ export class SavedObjectsManagementPlugin return { actions: actionSetup, + columns: columnSetup, serviceRegistry: this.serviceRegistry, }; } public start(core: CoreStart, { data }: StartDependencies) { const actionStart = this.actionService.start(); + const columnStart = this.columnService.start(); return { actions: actionStart, + columns: columnStart, }; } } diff --git a/src/plugins/saved_objects_management/public/services/column_service.mock.ts b/src/plugins/saved_objects_management/public/services/column_service.mock.ts new file mode 100644 index 0000000000000..977b2099771ba --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.mock.ts @@ -0,0 +1,57 @@ +/* + * 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 { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, +} from './column_service'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + }; + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + has: jest.fn(), + getAll: jest.fn(), + }; + + mock.has.mockReturnValue(true); + mock.getAll.mockReturnValue([]); + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn().mockReturnValue(createSetupMock()), + start: jest.fn().mockReturnValue(createStartMock()), + }; + return mock; +}; + +export const columnServiceMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/src/plugins/saved_objects_management/public/services/column_service.test.ts b/src/plugins/saved_objects_management/public/services/column_service.test.ts new file mode 100644 index 0000000000000..367422b0bbe11 --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; +import { SavedObjectsManagementColumn } from './types'; + +class DummyColumn implements SavedObjectsManagementColumn { + constructor(public id: string) {} + + public euiColumn = { + field: 'id', + name: 'name', + }; + + public loadData = async () => {}; +} + +describe('SavedObjectsManagementColumnRegistry', () => { + let service: SavedObjectsManagementColumnService; + let setup: SavedObjectsManagementColumnServiceSetup; + + const createColumn = (id: string): SavedObjectsManagementColumn => { + return new DummyColumn(id); + }; + + beforeEach(() => { + service = new SavedObjectsManagementColumnService(); + setup = service.setup(); + }); + + describe('#register', () => { + it('allows columns to be registered and retrieved', () => { + const column = createColumn('foo'); + setup.register(column); + const start = service.start(); + expect(start.getAll()).toContain(column); + }); + + it('does not allow columns with duplicate ids to be registered', () => { + const column = createColumn('my-column'); + setup.register(column); + expect(() => setup.register(column)).toThrowErrorMatchingInlineSnapshot( + `"Saved Objects Management Column with id 'my-column' already exists"` + ); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/services/column_service.ts b/src/plugins/saved_objects_management/public/services/column_service.ts new file mode 100644 index 0000000000000..5006d9df813cf --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.ts @@ -0,0 +1,55 @@ +/* + * 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 { SavedObjectsManagementColumn } from './types'; + +export interface SavedObjectsManagementColumnServiceSetup { + /** + * register given column in the registry. + */ + register: (column: SavedObjectsManagementColumn) => void; +} + +export interface SavedObjectsManagementColumnServiceStart { + /** + * return all {@link SavedObjectsManagementColumn | columns} currently registered. + */ + getAll: () => Array>; +} + +export class SavedObjectsManagementColumnService { + private readonly columns = new Map>(); + + setup(): SavedObjectsManagementColumnServiceSetup { + return { + register: (column) => { + if (this.columns.has(column.id)) { + throw new Error(`Saved Objects Management Column with id '${column.id}' already exists`); + } + this.columns.set(column.id, column); + }, + }; + } + + start(): SavedObjectsManagementColumnServiceStart { + return { + getAll: () => [...this.columns.values()], + }; + } +} diff --git a/src/plugins/saved_objects_management/public/services/index.ts b/src/plugins/saved_objects_management/public/services/index.ts index a59ad9012c402..f3379a3e29702 100644 --- a/src/plugins/saved_objects_management/public/services/index.ts +++ b/src/plugins/saved_objects_management/public/services/index.ts @@ -22,9 +22,18 @@ export { SavedObjectsManagementActionServiceStart, SavedObjectsManagementActionServiceSetup, } from './action_service'; +export { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; export { SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './service_registry'; -export { SavedObjectsManagementAction, SavedObjectsManagementRecord } from './types'; +export { + SavedObjectsManagementAction, + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from './types'; diff --git a/src/plugins/saved_objects_management/public/services/types.ts b/src/plugins/saved_objects_management/public/services/types/action.ts similarity index 86% rename from src/plugins/saved_objects_management/public/services/types.ts rename to src/plugins/saved_objects_management/public/services/types/action.ts index c2f807f63b1b9..2ead55d1f4338 100644 --- a/src/plugins/saved_objects_management/public/services/types.ts +++ b/src/plugins/saved_objects_management/public/services/types/action.ts @@ -17,18 +17,8 @@ * under the License. */ -import { ReactNode } from 'react'; -import { SavedObjectReference } from 'src/core/public'; - -export interface SavedObjectsManagementRecord { - type: string; - id: string; - meta: { - icon: string; - title: string; - }; - references: SavedObjectReference[]; -} +import { ReactNode } from '@elastic/eui/node_modules/@types/react'; +import { SavedObjectsManagementRecord } from '.'; export abstract class SavedObjectsManagementAction { public abstract render: () => ReactNode; @@ -43,6 +33,7 @@ export abstract class SavedObjectsManagementAction { onClick?: (item: SavedObjectsManagementRecord) => void; render?: (item: SavedObjectsManagementRecord) => any; }; + public refreshOnFinish?: () => boolean; private callbacks: Function[] = []; diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts new file mode 100644 index 0000000000000..79ee4d649177f --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiTableFieldDataColumnType } from '@elastic/eui'; +import { SavedObjectsManagementRecord } from '.'; + +export interface SavedObjectsManagementColumn { + id: string; + euiColumn: Omit, 'sortable'>; + + data?: T; + loadData: () => Promise; +} diff --git a/src/plugins/saved_objects_management/public/services/types/index.ts b/src/plugins/saved_objects_management/public/services/types/index.ts new file mode 100644 index 0000000000000..667ba8a683d8d --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SavedObjectsManagementAction } from './action'; +export { SavedObjectsManagementColumn } from './column'; +export { SavedObjectsManagementRecord } from './record'; diff --git a/src/plugins/saved_objects_management/public/services/types/record.ts b/src/plugins/saved_objects_management/public/services/types/record.ts new file mode 100644 index 0000000000000..9e00935e674ad --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/record.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference, SavedObjectsNamespaceType } from 'src/core/public'; + +export interface SavedObjectsManagementRecord { + type: string; + id: string; + meta: { + icon: string; + title: string; + namespaceType: SavedObjectsNamespaceType; + }; + references: SavedObjectReference[]; + namespaces?: string[]; +} diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts index 0c0f9d8feb506..11e685bd198e4 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts @@ -34,6 +34,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }); + managementService.getNamespaceType.mockReturnValue('single'); }); it('inject the metadata to the obj', () => { @@ -58,6 +59,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }, + namespaceType: 'single', }, }); }); diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts index 615caffd3b60b..54cad2d54e60a 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts @@ -35,6 +35,7 @@ export function injectMetaAttributes( result.meta.title = savedObjectsManagement.getTitle(savedObject); result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); + result.meta.namespaceType = savedObjectsManagement.getNamespaceType(savedObject); return result; } diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts new file mode 100644 index 0000000000000..a2c12a3970523 --- /dev/null +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -0,0 +1,51 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { injectMetaAttributes } from '../lib'; +import { ISavedObjectsManagement } from '../services'; + +export const registerGetRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const managementService = await managementServicePromise; + const { client } = context.core.savedObjects; + + const { type, id } = req.params; + const findResponse = await client.get(type, id); + + const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); + + return res.ok({ body: enhancedSavedObject }); + }) + ); +}; diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts index 237760444f04e..b39262f0c8b3c 100644 --- a/src/plugins/saved_objects_management/server/routes/index.test.ts +++ b/src/plugins/saved_objects_management/server/routes/index.test.ts @@ -34,7 +34,7 @@ describe('registerRoutes', () => { }); expect(httpSetup.createRouter).toHaveBeenCalledTimes(1); - expect(router.get).toHaveBeenCalledTimes(3); + expect(router.get).toHaveBeenCalledTimes(4); expect(router.post).toHaveBeenCalledTimes(2); expect(router.get).toHaveBeenCalledWith( @@ -43,6 +43,12 @@ describe('registerRoutes', () => { }), expect.any(Function) ); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/kibana/management/saved_objects/{type}/{id}', + }), + expect.any(Function) + ); expect(router.get).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/kibana/management/saved_objects/relationships/{type}/{id}', diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts index 0929de56b215e..e074a0d5cbee2 100644 --- a/src/plugins/saved_objects_management/server/routes/index.ts +++ b/src/plugins/saved_objects_management/server/routes/index.ts @@ -20,6 +20,7 @@ import { HttpServiceSetup } from 'src/core/server'; import { ISavedObjectsManagement } from '../services'; import { registerFindRoute } from './find'; +import { registerGetRoute } from './get'; import { registerScrollForCountRoute } from './scroll_count'; import { registerScrollForExportRoute } from './scroll_export'; import { registerRelationshipsRoute } from './relationships'; @@ -33,6 +34,7 @@ interface RegisterRouteOptions { export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) { const router = http.createRouter(); registerFindRoute(router, managementServicePromise); + registerGetRoute(router, managementServicePromise); registerScrollForCountRoute(router); registerScrollForExportRoute(router); registerRelationshipsRoute(router, managementServicePromise); diff --git a/src/plugins/saved_objects_management/server/services/management.mock.ts b/src/plugins/saved_objects_management/server/services/management.mock.ts index 2099cc0f77bcc..85c2d3e4b08d9 100644 --- a/src/plugins/saved_objects_management/server/services/management.mock.ts +++ b/src/plugins/saved_objects_management/server/services/management.mock.ts @@ -28,6 +28,7 @@ const createManagementMock = () => { getTitle: jest.fn(), getEditUrl: jest.fn(), getInAppUrl: jest.fn(), + getNamespaceType: jest.fn(), }; return mocked; }; diff --git a/src/plugins/saved_objects_management/server/services/management.test.ts b/src/plugins/saved_objects_management/server/services/management.test.ts index 3625a3f913444..7ddde312767de 100644 --- a/src/plugins/saved_objects_management/server/services/management.test.ts +++ b/src/plugins/saved_objects_management/server/services/management.test.ts @@ -198,4 +198,28 @@ describe('SavedObjectsManagement', () => { expect(result).toEqual({ path: 'called', uiCapabilitiesPath: 'my.path' }); }); }); + + describe('getNamespaceType()', () => { + it('returns empty for unknown type', () => { + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ name: 'foo', namespaceType: 'single' }); + + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual('single'); + }); + }); }); diff --git a/src/plugins/saved_objects_management/server/services/management.ts b/src/plugins/saved_objects_management/server/services/management.ts index 7aee974182497..499f37990c346 100644 --- a/src/plugins/saved_objects_management/server/services/management.ts +++ b/src/plugins/saved_objects_management/server/services/management.ts @@ -50,4 +50,8 @@ export class SavedObjectsManagement { const getInAppUrl = this.registry.getType(savedObject.type)?.management?.getInAppUrl; return getInAppUrl ? getInAppUrl(savedObject) : undefined; } + + public getNamespaceType(savedObject: SavedObject) { + return this.registry.getType(savedObject.type)?.namespaceType; + } } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index c306446b9780d..acd575badbe5b 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -48,6 +48,1214 @@ } } }, + "application_usage": { + "properties": { + "dashboards": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "dev_tools": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "discover": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "home": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "kibana": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "management": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "short_url_redirect": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "timelion": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "visualize": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "apm": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "csm": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "canvas": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "dashboard_mode": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "appSearch": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "workplaceSearch": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "graph": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "logs": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "metrics": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "infra": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "ingestManager": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "lens": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "maps": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "ml": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "monitoring": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "observability-overview": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_account": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_access_agreement": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_capture_url": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_logged_out": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_login": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_logout": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_overwritten_session": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:overview": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:detections": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:hosts": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:network": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:timelines": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:case": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:administration": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "siem": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "space_selector": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "uptime": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + } + } + }, "csp": { "properties": { "strict": { diff --git a/src/plugins/vis_type_table/public/agg_table/agg_table.test.js b/src/plugins/vis_type_table/public/agg_table/agg_table.test.js index 0362bd55963d9..29a10151a9418 100644 --- a/src/plugins/vis_type_table/public/agg_table/agg_table.test.js +++ b/src/plugins/vis_type_table/public/agg_table/agg_table.test.js @@ -24,7 +24,7 @@ import 'angular-mocks'; import sinon from 'sinon'; import { round } from 'lodash'; -import { getFieldFormatsRegistry } from '../../../../test_utils/public/stub_field_formats'; +import { getFieldFormatsRegistry } from '../../../data/public/test_utils'; import { coreMock } from '../../../../core/public/mocks'; import { initAngularBootstrap } from '../../../kibana_legacy/public'; import { setUiSettings } from '../../../data/public/services'; diff --git a/src/plugins/vis_type_table/public/agg_table/agg_table_group.test.js b/src/plugins/vis_type_table/public/agg_table/agg_table_group.test.js index 43913eed32f90..04cf624331d81 100644 --- a/src/plugins/vis_type_table/public/agg_table/agg_table_group.test.js +++ b/src/plugins/vis_type_table/public/agg_table/agg_table_group.test.js @@ -22,7 +22,7 @@ import angular from 'angular'; import 'angular-mocks'; import expect from '@kbn/expect'; -import { getFieldFormatsRegistry } from '../../../../test_utils/public/stub_field_formats'; +import { getFieldFormatsRegistry } from '../../../data/public/test_utils'; import { coreMock } from '../../../../core/public/mocks'; import { initAngularBootstrap } from '../../../kibana_legacy/public'; import { setUiSettings } from '../../../data/public/services'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js index 308579126eeb1..7aa7d53554d57 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js @@ -18,7 +18,7 @@ */ import { createTickFormatter } from './tick_formatter'; -import { getFieldFormatsRegistry } from '../../../../../../test_utils/public/stub_field_formats'; +import { getFieldFormatsRegistry } from '../../../../../data/public/test_utils'; import { setFieldFormats } from '../../../services'; import { UI_SETTINGS } from '../../../../../data/public'; diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/vis_config.js b/src/plugins/vis_type_vislib/public/vislib/lib/vis_config.js index 6490dfe252b29..dda9d85ec43c5 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/vis_config.js +++ b/src/plugins/vis_type_vislib/public/vislib/lib/vis_config.js @@ -41,7 +41,7 @@ export class VisConfig { const visType = visTypes[visConfigArgs.type]; const typeDefaults = visType(visConfigArgs, this.data); - this._values = _.defaultsDeep({}, typeDefaults, DEFAULT_VIS_CONFIG); + this._values = _.defaultsDeep({ ...typeDefaults }, DEFAULT_VIS_CONFIG); this._values.el = el; } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 4efdfd2911cbc..cc278a6ee9b3d 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -42,7 +42,7 @@ import { ExpressionRenderError, } from '../../../../plugins/expressions/public'; import { buildPipeline } from '../legacy/build_pipeline'; -import { Vis } from '../vis'; +import { Vis, SerializedVis } from '../vis'; import { getExpressions, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; @@ -63,6 +63,7 @@ export interface VisualizeInput extends EmbeddableInput { vis?: { colors?: { [key: string]: string }; }; + savedVis?: SerializedVis; table?: unknown; } diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 29d6f978bd05e..7e5cafd3ceecc 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -221,7 +221,7 @@ export class VisualizePlugin }), icon: 'visualizeApp', path: `/app/visualize#${VisualizeConstants.LANDING_PAGE_PATH}`, - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); } diff --git a/src/test_utils/public/stub_index_pattern.js b/src/test_utils/public/stub_index_pattern.js index f7b65930b683d..2bfd12dc0b1c4 100644 --- a/src/test_utils/public/stub_index_pattern.js +++ b/src/test_utils/public/stub_index_pattern.js @@ -23,6 +23,7 @@ import sinon from 'sinon'; // than just the type. Doing this as a temporary measure; it will be left behind when migrating to NP. import { IndexPattern, indexPatterns, KBN_FIELD_TYPES, FieldList } from '../../plugins/data/public'; +import { getFieldFormatsRegistry } from '../../plugins/data/public/test_utils'; import { setFieldFormats } from '../../plugins/data/public/services'; @@ -33,8 +34,6 @@ setFieldFormats({ }), }); -import { getFieldFormatsRegistry } from './stub_field_formats'; - export default function StubIndexPattern(pattern, getConfig, timeField, fields, core) { const registeredFieldFormats = getFieldFormatsRegistry(core); diff --git a/src/test_utils/tsconfig.json b/src/test_utils/tsconfig.json new file mode 100644 index 0000000000000..8b1b53b7213e1 --- /dev/null +++ b/src/test_utils/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "composite": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "." + ] +} diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js index fbacfe458d976..1666df2c83e5a 100644 --- a/test/api_integration/apis/saved_objects/import.js +++ b/test/api_integration/apis/saved_objects/import.js @@ -25,25 +25,33 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('import', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + const createError = (object, type) => ({ + ...object, + title: object.meta.title, + error: { type }, + }); + describe('with kibana index', () => { describe('with basic data existing', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); - it('should return 200', async () => { - await supertest - .post('/api/saved_objects/_import') - .query({ overwrite: true }) - .attach('file', join(__dirname, '../../fixtures/import.ndjson')) - .expect(200) - .then((resp) => { - expect(resp.body).to.eql({ - success: true, - successCount: 3, - }); - }); - }); - it('should return 415 when no file passed in', async () => { await supertest .post('/api/saved_objects/_import') @@ -67,30 +75,9 @@ export default function ({ getService }) { success: false, successCount: 0, errors: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - type: 'index-pattern', - title: 'logstash-*', - error: { - type: 'conflict', - }, - }, - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - type: 'visualization', - title: 'Count of requests', - error: { - type: 'conflict', - }, - }, - { - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - type: 'dashboard', - title: 'Requests', - error: { - type: 'conflict', - }, - }, + createError(indexPattern, 'conflict'), + createError(visualization, 'conflict'), + createError(dashboard, 'conflict'), ], }); }); @@ -99,15 +86,18 @@ export default function ({ getService }) { it('should return 200 when conflicts exist but overwrite is passed in', async () => { await supertest .post('/api/saved_objects/_import') - .query({ - overwrite: true, - }) + .query({ overwrite: true }) .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], }); }); }); @@ -130,9 +120,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -162,7 +151,7 @@ export default function ({ getService }) { JSON.stringify({ type: 'visualization', id: '1', - attributes: {}, + attributes: { title: 'My visualization' }, references: [ { name: 'ref_0', @@ -189,9 +178,10 @@ export default function ({ getService }) { { type: 'visualization', id: '1', + title: 'My visualization', + meta: { title: 'My visualization', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index aacfcd4382fac..5380e9c3d11d8 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -25,6 +25,23 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('resolve_import_errors', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + describe('without kibana index', () => { // Cleanup data that got created in import after(() => esArchiver.unload('saved_objects/basic')); @@ -72,6 +89,11 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], }); }); }); @@ -109,9 +131,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -175,9 +196,9 @@ export default function ({ getService }) { id: '1', type: 'visualization', title: 'My favorite vis', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', @@ -234,7 +255,15 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 3 }); + expect(resp.body).to.eql({ + success: true, + successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], + }); }); }); @@ -254,7 +283,11 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 1 }); + expect(resp.body).to.eql({ + success: true, + successCount: 1, + successResults: [{ ...visualization, overwrite: true }], + }); }); }); @@ -298,6 +331,13 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 1, + successResults: [ + { + type: 'visualization', + id: '1', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, + }, + ], }); }); await supertest diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 08c4327d7c0c4..c1c78570d8fe1 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -68,6 +68,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, title: 'Count of requests', + namespaceType: 'single', }, }, ], @@ -225,6 +226,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }); })); @@ -243,6 +245,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }); })); @@ -261,6 +264,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', @@ -271,6 +275,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); })); @@ -290,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts new file mode 100644 index 0000000000000..8eb4cd7ab9a43 --- /dev/null +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -0,0 +1,69 @@ +/* + * 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 { Response } from 'supertest'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('legacyEs'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab'; + const nonexistentObject = 'wigwags/foo'; + + describe('with kibana index', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200 for object that exists and inject metadata', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${existingObject}`) + .expect(200) + .then((resp: Response) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('visualization'); + expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for object that does not exist', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${nonexistentObject}`) + .expect(404)); + }); + + describe('without kibana index', () => { + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana', + ignore: [404], + }) + ); + + it('should return 404 for object that no longer exists', async () => + await supertest.get(`/api/kibana/management/saved_objects/${existingObject}`).expect(404)); + }); + }); +} diff --git a/test/api_integration/apis/saved_objects_management/index.ts b/test/api_integration/apis/saved_objects_management/index.ts index 9f13e4fc5975d..a5db29a6200f3 100644 --- a/test/api_integration/apis/saved_objects_management/index.ts +++ b/test/api_integration/apis/saved_objects_management/index.ts @@ -22,6 +22,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved objects management apis', () => { loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./relationships')); loadTestFile(require.resolve('./scroll_count')); }); diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index a1ea65645c13f..8b7837f80ee44 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -38,6 +38,7 @@ export default function ({ getService }: FtrProviderContext) { path: schema.string(), uiCapabilitiesPath: schema.string(), }), + namespaceType: schema.string(), }), }) ); @@ -89,6 +90,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, }, { @@ -104,6 +106,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -130,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -145,6 +149,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'parent', }, @@ -189,6 +194,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, { @@ -204,6 +210,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -227,6 +234,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -242,6 +250,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -286,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -301,6 +311,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }, }, ]); @@ -326,6 +337,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -369,6 +381,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -384,6 +397,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -409,6 +423,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'parent', }, diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 116d1eac90cea..6da9ebed0538a 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -165,6 +165,27 @@ export default function ({ getService, getPageObjects }) { }); }); + //add a test to sort numeric scripted field + it('should sort scripted field value in Discover', async function () { + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 10:53:14.181\n-1'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\n20'); + }); + }); + it('should filter by scripted field value in Discover', async function () { await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName); await log.debug('filter by the first value (14) in the expanded scripted field list'); @@ -252,6 +273,27 @@ export default function ({ getService, getPageObjects }) { }); }); + //add a test to sort string scripted field + it('should sort scripted field value in Discover', async function () { + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 09:48:40.594\nbad'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\ngood'); + }); + }); + it('should filter by scripted field value in Discover', async function () { await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await log.debug('filter by "bad" in the expanded scripted field list'); @@ -330,6 +372,28 @@ export default function ({ getService, getPageObjects }) { await filterBar.removeAllFilters(); }); + //add a test to sort boolean + //existing bug: https://github.com/elastic/kibana/issues/75519 hence the issue is skipped. + it.skip('should sort scripted field value in Discover', async function () { + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\ntrue'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\nfalse'); + }); + }); + it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -384,6 +448,28 @@ export default function ({ getService, getPageObjects }) { }); }); + //add a test to sort date scripted field + //https://github.com/elastic/kibana/issues/75711 + it.skip('should sort scripted field value in Discover', async function () { + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); + }); + }); + it('should filter by scripted field value in Discover', async function () { await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await log.debug('filter by "Sep 17, 2015 @ 23:00" in the expanded scripted field list'); diff --git a/test/functional/apps/visualize/_vega_chart.ts b/test/functional/apps/visualize/_vega_chart.ts index b59d9590bb62a..f599afa3afc32 100644 --- a/test/functional/apps/visualize/_vega_chart.ts +++ b/test/functional/apps/visualize/_vega_chart.ts @@ -50,7 +50,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); - describe('vega chart in visualize app', () => { + // FLAKY: https://github.com/elastic/kibana/issues/75699 + describe.skip('vega chart in visualize app', () => { before(async () => { log.debug('navigateToApp visualize'); await PageObjects.visualize.navigateToNewVisualization(); diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index bf0e0dd7f56f2..e8f8982d7163c 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -98,19 +98,19 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont } async clickOnConsole() { - await testSubjects.click('homeSynopsisLinkconsole'); + await this.clickSynopsis('console'); } async clickOnLogo() { await testSubjects.click('logo'); } - async ClickOnLogsData() { - await testSubjects.click('logsData'); + async clickOnAddData() { + await this.clickSynopsis('home_tutorial_directory'); } // clicks on Active MQ logs async clickOnLogsTutorial() { - await testSubjects.click('homeSynopsisLinkactivemq logs'); + await this.clickSynopsis('activemqlogs'); } // clicks on cloud tutorial link diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index ad82ea9b6fbc1..e165341dbd63d 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -48,7 +48,13 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv if (!overwriteAll) { log.debug(`Toggling overwriteAll`); - await testSubjects.click('importSavedObjectsOverwriteToggle'); + const radio = await testSubjects.find( + 'savedObjectsManagement-importModeControl-overwriteRadioGroup' + ); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); } else { log.debug(`Leaving overwriteAll alone`); } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 0b2a0878a465f..f2e4039f8c2bd 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -7,17 +7,15 @@ "templateVersion": "1.0.0" }, "license": "Apache-2.0", - "dependencies": { - "@elastic/eui": "27.4.1", - "react": "^16.12.0", - "react-dom": "^16.12.0" - }, "scripts": { "kbn": "node ../../../../scripts/kbn.js", "build": "rm -rf './target' && tsc" }, "devDependencies": { + "@elastic/eui": "27.4.1", "@kbn/plugin-helpers": "9.0.2", + "react": "^16.12.0", + "react-dom": "^16.12.0", "typescript": "4.0.2" } } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/tsconfig.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/tsconfig.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/app_link_test/tsconfig.json b/test/plugin_functional/plugins/app_link_test/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/app_link_test/tsconfig.json +++ b/test/plugin_functional/plugins/app_link_test/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/core_app_status/tsconfig.json +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_a/tsconfig.json b/test/plugin_functional/plugins/core_plugin_a/tsconfig.json index 1ba21f11b7de2..ccbffc34bce4a 100644 --- a/test/plugin_functional/plugins/core_plugin_a/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_a/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json b/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_b/tsconfig.json b/test/plugin_functional/plugins/core_plugin_b/tsconfig.json index 1ba21f11b7de2..ccbffc34bce4a 100644 --- a/test/plugin_functional/plugins/core_plugin_b/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_b/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json b/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_chromeless/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json index d0751f31ecc5e..0e27246a49980 100644 --- a/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_plugin_static_assets/tsconfig.json b/test/plugin_functional/plugins/core_plugin_static_assets/tsconfig.json index 4a564ee1e5578..433b041b9af3f 100644 --- a/test/plugin_functional/plugins/core_plugin_static_assets/tsconfig.json +++ b/test/plugin_functional/plugins/core_plugin_static_assets/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json index baedb5f2f621b..0aac2eb570987 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json index 4a564ee1e5578..433b041b9af3f 100644 --- a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json index d0751f31ecc5e..0e27246a49980 100644 --- a/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/index_patterns/tsconfig.json b/test/plugin_functional/plugins/index_patterns/tsconfig.json index 6f0c32ad30601..d5ccf7adddb2d 100644 --- a/test/plugin_functional/plugins/index_patterns/tsconfig.json +++ b/test/plugin_functional/plugins/index_patterns/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index bea25a8176297..0e1a0b550ca15 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -7,15 +7,13 @@ "templateVersion": "1.0.0" }, "license": "Apache-2.0", - "dependencies": { - "@elastic/eui": "27.4.1", - "react": "^16.12.0" - }, "scripts": { "kbn": "node ../../../../scripts/kbn.js", "build": "rm -rf './target' && tsc" }, "devDependencies": { + "@elastic/eui": "27.4.1", + "react": "^16.12.0", "typescript": "4.0.2" } } diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/tsconfig.json b/test/plugin_functional/plugins/kbn_sample_panel_action/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/tsconfig.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/kbn_top_nav/tsconfig.json b/test/plugin_functional/plugins/kbn_top_nav/tsconfig.json index 1ba21f11b7de2..ccbffc34bce4a 100644 --- a/test/plugin_functional/plugins/kbn_top_nav/tsconfig.json +++ b/test/plugin_functional/plugins/kbn_top_nav/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index 51344c0095566..fd1fb0aa1fca9 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -7,16 +7,14 @@ "templateVersion": "1.0.0" }, "license": "Apache-2.0", - "dependencies": { - "@elastic/eui": "27.4.1", - "react": "^16.12.0" - }, "scripts": { "kbn": "node ../../../../scripts/kbn.js", "build": "rm -rf './target' && tsc" }, "devDependencies": { + "@elastic/eui": "27.4.1", "@kbn/plugin-helpers": "9.0.2", + "react": "^16.12.0", "typescript": "4.0.2" } } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/tsconfig.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/tsconfig.json index d8096d9aab27a..c93f4c0f242e1 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/tsconfig.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true, diff --git a/test/plugin_functional/plugins/management_test_plugin/tsconfig.json b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json index 5fcaeafbb0d85..4d979fbf7f15f 100644 --- a/test/plugin_functional/plugins/management_test_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/rendering_plugin/tsconfig.json b/test/plugin_functional/plugins/rendering_plugin/tsconfig.json index 1ba21f11b7de2..ccbffc34bce4a 100644 --- a/test/plugin_functional/plugins/rendering_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/rendering_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/plugin_functional/plugins/ui_settings_plugin/tsconfig.json b/test/plugin_functional/plugins/ui_settings_plugin/tsconfig.json index 7c170405bbfc7..33b2d6c0f64ad 100644 --- a/test/plugin_functional/plugins/ui_settings_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/ui_settings_plugin/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/test/tsconfig.json b/test/tsconfig.json index 87e79b295315f..5b65db4b6dfd6 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "types": [ "node", diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000000000..9bc3448cd1a7b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,63 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + // Allows for importing from `kibana` package for the exported types. + "kibana": ["./kibana"], + "kibana/public": ["src/core/public"], + "kibana/server": ["src/core/server"], + "plugins/*": ["src/legacy/core_plugins/*/public/"], + "ui/*": [ + "src/legacy/ui/public/*" + ], + "test_utils/*": [ + "src/test_utils/public/*" + ], + "fixtures/*": ["src/fixtures/*"] + }, + // Support .tsx files and transform JSX into calls to React.createElement + "jsx": "react", + // Enables all strict type checking options. + "strict": true, + // save information about the project graph on disk + "incremental": true, + // enables "core language features" + "lib": [ + "esnext", + // includes support for browser APIs + "dom" + ], + // Node 8 should support everything output by esnext, we override this + // in webpack with loader-level compiler options + "target": "esnext", + // Use commonjs for node, overridden in webpack to keep import statements + // to maintain support for things like `await import()` + "module": "commonjs", + // Allows default imports from modules with no default export. This does not affect code emit, just type checking. + // We have to enable this option explicitly since `esModuleInterop` doesn't enable it automatically when ES2015 or + // ESNext module format is used. + "allowSyntheticDefaultImports": true, + // Emits __importStar and __importDefault helpers for runtime babel ecosystem compatibility. + "esModuleInterop": true, + // Resolve modules in the same way as Node.js. Aka make `require` works the + // same in TypeScript as it does in Node.js. + "moduleResolution": "node", + // Disallow inconsistently-cased references to the same file. + "forceConsistentCasingInFileNames": true, + // Forbid unused local variables as the rule was deprecated by ts-lint + "noUnusedLocals": true, + // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. + "downlevelIteration": true, + // import tslib helpers rather than inlining helpers for iteration or spreading, for instance + "importHelpers": true, + // adding global typings + "types": [ + "node", + "jest", + "react", + "flot", + "jest-styled-components", + "@testing-library/jest-dom" + ] + } +} diff --git a/tsconfig.browser.json b/tsconfig.browser.json index fdfc868157e0d..b0886bf3e5e28 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.json", + "extends": "./tsconfig.base.json", "compilerOptions": { "target": "es5", "module": "esnext", diff --git a/tsconfig.json b/tsconfig.json index 66906fb18bb80..ae0c42a7906bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,75 +1,23 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "baseUrl": ".", - "paths": { - // Allows for importing from `kibana` package for the exported types. - "kibana": ["./kibana"], - "kibana/public": ["src/core/public"], - "kibana/server": ["src/core/server"], - "plugins/*": ["src/legacy/core_plugins/*/public/"], - "ui/*": [ - "src/legacy/ui/public/*" - ], - "test_utils/*": [ - "src/test_utils/public/*" - ], - "fixtures/*": ["src/fixtures/*"] - }, - // Support .tsx files and transform JSX into calls to React.createElement - "jsx": "react", - // Enables all strict type checking options. - "strict": true, - // enables "core language features" - "lib": [ - "esnext", - // includes support for browser APIs - "dom" - ], - // Node 8 should support everything output by esnext, we override this - // in webpack with loader-level compiler options - "target": "esnext", - // Use commonjs for node, overridden in webpack to keep import statements - // to maintain support for things like `await import()` - "module": "commonjs", - // Allows default imports from modules with no default export. This does not affect code emit, just type checking. - // We have to enable this option explicitly since `esModuleInterop` doesn't enable it automatically when ES2015 or - // ESNext module format is used. - "allowSyntheticDefaultImports": true, - // Emits __importStar and __importDefault helpers for runtime babel ecosystem compatibility. - "esModuleInterop": true, - // Resolve modules in the same way as Node.js. Aka make `require` works the - // same in TypeScript as it does in Node.js. - "moduleResolution": "node", - // Disallow inconsistently-cased references to the same file. - "forceConsistentCasingInFileNames": true, - // Forbid unused local variables as the rule was deprecated by ts-lint - "noUnusedLocals": true, - // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. - "downlevelIteration": true, - // import tslib helpers rather than inlining helpers for iteration or spreading, for instance - "importHelpers": true, - // adding global typings - "types": [ - "node", - "jest", - "react", - "flot", - "jest-styled-components", - "@testing-library/jest-dom" - ] }, "include": [ "kibana.d.ts", - "src/**/*", - "typings/**/*", - "test_utils/**/*" + "src", + "typings", + "test_utils" ], "exclude": [ - "src/**/__fixtures__/**/*" + "src/**/__fixtures__/**/*", + "src/test_utils" // In the build we actually exclude **/public/**/* from this config so that // we can run the TSC on both this and the .browser version of this config // file, but if we did it during development IDEs would not be able to find // the tsconfig.json file for public files correctly. // "src/**/public/**/*" + ], + "references": [ + { "path": "./src/test_utils/tsconfig.json" } ] } diff --git a/tsconfig.types.json b/tsconfig.types.json index 2f5919e413e51..a02ab64206b8b 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -1,8 +1,8 @@ { - "extends": "./tsconfig", + "extends": "./tsconfig.base.json", "compilerOptions": { "declaration": true, - "declarationDir": "./target/types", + "outDir": "./target/types", "stripInternal": false, "emitDeclarationOnly": true, "declarationMap": true @@ -13,5 +13,8 @@ "src/plugins/data/server/index.ts", "src/plugins/data/public/index.ts", "typings" - ] + ], + "references": [ + { "path": "./src/test_utils" } + ] } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 00668f2ccdaa7..e5b39584a519b 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -86,6 +86,7 @@ def withFunctionalTestEnv(List additionalEnvs = [], Closure closure) { def esPort = "61${parallelId}2" def esTransportPort = "61${parallelId}3" def ingestManagementPackageRegistryPort = "61${parallelId}4" + def alertingProxyPort = "61${parallelId}5" withEnv([ "CI_GROUP=${parallelId}", @@ -98,6 +99,7 @@ def withFunctionalTestEnv(List additionalEnvs = [], Closure closure) { "TEST_ES_TRANSPORT_PORT=${esTransportPort}", "KBN_NP_PLUGINS_BUILT=true", "INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=${ingestManagementPackageRegistryPort}", + "ALERTING_PROXY_PORT=${alertingProxyPort}" ] + additionalEnvs) { closure() } diff --git a/x-pack/examples/ui_actions_enhanced_examples/tsconfig.json b/x-pack/examples/ui_actions_enhanced_examples/tsconfig.json index d508076b33199..798a9c222c5ab 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/tsconfig.json +++ b/x-pack/examples/ui_actions_enhanced_examples/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./target", "skipLibCheck": true diff --git a/x-pack/package.json b/x-pack/package.json index 96b16cd7bad88..dd8cb589dc232 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -30,6 +30,8 @@ }, "devDependencies": { "@cypress/webpack-preprocessor": "^4.1.0", + "@elastic/apm-rum-react": "^1.2.3", + "@elastic/maki": "6.3.0", "@kbn/dev-utils": "1.0.0", "@kbn/es": "1.0.0", "@kbn/expect": "1.0.0", @@ -37,6 +39,10 @@ "@kbn/storybook": "1.0.0", "@kbn/test": "1.0.0", "@kbn/utility-types": "1.0.0", + "@mapbox/geojson-rewind": "^0.4.1", + "@mapbox/mapbox-gl-draw": "^1.2.0", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@scant/router": "^0.1.0", "@storybook/addon-actions": "^5.3.19", "@storybook/addon-console": "^1.2.1", "@storybook/addon-info": "^5.3.19", @@ -47,6 +53,11 @@ "@testing-library/jest-dom": "^5.8.0", "@testing-library/react": "^9.3.2", "@testing-library/react-hooks": "^3.2.1", + "@turf/bbox": "6.0.1", + "@turf/bbox-polygon": "6.0.1", + "@turf/boolean-contains": "6.0.1", + "@turf/distance": "6.0.1", + "@turf/helpers": "6.0.1", "@types/angular": "^1.6.56", "@types/archiver": "^3.1.0", "@types/base64-js": "^1.2.5", @@ -75,6 +86,7 @@ "@types/history": "^4.7.3", "@types/hoist-non-react-statics": "^3.3.1", "@types/http-proxy": "^1.17.4", + "@types/http-proxy-agent": "^2.0.2", "@types/jest": "^25.2.3", "@types/jest-specific-snapshot": "^0.5.4", "@types/joi": "^13.4.2", @@ -124,6 +136,11 @@ "@types/xml2js": "^0.4.5", "@welldone-software/why-did-you-render": "^4.0.0", "abab": "^1.0.4", + "angular": "^1.8.0", + "angular-sanitize": "1.8.0", + "apollo-link": "^1.2.3", + "apollo-link-error": "^1.1.7", + "apollo-link-state": "^0.4.1", "autoprefixer": "^9.7.4", "axios": "^0.19.0", "babel-jest": "^25.5.1", @@ -131,14 +148,22 @@ "babel-plugin-require-context-hook": "npm:babel-plugin-require-context-hook-babel7@1.0.0", "base64-js": "^1.3.1", "base64url": "^3.0.1", + "brace": "0.11.1", + "broadcast-channel": "^3.0.3", "canvas": "^2.6.1", "chalk": "^4.1.0", "chance": "1.0.18", "cheerio": "0.22.0", "commander": "3.0.2", + "constate": "^1.3.2", + "copy-to-clipboard": "^3.0.8", "copy-webpack-plugin": "^6.0.2", + "cronstrue": "^1.51.0", "cypress": "4.11.0", "cypress-multi-reporters": "^1.2.3", + "d3": "3.5.17", + "d3-scale": "1.0.7", + "dragselect": "1.13.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-utils": "^1.13.0", @@ -146,6 +171,8 @@ "execa": "^4.0.2", "fancy-log": "^1.3.2", "fetch-mock": "^7.3.9", + "file-saver": "^1.3.8", + "formsy-react": "^1.1.5", "graphql-code-generator": "^0.18.2", "graphql-codegen-add": "^0.18.2", "graphql-codegen-introspection": "^0.18.2", @@ -155,16 +182,27 @@ "graphql-codegen-typescript-server": "^0.18.2", "gulp": "4.0.2", "hapi": "^17.5.3", + "he": "^1.2.0", + "history-extra": "^5.0.1", "hoist-non-react-statics": "^3.3.2", + "i18n-iso-countries": "^4.3.1", + "icalendar": "0.7.1", "jest": "^25.5.4", "jest-circus": "^25.5.4", "jest-cli": "^25.5.4", "jest-styled-components": "^7.0.2", + "js-search": "^1.4.3", "jsdom": "13.1.0", "jsondiffpatch": "0.4.1", + "jsts": "^1.6.2", + "kea": "^2.0.1", "loader-utils": "^1.2.3", + "lz-string": "^1.4.4", "madge": "3.4.4", + "mapbox-gl": "^1.10.0", + "mapbox-gl-draw-rectangle-mode": "^1.0.4", "marge": "^1.0.1", + "memoize-one": "^5.0.0", "mini-css-extract-plugin": "0.8.0", "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", @@ -174,14 +212,39 @@ "mutation-observer": "^1.0.3", "node-fetch": "^2.6.0", "null-loader": "^3.0.0", + "oboe": "^2.1.4", "pixelmatch": "^5.1.0", + "pluralize": "3.1.0", + "polished": "^1.9.2", "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "postcss-prefix-selector": "^1.7.2", "proxyquire": "1.8.0", + "re-resizable": "^6.1.1", + "react-apollo": "^2.1.4", + "react-beautiful-dnd": "^12.2.0", "react-docgen-typescript-loader": "^3.1.1", + "react-dropzone": "^4.2.9", + "react-fast-compare": "^2.0.4", "react-is": "^16.8.0", + "react-markdown": "^4.3.1", + "react-reverse-portal": "^1.0.4", + "react-router": "^5.2.0", + "react-shortcuts": "^2.0.0", + "react-sticky": "^6.0.3", + "react-syntax-highlighter": "^5.7.0", "react-test-renderer": "^16.12.0", + "react-tiny-virtual-list": "^2.2.0", + "react-use": "^13.27.0", + "react-virtualized": "^9.21.2", + "react-vis": "^1.8.1", + "react-visibility-sensor": "^5.1.1", + "reduce-reducers": "^1.0.4", + "redux-actions": "^2.6.5", + "redux-saga": "^1.1.3", + "redux-thunks": "^1.0.0", + "reselect": "^4.0.0", + "resize-observer-polyfill": "^1.5.0", "rxjs-marbles": "^5.0.6", "sass-loader": "^8.0.2", "sass-resources-loader": "^2.0.1", @@ -190,10 +253,18 @@ "string-replace-loader": "^2.2.0", "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", + "suricata-sid-db": "^1.0.2", + "tinycolor2": "1.4.1", "tmp": "0.1.0", + "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "ts-loader": "^6.0.4", "typescript": "4.0.2", + "typescript-fsa": "^3.0.0", + "typescript-fsa-reducers": "^1.2.1", + "unstated": "^2.1.1", + "use-resize-observer": "^6.0.0", + "venn.js": "0.2.20", "vinyl-fs": "^3.0.3", "whatwg-fetch": "^3.0.0", "xml-crypto": "^1.4.0", @@ -203,12 +274,10 @@ "@babel/core": "^7.11.1", "@babel/register": "^7.10.5", "@babel/runtime": "^7.11.2", - "@elastic/apm-rum-react": "^1.2.3", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.9.3", "@elastic/eui": "27.4.1", "@elastic/filesaver": "1.1.2", - "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", "@elastic/safer-lodash-set": "0.0.0", @@ -217,59 +286,34 @@ "@kbn/i18n": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/ui-framework": "1.0.0", - "@mapbox/geojson-rewind": "^0.4.1", - "@mapbox/mapbox-gl-draw": "^1.2.0", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", - "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", - "@turf/bbox": "6.0.1", - "@turf/bbox-polygon": "6.0.1", - "@turf/boolean-contains": "6.0.1", "@turf/circle": "6.0.1", - "@turf/distance": "6.0.1", - "@turf/helpers": "6.0.1", - "@types/http-proxy-agent": "^2.0.2", - "angular": "^1.8.0", "angular-resource": "1.8.0", - "angular-sanitize": "1.8.0", "angular-ui-ace": "0.2.3", "apollo-cache-inmemory": "1.6.2", "apollo-client": "^2.3.8", - "apollo-link": "^1.2.3", - "apollo-link-error": "^1.1.7", "apollo-link-http": "^1.5.16", "apollo-link-schema": "^1.1.0", - "apollo-link-state": "^0.4.1", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", "archiver": "3.1.1", "axios": "^0.19.0", "bluebird": "3.5.5", "boom": "^7.2.0", - "brace": "0.11.1", - "broadcast-channel": "^3.0.3", "chroma-js": "^1.4.1", "classnames": "2.2.6", "concat-stream": "1.6.2", - "constate": "^1.3.2", "content-disposition": "0.5.3", - "copy-to-clipboard": "^3.0.8", - "cronstrue": "^1.51.0", "cytoscape": "^3.10.0", - "d3": "3.5.17", "d3-array": "1.2.4", - "d3-scale": "1.0.7", "dedent": "^0.7.0", "del": "^5.1.0", - "dragselect": "1.13.1", "elasticsearch": "^16.7.0", "extract-zip": "^1.7.0", - "file-saver": "^1.3.8", "file-type": "^10.9.0", "font-awesome": "4.7.0", - "formsy-react": "^1.1.5", "fp-ts": "^2.3.1", - "get-port": "^4.2.0", + "get-port": "^5.0.0", "getos": "^3.1.0", "git-url-parse": "11.1.2", "github-markdown-css": "^2.10.0", @@ -280,11 +324,7 @@ "graphql-tools": "^3.0.2", "h2o2": "^8.1.2", "handlebars": "4.7.6", - "he": "^1.2.0", "history": "4.9.0", - "history-extra": "^5.0.1", - "i18n-iso-countries": "^4.3.1", - "icalendar": "0.7.1", "idx": "^2.5.6", "immer": "^1.5.0", "inline-style": "^2.0.0", @@ -293,18 +333,11 @@ "isbinaryfile": "4.0.2", "joi": "^13.5.2", "jquery": "^3.5.0", - "js-search": "^1.4.3", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", "jsonwebtoken": "^8.5.1", - "jsts": "^1.6.2", - "kea": "^2.0.1", "lodash": "^4.17.15", - "lz-string": "^1.4.4", - "mapbox-gl": "^1.10.0", - "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", - "memoize-one": "^5.0.0", "mime": "^2.4.4", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", @@ -315,54 +348,29 @@ "nodemailer": "^4.7.0", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", - "oboe": "^2.1.4", "oppsy": "^2.0.0", "p-retry": "^4.2.0", "papaparse": "^5.2.0", "pdfmake": "^0.1.65", - "pluralize": "3.1.0", "pngjs": "3.4.0", - "polished": "^1.9.2", "prop-types": "^15.6.0", "proper-lockfile": "^3.2.0", "puid": "1.0.7", "puppeteer-core": "^1.19.0", "query-string": "5.1.1", "raw-loader": "3.1.0", - "re-resizable": "^6.1.1", "react": "^16.12.0", - "react-apollo": "^2.1.4", - "react-beautiful-dnd": "^12.2.0", "react-datetime": "^2.14.0", "react-dom": "^16.12.0", - "react-dropzone": "^4.2.9", - "react-fast-compare": "^2.0.4", - "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", "react-portal": "^3.2.0", "react-redux": "^7.2.0", - "react-reverse-portal": "^1.0.4", - "react-router": "^5.2.0", "react-router-dom": "^5.2.0", - "react-shortcuts": "^2.0.0", - "react-sticky": "^6.0.3", - "react-syntax-highlighter": "^5.7.0", - "react-tiny-virtual-list": "^2.2.0", - "react-use": "^13.27.0", - "react-virtualized": "^9.21.2", - "react-vis": "^1.8.1", - "react-visibility-sensor": "^5.1.1", "recompose": "^0.26.0", - "reduce-reducers": "^1.0.4", "redux": "^4.0.5", - "redux-actions": "^2.6.5", "redux-observable": "^1.2.0", - "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", - "redux-thunks": "^1.0.0", "request": "^2.88.0", - "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", "rison-node": "0.3.1", "rxjs": "^6.5.5", "semver": "5.7.0", @@ -371,18 +379,10 @@ "stats-lite": "^2.2.0", "style-it": "^2.1.3", "styled-components": "^5.1.0", - "suricata-sid-db": "^1.0.2", - "tinycolor2": "1.4.1", "tinymath": "1.2.1", - "topojson-client": "3.0.0", "tslib": "^2.0.0", - "typescript-fsa": "^3.0.0", - "typescript-fsa-reducers": "^1.2.1", "ui-select": "0.19.8", - "unstated": "^2.1.1", - "use-resize-observer": "^6.0.0", "uuid": "3.3.2", - "venn.js": "0.2.20", "vscode-languageserver": "^5.2.1", "webpack": "^4.41.5", "wellknown": "^0.5.0", diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 3470ede0f15c7..868f6f180cc91 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -19,7 +19,7 @@ Table of Contents - [Usage](#usage) - [Kibana Actions Configuration](#kibana-actions-configuration) - [Configuration Options](#configuration-options) - - [Whitelisting Built-in Action Types](#whitelisting-built-in-action-types) + - [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-hosts-allow-list) - [Configuration Utilities](#configuration-utilities) - [Action types](#action-types) - [Methods](#methods) @@ -106,15 +106,15 @@ Built-In-Actions are configured using the _xpack.actions_ namespoace under _kiba | Namespaced Key | Description | Type | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | | _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | -| _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | +| _xpack.actions._**allowedHosts** | Which _hostnames_ are allowed for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | | _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | | _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | -#### Whitelisting Built-in Action Types +#### Adding Built-in Action Types to allowedHosts -It is worth noting that the **whitelistedHosts** configuation applies to built-in action types (such as Slack, or PagerDuty) as well. +It is worth noting that the **allowedHosts** configuation applies to built-in action types (such as Slack, or PagerDuty) as well. -Uniquely, the _PagerDuty Action Type_ has been configured to support the service's Events API (at _https://events.pagerduty.com/v2/enqueue_, which you can read about [here](https://v2.developer.pagerduty.com/docs/events-api-v2)) as a default, but this too, must be included in the whitelist before the PagerDuty action can be used. +Uniquely, the _PagerDuty Action Type_ has been configured to support the service's Events API (at _https://events.pagerduty.com/v2/enqueue_, which you can read about [here](https://v2.developer.pagerduty.com/docs/events-api-v2)) as a default, but this too, must be included in the allowedHosts before the PagerDuty action can be used. ### Configuration Utilities @@ -122,11 +122,11 @@ This module provides a Utilities for interacting with the configuration. | Method | Arguments | Description | Return Type | | ------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | -| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all URI's are allowed (using an "\*") then it will always return `true`. | Boolean | +| isHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will always return `true`. | Boolean | | isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | -| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | -| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | +| ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed | +| ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . | | ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | ## Action types @@ -666,7 +666,7 @@ Currently actions are licensed as "basic" if the action only interacts with the Currently actions that are licensed as "basic" **MUST** be implemented in the actions plugin, other actions can be implemented in any other plugin that pre-reqs the actions plugin. If the new action is generic across the stack, it probably belongs in the actions plugin, but if your action is very specific to a plugin/solution, it might be easiest to implement it in the plugin/solution. Keep in mind that if Kibana is run without the plugin being enabled, any actions defined in that plugin will not run, nor will those actions be available via APIs or UI. -Actions that take URLs or hostnames should check that those values are whitelisted. The whitelisting utilities are currently internal to the actions plugin, and so such actions will need to be implemented in the actions plugin. Longer-term, we will expose these utilities so they can be used by alerts implemented in other plugins; see [issue #64659](https://github.com/elastic/kibana/issues/64659). +Actions that take URLs or hostnames should check that those values are allowed. The allowed host list utilities are currently internal to the actions plugin, and so such actions will need to be implemented in the actions plugin. Longer-term, we will expose these utilities so they can be used by alerts implemented in other plugins; see [issue #64659](https://github.com/elastic/kibana/issues/64659). ## documentation diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 16a5a59882dd6..573fb0e1be580 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -295,7 +295,7 @@ describe('create()', () => { const localConfigUtils = getActionsConfigurationUtilities({ enabled: true, enabledActionTypes: ['some-not-ignored-action-type'], - whitelistedHosts: ['*'], + allowedHosts: ['*'], }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index addd35ae4f5f3..67ab495fc9678 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -8,11 +8,11 @@ import { ActionsConfigurationUtilities } from './actions_config'; const createActionsConfigMock = () => { const mocked: jest.Mocked = { - isWhitelistedHostname: jest.fn().mockReturnValue(true), - isWhitelistedUri: jest.fn().mockReturnValue(true), + isHostnameAllowed: jest.fn().mockReturnValue(true), + isUriAllowed: jest.fn().mockReturnValue(true), isActionTypeEnabled: jest.fn().mockReturnValue(true), - ensureWhitelistedHostname: jest.fn().mockReturnValue({}), - ensureWhitelistedUri: jest.fn().mockReturnValue({}), + ensureHostnameAllowed: jest.fn().mockReturnValue({}), + ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), }; return mocked; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 7d9d431d1c1be..56c58054ca799 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -7,163 +7,151 @@ import { ActionsConfigType } from './types'; import { getActionsConfigurationUtilities, - WhitelistedHosts, + AllowedHosts, EnabledActionTypes, } from './actions_config'; const DefaultActionsConfig: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: [], }; -describe('ensureWhitelistedUri', () => { +describe('ensureUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [WhitelistedHosts.Any], + allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).ensureWhitelistedUri( - 'https://github.com/elastic/kibana' - ) + getActionsConfigurationUtilities(config).ensureUriAllowed('https://github.com/elastic/kibana') ).toBeUndefined(); }); - test('throws when the hostname in the requested uri is not in the whitelist', () => { + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfigType = DefaultActionsConfig; expect(() => - getActionsConfigurationUtilities(config).ensureWhitelistedUri( - 'https://github.com/elastic/kibana' - ) + getActionsConfigurationUtilities(config).ensureUriAllowed('https://github.com/elastic/kibana') ).toThrowErrorMatchingInlineSnapshot( - `"target url \\"https://github.com/elastic/kibana\\" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts"` + `"target url \\"https://github.com/elastic/kibana\\" is not added to the Kibana config xpack.actions.allowedHosts"` ); }); test('throws when the uri cannot be parsed as a valid URI', () => { const config: ActionsConfigType = DefaultActionsConfig; expect(() => - getActionsConfigurationUtilities(config).ensureWhitelistedUri('github.com/elastic') + getActionsConfigurationUtilities(config).ensureUriAllowed('github.com/elastic') ).toThrowErrorMatchingInlineSnapshot( - `"target url \\"github.com/elastic\\" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts"` + `"target url \\"github.com/elastic\\" is not added to the Kibana config xpack.actions.allowedHosts"` ); }); - test('returns true when the hostname in the requested uri is in the whitelist', () => { + test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: ['github.com'], + allowedHosts: ['github.com'], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).ensureWhitelistedUri( - 'https://github.com/elastic/kibana' - ) + getActionsConfigurationUtilities(config).ensureUriAllowed('https://github.com/elastic/kibana') ).toBeUndefined(); }); }); -describe('ensureWhitelistedHostname', () => { +describe('ensureHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [WhitelistedHosts.Any], + allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + getActionsConfigurationUtilities(config).ensureHostnameAllowed('github.com') ).toBeUndefined(); }); - test('throws when the hostname in the requested uri is not in the whitelist', () => { + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfigType = DefaultActionsConfig; expect(() => - getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + getActionsConfigurationUtilities(config).ensureHostnameAllowed('github.com') ).toThrowErrorMatchingInlineSnapshot( - `"target hostname \\"github.com\\" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts"` + `"target hostname \\"github.com\\" is not added to the Kibana config xpack.actions.allowedHosts"` ); }); - test('returns true when the hostname in the requested uri is in the whitelist', () => { + test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: ['github.com'], + allowedHosts: ['github.com'], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + getActionsConfigurationUtilities(config).ensureHostnameAllowed('github.com') ).toBeUndefined(); }); }); -describe('isWhitelistedUri', () => { +describe('isUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [WhitelistedHosts.Any], + allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + getActionsConfigurationUtilities(config).isUriAllowed('https://github.com/elastic/kibana') ).toEqual(true); }); - test('throws when the hostname in the requested uri is not in the whitelist', () => { + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfigType = DefaultActionsConfig; expect( - getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + getActionsConfigurationUtilities(config).isUriAllowed('https://github.com/elastic/kibana') ).toEqual(false); }); test('throws when the uri cannot be parsed as a valid URI', () => { const config: ActionsConfigType = DefaultActionsConfig; - expect(getActionsConfigurationUtilities(config).isWhitelistedUri('github.com/elastic')).toEqual( + expect(getActionsConfigurationUtilities(config).isUriAllowed('github.com/elastic')).toEqual( false ); }); - test('returns true when the hostname in the requested uri is in the whitelist', () => { + test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: ['github.com'], + allowedHosts: ['github.com'], enabledActionTypes: [], }; expect( - getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + getActionsConfigurationUtilities(config).isUriAllowed('https://github.com/elastic/kibana') ).toEqual(true); }); }); -describe('isWhitelistedHostname', () => { +describe('isHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [WhitelistedHosts.Any], + allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; - expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( - true - ); + expect(getActionsConfigurationUtilities(config).isHostnameAllowed('github.com')).toEqual(true); }); - test('throws when the hostname in the requested uri is not in the whitelist', () => { + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfigType = DefaultActionsConfig; - expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( - false - ); + expect(getActionsConfigurationUtilities(config).isHostnameAllowed('github.com')).toEqual(false); }); - test('returns true when the hostname in the requested uri is in the whitelist', () => { + test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: ['github.com'], + allowedHosts: ['github.com'], enabledActionTypes: [], }; - expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( - true - ); + expect(getActionsConfigurationUtilities(config).isHostnameAllowed('github.com')).toEqual(true); }); }); @@ -171,7 +159,7 @@ describe('isActionTypeEnabled', () => { test('returns true when "any" actionTypes are allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], }; expect(getActionsConfigurationUtilities(config).isActionTypeEnabled('foo')).toEqual(true); @@ -180,7 +168,7 @@ describe('isActionTypeEnabled', () => { test('returns false when no actionType is allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: [], }; expect(getActionsConfigurationUtilities(config).isActionTypeEnabled('foo')).toEqual(false); @@ -189,7 +177,7 @@ describe('isActionTypeEnabled', () => { test('returns false when the actionType is not in the enabled list', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['foo'], }; expect(getActionsConfigurationUtilities(config).isActionTypeEnabled('bar')).toEqual(false); @@ -198,7 +186,7 @@ describe('isActionTypeEnabled', () => { test('returns true when the actionType is in the enabled list', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], }; expect(getActionsConfigurationUtilities(config).isActionTypeEnabled('foo')).toEqual(true); @@ -209,7 +197,7 @@ describe('ensureActionTypeEnabled', () => { test('does not throw when any actionType is allowed', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], }; expect(getActionsConfigurationUtilities(config).ensureActionTypeEnabled('foo')).toBeUndefined(); @@ -227,7 +215,7 @@ describe('ensureActionTypeEnabled', () => { test('throws when actionType is not enabled', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['ignore'], }; expect(() => @@ -240,7 +228,7 @@ describe('ensureActionTypeEnabled', () => { test('does not throw when actionType is enabled', () => { const config: ActionsConfigType = { enabled: false, - whitelistedHosts: [], + allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], }; expect(getActionsConfigurationUtilities(config).ensureActionTypeEnabled('foo')).toBeUndefined(); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index b15fe5b4007c5..609e4969222f9 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -13,7 +13,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { ActionsConfigType } from './types'; import { ActionTypeDisabledError } from './lib'; -export enum WhitelistedHosts { +export enum AllowedHosts { Any = '*', } @@ -21,24 +21,24 @@ export enum EnabledActionTypes { Any = '*', } -enum WhitelistingField { +enum AllowListingField { url = 'url', hostname = 'hostname', } export interface ActionsConfigurationUtilities { - isWhitelistedHostname: (hostname: string) => boolean; - isWhitelistedUri: (uri: string) => boolean; + isHostnameAllowed: (hostname: string) => boolean; + isUriAllowed: (uri: string) => boolean; isActionTypeEnabled: (actionType: string) => boolean; - ensureWhitelistedHostname: (hostname: string) => void; - ensureWhitelistedUri: (uri: string) => void; + ensureHostnameAllowed: (hostname: string) => void; + ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; } -function whitelistingErrorMessage(field: WhitelistingField, value: string) { - return i18n.translate('xpack.actions.urlWhitelistConfigurationError', { +function allowListErrorMessage(field: AllowListingField, value: string) { + return i18n.translate('xpack.actions.urlAllowedHostsConfigurationError', { defaultMessage: - 'target {field} "{value}" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts', + 'target {field} "{value}" is not added to the Kibana config xpack.actions.allowedHosts', values: { value, field, @@ -56,18 +56,18 @@ function disabledActionTypeErrorMessage(actionType: string) { }); } -function isWhitelisted({ whitelistedHosts }: ActionsConfigType, hostname: string): boolean { - const whitelisted = new Set(whitelistedHosts); - if (whitelisted.has(WhitelistedHosts.Any)) return true; - if (whitelisted.has(hostname)) return true; +function isAllowed({ allowedHosts }: ActionsConfigType, hostname: string): boolean { + const allowed = new Set(allowedHosts); + if (allowed.has(AllowedHosts.Any)) return true; + if (allowed.has(hostname)) return true; return false; } -function isWhitelistedHostnameInUri(config: ActionsConfigType, uri: string): boolean { +function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean { return pipe( tryCatch(() => new URL(uri)), map((url) => url.hostname), - mapNullable((hostname) => isWhitelisted(config, hostname)), + mapNullable((hostname) => isAllowed(config, hostname)), getOrElse(() => false) ); } @@ -85,21 +85,21 @@ function isActionTypeEnabledInConfig( export function getActionsConfigurationUtilities( config: ActionsConfigType ): ActionsConfigurationUtilities { - const isWhitelistedHostname = curry(isWhitelisted)(config); - const isWhitelistedUri = curry(isWhitelistedHostnameInUri)(config); + const isHostnameAllowed = curry(isAllowed)(config); + const isUriAllowed = curry(isHostnameAllowedInUri)(config); const isActionTypeEnabled = curry(isActionTypeEnabledInConfig)(config); return { - isWhitelistedHostname, - isWhitelistedUri, + isHostnameAllowed, + isUriAllowed, isActionTypeEnabled, - ensureWhitelistedUri(uri: string) { - if (!isWhitelistedUri(uri)) { - throw new Error(whitelistingErrorMessage(WhitelistingField.url, uri)); + ensureUriAllowed(uri: string) { + if (!isUriAllowed(uri)) { + throw new Error(allowListErrorMessage(AllowListingField.url, uri)); } }, - ensureWhitelistedHostname(hostname: string) { - if (!isWhitelistedHostname(hostname)) { - throw new Error(whitelistingErrorMessage(WhitelistingField.hostname, hostname)); + ensureHostnameAllowed(hostname: string) { + if (!isHostnameAllowed(hostname)) { + throw new Error(allowListErrorMessage(AllowListingField.hostname, hostname)); } }, ensureActionTypeEnabled(actionType: string) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts index 80e301e5be082..08e8a8be6a3e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts @@ -23,9 +23,9 @@ export const validateCommonConfig = ( return i18n.MAPPING_EMPTY; } - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.WHITE_LISTED_ERROR(whitelistError.message); + configurationUtilities.ensureUriAllowed(configObject.apiUrl); + } catch (allowListError) { + return i18n.WHITE_LISTED_ERROR(allowListError.message); } }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 62f369816d714..7147483998d98 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -121,56 +121,56 @@ describe('config validation', () => { const NODEMAILER_AOL_SERVICE = 'AOL'; const NODEMAILER_AOL_SERVICE_HOST = 'smtp.aol.com'; - test('config validation handles email host whitelisting', () => { + test('config validation handles email host in allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - isWhitelistedHostname: (hostname) => hostname === NODEMAILER_AOL_SERVICE_HOST, + isHostnameAllowed: (hostname) => hostname === NODEMAILER_AOL_SERVICE_HOST, }, }); const baseConfig = { from: 'bob@example.com', }; - const whitelistedConfig1 = { + const allowedHosts1 = { ...baseConfig, service: NODEMAILER_AOL_SERVICE, }; - const whitelistedConfig2 = { + const allowedHosts2 = { ...baseConfig, host: NODEMAILER_AOL_SERVICE_HOST, port: 42, }; - const notWhitelistedConfig1 = { + const notAllowedHosts1 = { ...baseConfig, service: 'gmail', }; - const notWhitelistedConfig2 = { + const notAllowedHosts2 = { ...baseConfig, host: 'smtp.gmail.com', port: 42, }; - const validatedConfig1 = validateConfig(actionType, whitelistedConfig1); - expect(validatedConfig1.service).toEqual(whitelistedConfig1.service); - expect(validatedConfig1.from).toEqual(whitelistedConfig1.from); + const validatedConfig1 = validateConfig(actionType, allowedHosts1); + expect(validatedConfig1.service).toEqual(allowedHosts1.service); + expect(validatedConfig1.from).toEqual(allowedHosts1.from); - const validatedConfig2 = validateConfig(actionType, whitelistedConfig2); - expect(validatedConfig2.host).toEqual(whitelistedConfig2.host); - expect(validatedConfig2.port).toEqual(whitelistedConfig2.port); - expect(validatedConfig2.from).toEqual(whitelistedConfig2.from); + const validatedConfig2 = validateConfig(actionType, allowedHosts2); + expect(validatedConfig2.host).toEqual(allowedHosts2.host); + expect(validatedConfig2.port).toEqual(allowedHosts2.port); + expect(validatedConfig2.from).toEqual(allowedHosts2.from); expect(() => { - validateConfig(actionType, notWhitelistedConfig1); + validateConfig(actionType, notAllowedHosts1); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [service] value 'gmail' resolves to host 'smtp.gmail.com' which is not in the whitelistedHosts configuration"` + `"error validating action type config: [service] value 'gmail' resolves to host 'smtp.gmail.com' which is not in the allowedHosts configuration"` ); expect(() => { - validateConfig(actionType, notWhitelistedConfig2); + validateConfig(actionType, notAllowedHosts2); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [host] value 'smtp.gmail.com' is not in the whitelistedHosts configuration"` + `"error validating action type config: [host] value 'smtp.gmail.com' is not in the allowedHosts configuration"` ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index e9dc4eea5dcfc..6fd2d694b06f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -66,16 +66,16 @@ function validateConfig( return '[port] is required if [service] is not provided'; } - if (!configurationUtilities.isWhitelistedHostname(config.host)) { - return `[host] value '${config.host}' is not in the whitelistedHosts configuration`; + if (!configurationUtilities.isHostnameAllowed(config.host)) { + return `[host] value '${config.host}' is not in the allowedHosts configuration`; } } else { const host = getServiceNameHost(config.service); if (host == null) { return `[service] value '${config.service}' is not valid`; } - if (!configurationUtilities.isWhitelistedHostname(host)) { - return `[service] value '${config.service}' resolves to host '${host}' which is not in the whitelistedHosts configuration`; + if (!configurationUtilities.isHostnameAllowed(host)) { + return `[service] value '${config.service}' resolves to host '${host}' which is not in the allowedHosts configuration`; } } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index c379c05ee88e3..772e7df416979 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -64,12 +64,12 @@ describe('validateConfig()', () => { ); }); - test('should validate and pass when the pagerduty url is whitelisted', () => { + test('should validate and pass when the pagerduty url is added to allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureWhitelistedUri: (url) => { + ensureUriAllowed: (url) => { expect(url).toEqual('https://events.pagerduty.com/v2/enqueue'); }, }, @@ -80,13 +80,13 @@ describe('validateConfig()', () => { ).toEqual({ apiUrl: 'https://events.pagerduty.com/v2/enqueue' }); }); - test('config validation returns an error if the specified URL isnt whitelisted', () => { + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureWhitelistedUri: (_) => { - throw new Error(`target url is not whitelisted`); + ensureUriAllowed: (_) => { + throw new Error(`target url is not added to allowedHosts`); }, }, }); @@ -94,7 +94,7 @@ describe('validateConfig()', () => { expect(() => { validateConfig(actionType, { apiUrl: 'https://events.pagerduty.com/v2/enqueue' }); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring pagerduty action: target url is not whitelisted"` + `"error validating action type config: error configuring pagerduty action: target url is not added to allowedHosts"` ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index c0edfc530e738..640a38d77b6c2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -135,12 +135,12 @@ function valdiateActionTypeConfig( configObject: ActionTypeConfigType ) { try { - configurationUtilities.ensureWhitelistedUri(getPagerDutyApiUrl(configObject)); - } catch (whitelistError) { + configurationUtilities.ensureUriAllowed(getPagerDutyApiUrl(configObject)); + } catch (allowListError) { return i18n.translate('xpack.actions.builtin.pagerduty.pagerdutyConfigurationError', { defaultMessage: 'error configuring pagerduty action: {message}', values: { - message: whitelistError.message, + message: allowListError.message, }, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index cf1c26e6462a2..9b1da4b4007c6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -78,8 +78,6 @@ export const createExternalService = ( const createIncident = async ({ incident }: ExternalServiceParams) => { try { - logger.warn(`incident error : ${JSON.stringify(proxySettings)}`); - logger.warn(`incident error : ${url}`); const res = await request({ axios: axiosInstance, url: `${incidentUrl}`, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts index 65bbe9aea8119..6eec3b8d63b86 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts @@ -26,9 +26,9 @@ export const validateCommonConfig = ( } try { - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.WHITE_LISTED_ERROR(whitelistError.message); + configurationUtilities.ensureUriAllowed(configObject.apiUrl); + } catch (allowListError) { + return i18n.WHITE_LISTED_ERROR(allowListError.message); } }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 812657138152c..b15d92cecba62 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -96,12 +96,12 @@ describe('validateActionTypeSecrets()', () => { ); }); - test('should validate and pass when the slack webhookUrl is whitelisted', () => { + test('should validate and pass when the slack webhookUrl is added to allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureWhitelistedUri: (url) => { + ensureUriAllowed: (url) => { expect(url).toEqual('https://api.slack.com/'); }, }, @@ -112,13 +112,13 @@ describe('validateActionTypeSecrets()', () => { }); }); - test('config validation returns an error if the specified URL isnt whitelisted', () => { + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureWhitelistedHostname: () => { - throw new Error(`target hostname is not whitelisted`); + ensureHostnameAllowed: () => { + throw new Error(`target hostname is not added to allowedHosts`); }, }, }); @@ -126,7 +126,7 @@ describe('validateActionTypeSecrets()', () => { expect(() => { validateSecrets(actionType, { webhookUrl: 'https://api.slack.com/' }); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: error configuring slack action: target hostname is not whitelisted"` + `"error validating action type secrets: error configuring slack action: target hostname is not added to allowedHosts"` ); }); }); @@ -209,7 +209,7 @@ describe('execute()', () => { rejectUnauthorizedCertificates: false, }, }); - expect(mockedLogger.info).toHaveBeenCalledWith( + expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' ); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 293328c809435..1605cd4b69f5e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -91,12 +91,12 @@ function valdiateActionTypeConfig( } try { - configurationUtilities.ensureWhitelistedHostname(url.hostname); - } catch (whitelistError) { + configurationUtilities.ensureHostnameAllowed(url.hostname); + } catch (allowListError) { return i18n.translate('xpack.actions.builtin.slack.slackConfigurationError', { defaultMessage: 'error configuring slack action: {message}', values: { - message: whitelistError.message, + message: allowListError.message, }, }); } @@ -119,7 +119,7 @@ async function slackExecutor( let proxyAgent: HttpsProxyAgent | HttpProxyAgent | undefined; if (execOptions.proxySettings) { proxyAgent = getProxyAgent(execOptions.proxySettings, logger); - logger.info(`IncomingWebhook was called with proxyUrl ${execOptions.proxySettings.proxyUrl}`); + logger.debug(`IncomingWebhook was called with proxyUrl ${execOptions.proxySettings.proxyUrl}`); } try { @@ -130,8 +130,6 @@ async function slackExecutor( }); result = await webhook.send(message); } catch (err) { - logger.error(`error on ${actionId} slack event: ${err.message}`); - if (err.original == null || err.original.response == null) { return serviceErrorResult(actionId, err.message); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index ea9f30452918c..23ce527d4ae0d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -176,7 +176,7 @@ describe('config validation', () => { `); }); - test('config validation passes when kibana config whitelists the url', () => { + test('config validation passes when kibana config url does not present in allowedHosts', () => { // any for testing // eslint-disable-next-line @typescript-eslint/no-explicit-any const config: Record = { @@ -192,13 +192,13 @@ describe('config validation', () => { }); }); - test('config validation returns an error if the specified URL isnt whitelisted', () => { + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { actionType = getActionType({ logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureWhitelistedUri: (_) => { - throw new Error(`target url is not whitelisted`); + ensureUriAllowed: (_) => { + throw new Error(`target url is not present in allowedHosts`); }, }, }); @@ -215,7 +215,7 @@ describe('config validation', () => { expect(() => { validateConfig(actionType, config); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring webhook action: target url is not whitelisted"` + `"error validating action type config: error configuring webhook action: target url is not present in allowedHosts"` ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index d9a005565498d..d0ec31721685e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -111,12 +111,12 @@ function validateActionTypeConfig( } try { - configurationUtilities.ensureWhitelistedUri(url.toString()); - } catch (whitelistError) { + configurationUtilities.ensureUriAllowed(url.toString()); + } catch (allowListError) { return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationError', { defaultMessage: 'error configuring webhook action: {message}', values: { - message: whitelistError.message, + message: allowListError.message, }, }); } diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 795fbbf84145b..ac815a425a2b7 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -10,15 +10,15 @@ describe('config validation', () => { const config: Record = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { + "allowedHosts": Array [ + "*", + ], "enabled": true, "enabledActionTypes": Array [ "*", ], "preconfigured": Object {}, "rejectUnauthorizedCertificates": true, - "whitelistedHosts": Array [ - "*", - ], } `); }); @@ -38,6 +38,9 @@ describe('config validation', () => { }; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { + "allowedHosts": Array [ + "*", + ], "enabled": true, "enabledActionTypes": Array [ "*", @@ -53,9 +56,6 @@ describe('config validation', () => { }, }, "rejectUnauthorizedCertificates": false, - "whitelistedHosts": Array [ - "*", - ], } `); }); diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index ba80915ebe243..087a08f572c65 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -5,7 +5,7 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { WhitelistedHosts, EnabledActionTypes } from './actions_config'; +import { AllowedHosts, EnabledActionTypes } from './actions_config'; const preconfiguredActionSchema = schema.object({ name: schema.string({ minLength: 1 }), @@ -16,16 +16,16 @@ const preconfiguredActionSchema = schema.object({ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), - whitelistedHosts: schema.arrayOf( - schema.oneOf([schema.string({ hostname: true }), schema.literal(WhitelistedHosts.Any)]), + allowedHosts: schema.arrayOf( + schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]), { - defaultValue: [WhitelistedHosts.Any], + defaultValue: [AllowedHosts.Any], } ), enabledActionTypes: schema.arrayOf( schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]), { - defaultValue: [WhitelistedHosts.Any], + defaultValue: [AllowedHosts.Any], } ), preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, { diff --git a/x-pack/plugins/actions/server/lib/errors/preconfigured_action_disabled_modification.ts b/x-pack/plugins/actions/server/lib/errors/preconfigured_action_disabled_modification.ts index 884353e132b9c..20790d7cf5128 100644 --- a/x-pack/plugins/actions/server/lib/errors/preconfigured_action_disabled_modification.ts +++ b/x-pack/plugins/actions/server/lib/errors/preconfigured_action_disabled_modification.ts @@ -9,7 +9,8 @@ import { ErrorThatHandlesItsOwnResponse } from './types'; export type PreconfiguredActionDisabledFrom = 'update' | 'delete'; -export class PreconfiguredActionDisabledModificationError extends Error +export class PreconfiguredActionDisabledModificationError + extends Error implements ErrorThatHandlesItsOwnResponse { public readonly disabledFrom: PreconfiguredActionDisabledFrom; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 341a17889923f..4fdf9f2523568 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -32,7 +32,7 @@ describe('Actions Plugin', () => { context = coreMock.createPluginInitializerContext({ enabled: true, enabledActionTypes: ['*'], - whitelistedHosts: ['*'], + allowedHosts: ['*'], preconfigured: {}, rejectUnauthorizedCertificates: true, }); @@ -186,7 +186,7 @@ describe('Actions Plugin', () => { const context = coreMock.createPluginInitializerContext({ enabled: true, enabledActionTypes: ['*'], - whitelistedHosts: ['*'], + allowedHosts: ['*'], preconfigured: { preconfiguredServerLog: { actionTypeId: '.server-log', diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index bf7bd709a4a88..0a7d6bf01b7ec 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -47,7 +47,7 @@ export interface ActionsPlugin { export interface ActionsConfigType { enabled: boolean; - whitelistedHosts: string[]; + allowedHosts: string[]; enabledActionTypes: string[]; } @@ -100,8 +100,8 @@ interface ValidatorType { } export interface ActionValidationService { - isWhitelistedHostname(hostname: string): boolean; - isWhitelistedUri(uri: string): boolean; + isHostnameAllowed(hostname: string): boolean; + isUriAllowed(uri: string): boolean; } export interface ActionType< diff --git a/x-pack/plugins/apm/e2e/package.json b/x-pack/plugins/apm/e2e/package.json index 5101e64235c62..56204b5fb9f9d 100644 --- a/x-pack/plugins/apm/e2e/package.json +++ b/x-pack/plugins/apm/e2e/package.json @@ -10,8 +10,6 @@ "dependencies": { "@cypress/snapshot": "^2.1.3", "@cypress/webpack-preprocessor": "^5.4.1", - "@types/cypress-cucumber-preprocessor": "^1.14.1", - "@types/node": "^14.0.14", "axios": "^0.19.2", "cypress": "^4.9.0", "cypress-cucumber-preprocessor": "^2.5.2", @@ -19,9 +17,13 @@ "p-limit": "^3.0.1", "p-retry": "^4.2.0", "ts-loader": "^7.0.5", - "typescript": "3.9.6", + "typescript": "4.0.2", "wait-on": "^5.0.1", "webpack": "^4.43.0", "yargs": "^15.4.0" + }, + "devDependencies": { + "@types/cypress-cucumber-preprocessor": "^1.14.1", + "@types/node": "^14.0.14" } } diff --git a/x-pack/plugins/apm/e2e/tsconfig.json b/x-pack/plugins/apm/e2e/tsconfig.json index a7091a20186b2..c4587349c7ad7 100644 --- a/x-pack/plugins/apm/e2e/tsconfig.json +++ b/x-pack/plugins/apm/e2e/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "exclude": ["tmp"], "include": ["./**/*"], "compilerOptions": { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index ee89abf59ee23..6cc3bb2a2c7e1 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -6,7 +6,6 @@ "features", "apmOss", "data", - "home", "licensing", "triggers_actions_ui" ], @@ -18,7 +17,8 @@ "alerts", "observability", "security", - "ml" + "ml", + "home" ], "server": true, "ui": true, @@ -32,6 +32,7 @@ "requiredBundles": [ "kibanaReact", "kibanaUtils", - "observability" + "observability", + "home" ] } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx index 934a985dd735a..9211504a2dffe 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -30,6 +30,7 @@ import { history } from '../../../../utils/history'; import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; import { ChartWrapper } from '../ChartWrapper'; import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; interface Props { data?: Array>; @@ -37,7 +38,15 @@ interface Props { } export function PageViewsChart({ data, loading }: Props) { - const formatter = timeFormatter(niceTimeFormatByDay(2)); + const { urlParams } = useUrlParams(); + + const { start, end } = urlParams; + const diffInDays = moment(new Date(end as string)).diff( + moment(new Date(start as string)), + 'day' + ); + + const formatter = timeFormatter(niceTimeFormatByDay(diffInDays > 1 ? 2 : 1)); const onBrushEnd: BrushEndListener = ({ x }) => { if (!x) { @@ -91,18 +100,21 @@ export function PageViewsChart({ data, loading }: Props) { } showLegend onBrushEnd={onBrushEnd} + xDomain={{ + min: new Date(start as string).valueOf(), + max: new Date(end as string).valueOf(), + }} /> numeral(d).format('0.0 a')} + tickFormat={(d) => numeral(d).format('0a')} /> @@ -54,7 +56,7 @@ export function ClientMetrics() { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index c7545ff9a2764..53f2d5ae238c5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -102,7 +102,7 @@ export function PageLoadDistribution() { /> - + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index 96d1b529c52f9..66eeaf433d2a1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -16,9 +16,6 @@ export const I18LABELS = { pageViews: i18n.translate('xpack.apm.rum.dashboard.pageViews', { defaultMessage: 'Page views', }), - dateTime: i18n.translate('xpack.apm.rum.dashboard.dateTime.label', { - defaultMessage: 'Date / Time', - }), percPageLoaded: i18n.translate('xpack.apm.rum.dashboard.pagesLoaded.label', { defaultMessage: 'Pages loaded', }), diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx index a4d42bcf51d01..e1b5ffcd0e0f5 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx @@ -40,7 +40,6 @@ const Container = styled.div` padding-bottom: ${px(units.plus)}; margin-right: ${(props) => px(props.timelineMargins.right)}; margin-left: ${(props) => px(props.timelineMargins.left)}; - border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; background-color: ${({ isSelected, theme }) => isSelected ? theme.eui.euiColorLightestShade : 'initial'}; cursor: pointer; @@ -191,7 +190,10 @@ export function WaterfallItem({ type={item.docType} timelineMargins={timelineMargins} isSelected={isSelected} - onClick={onClick} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + onClick(); + }} > ; + onToggleEntryTransaction?: ( + nextState: EuiAccordionProps['forceState'] + ) => void; + timelineMargins: Margins; + onClickWaterfallItem: (item: IWaterfallItem) => void; +} + +const StyledAccordion = styled(EuiAccordion).withConfig({ + shouldForwardProp: (prop) => + !['childrenCount', 'marginLeftLevel', 'hasError'].includes(prop), +})< + EuiAccordionProps & { + childrenCount: number; + marginLeftLevel: number; + hasError: boolean; + } +>` + .euiAccordion { + border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + } + .euiIEFlexWrapFix { + width: 100%; + height: 48px; + } + .euiAccordion__childWrapper { + transition: none; + } + + .euiAccordion__padding--l { + padding-top: 0; + padding-bottom: 0; + } + + .euiAccordion__iconWrapper { + display: flex; + position: relative; + &:after { + content: ${(props) => `'${props.childrenCount}'`}; + position: absolute; + left: 20px; + top: -1px; + z-index: 1; + font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; + } + } + + ${(props) => { + const borderLeft = props.hasError + ? `2px solid ${props.theme.eui.euiColorDanger};` + : `1px solid ${props.theme.eui.euiColorLightShade};`; + return `.button_${props.id} { + margin-left: ${props.marginLeftLevel}px; + border-left: ${borderLeft} + &:hover { + background-color: ${props.theme.eui.euiColorLightestShade}; + } + }`; + // + }} +`; + +const WaterfallItemContainer = styled.div` + position: absolute; + width: 100%; + left: 0; +`; + +export function AccordionWaterfall(props: AccordionWaterfallProps) { + const [isOpen, setIsOpen] = useState(props.isOpen); + + const { + item, + level, + serviceColors, + duration, + childrenByParentId, + waterfallItemId, + location, + errorsPerTransaction, + timelineMargins, + onClickWaterfallItem, + } = props; + + const nextLevel = level + 1; + + const errorCount = + item.docType === 'transaction' + ? errorsPerTransaction[item.doc.transaction.id] + : 0; + + const children = childrenByParentId[item.id] || []; + + // To indent the items creating the parent/child tree + const marginLeftLevel = 8 * level; + + return ( + 0} + marginLeftLevel={marginLeftLevel} + childrenCount={children.length} + buttonContent={ + + { + onClickWaterfallItem(item); + }} + /> + + } + arrowDisplay={isEmpty(children) ? 'none' : 'left'} + initialIsOpen={true} + forceState={isOpen ? 'open' : 'closed'} + onToggle={() => setIsOpen((isCurrentOpen) => !isCurrentOpen)} + > + {children.map((child) => ( + + ))} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index 1fd0ec761b1ae..7daf1b798749b 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -4,21 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiCallOut } from '@elastic/eui'; +import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Location } from 'history'; -import React from 'react'; +import React, { useState } from 'react'; // @ts-ignore import { StickyContainer } from 'react-sticky'; import styled from 'styled-components'; import { px } from '../../../../../../style/variables'; import { history } from '../../../../../../utils/history'; import { Timeline } from '../../../../../shared/charts/Timeline'; +import { HeightRetainer } from '../../../../../shared/HeightRetainer'; import { fromQuery, toQuery } from '../../../../../shared/Links/url_helpers'; import { getAgentMarks } from '../Marks/get_agent_marks'; import { getErrorMarks } from '../Marks/get_error_marks'; +import { AccordionWaterfall } from './accordion_waterfall'; import { WaterfallFlyout } from './WaterfallFlyout'; -import { WaterfallItem } from './WaterfallItem'; import { IWaterfall, IWaterfallItem, @@ -32,7 +33,7 @@ const Container = styled.div` const TIMELINE_MARGINS = { top: 40, - left: 50, + left: 100, right: 50, bottom: 0, }; @@ -58,6 +59,7 @@ const WaterfallItemsContainer = styled.div<{ paddingTop: number; }>` padding-top: ${(props) => px(props.paddingTop)}; + border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorMediumShade}; `; interface Props { @@ -66,72 +68,91 @@ interface Props { location: Location; exceedsMax: boolean; } - export function Waterfall({ waterfall, exceedsMax, waterfallItemId, location, }: Props) { + const [isAccordionOpen, setIsAccordionOpen] = useState(true); const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found const waterfallHeight = itemContainerHeight * waterfall.items.length; const { serviceColors, duration } = waterfall; - const agentMarks = getAgentMarks(waterfall.entryTransaction); + const agentMarks = getAgentMarks(waterfall.entryWaterfallTransaction?.doc); const errorMarks = getErrorMarks(waterfall.errorItems, serviceColors); - function renderWaterfallItem(item: IWaterfallItem) { - const errorCount = - item.docType === 'transaction' - ? waterfall.errorsPerTransaction[item.doc.transaction.id] - : 0; - + function renderItems( + childrenByParentId: Record + ) { + const { entryWaterfallTransaction } = waterfall; + if (!entryWaterfallTransaction) { + return null; + } return ( - toggleFlyout({ item, location })} + onClickWaterfallItem={(item: IWaterfallItem) => + toggleFlyout({ item, location }) + } /> ); } return ( - - {exceedsMax && ( - - )} - - - - {waterfall.items.map(renderWaterfallItem)} - - + + + {exceedsMax && ( + + )} + +
+ { + setIsAccordionOpen((isOpen) => !isOpen); + }} + /> + +
+ + {renderItems(waterfall.childrenByParentId)} + +
- -
+ +
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap index c9b29e8692f44..204c5e9ae6da2 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap @@ -2,27 +2,734 @@ exports[`waterfall_helpers getWaterfall should return full waterfall 1`] = ` Object { + "childrenByParentId": Object { + "mySpanIdA": Array [ + Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdA", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 481, + }, + "id": "mySpanIdB", + "name": "SELECT FROM products", + }, + "timestamp": Object { + "us": 1549324795825633, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 481, + "id": "mySpanIdB", + "offset": 41627, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId2", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", + }, + "timestamp": Object { + "us": 1549324795824504, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + "parentId": "mySpanIdD", + "skew": 0, + }, + "parentId": "myTransactionId2", + "skew": 0, + }, + "parentId": "mySpanIdA", + "skew": 0, + }, + Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdA", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 532, + }, + "id": "mySpanIdC", + "name": "SELECT FROM product", + }, + "timestamp": Object { + "us": 1549324795827905, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 532, + "id": "mySpanIdC", + "offset": 43899, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId2", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", + }, + "timestamp": Object { + "us": 1549324795824504, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + "parentId": "mySpanIdD", + "skew": 0, + }, + "parentId": "myTransactionId2", + "skew": 0, + }, + "parentId": "mySpanIdA", + "skew": 0, + }, + ], + "mySpanIdD": Array [ + Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + "parentId": "mySpanIdD", + "skew": 0, + }, + ], + "myTransactionId1": Array [ + Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + ], + "myTransactionId2": Array [ + Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId2", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", + }, + "timestamp": Object { + "us": 1549324795824504, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + "parentId": "mySpanIdD", + "skew": 0, + }, + "parentId": "myTransactionId2", + "skew": 0, + }, + ], + "root": Array [ + Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + ], + }, "duration": 49660, - "entryTransaction": Object { - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-node", - }, - "timestamp": Object { - "us": 1549324795784006, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "duration": Object { - "us": 49660, + "entryWaterfallTransaction": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", }, - "id": "myTransactionId1", - "name": "GET /api", }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, }, "errorItems": Array [ Object { @@ -42,13 +749,115 @@ Object { "id": "myTransactionId1", }, "processor": Object { - "event": "error", + "event": "error", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795810000, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "error", + "duration": 0, + "id": "error1", + "offset": 25994, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + ], + "errorsCount": 1, + "errorsPerTransaction": Object { + "myTransactionId1": 2, + "myTransactionId2": 3, + }, + "items": Array [ + Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", }, "service": Object { - "name": "opbeans-ruby", + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", }, "timestamp": Object { - "us": 1549324795810000, + "us": 1549324795785760, }, "trace": Object { "id": "myTraceId", @@ -57,10 +866,10 @@ Object { "id": "myTransactionId1", }, }, - "docType": "error", - "duration": 0, - "id": "error1", - "offset": 25994, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, "parent": Object { "doc": Object { "processor": Object { @@ -94,387 +903,744 @@ Object { "parentId": "myTransactionId1", "skew": 0, }, - ], - "errorsCount": 1, - "errorsPerTransaction": Object { - "myTransactionId1": 2, - "myTransactionId2": 3, - }, - "items": Array [ Object { "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, "processor": Object { "event": "transaction", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "timestamp": Object { - "us": 1549324795784006, + "us": 1549324795823304, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { "duration": Object { - "us": 49660, + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, }, + "docType": "transaction", + "duration": 49660, "id": "myTransactionId1", - "name": "GET /api", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + "parentId": "mySpanIdD", + "skew": 0, + }, + Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId2", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", + }, + "timestamp": Object { + "us": 1549324795824504, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, }, + "parentId": "mySpanIdD", + "skew": 0, }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, + "parentId": "myTransactionId2", "skew": 0, }, Object { "doc": Object { "parent": Object { - "id": "myTransactionId1", + "id": "mySpanIdA", }, "processor": Object { "event": "span", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "span": Object { "duration": Object { - "us": 47557, + "us": 481, }, - "id": "mySpanIdD", - "name": "GET opbeans-ruby:3000/api/products", + "id": "mySpanIdB", + "name": "SELECT FROM products", }, "timestamp": Object { - "us": 1549324795785760, + "us": 1549324795825633, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "id": "myTransactionId1", + "id": "myTransactionId2", }, }, "docType": "span", - "duration": 47557, - "id": "mySpanIdD", - "offset": 1754, + "duration": 481, + "id": "mySpanIdB", + "offset": 41627, "parent": Object { "doc": Object { + "parent": Object { + "id": "myTransactionId2", + }, "processor": Object { - "event": "transaction", + "event": "span", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", + }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", }, "timestamp": Object { - "us": 1549324795784006, + "us": 1549324795824504, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "duration": Object { - "us": 49660, + "id": "myTransactionId2", + }, + }, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", }, - "id": "myTransactionId1", - "name": "GET /api", + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, }, + "parentId": "mySpanIdD", + "skew": 0, }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, + "parentId": "myTransactionId2", "skew": 0, }, - "parentId": "myTransactionId1", + "parentId": "mySpanIdA", "skew": 0, }, Object { "doc": Object { "parent": Object { - "id": "mySpanIdD", + "id": "mySpanIdA", }, "processor": Object { - "event": "transaction", + "event": "span", }, "service": Object { "name": "opbeans-ruby", }, + "span": Object { + "duration": Object { + "us": 532, + }, + "id": "mySpanIdC", + "name": "SELECT FROM product", + }, "timestamp": Object { - "us": 1549324795823304, + "us": 1549324795827905, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "duration": Object { - "us": 8634, - }, "id": "myTransactionId2", - "marks": Object { - "agent": Object { - "domComplete": 383, - "domInteractive": 382, - "timeToFirstByte": 14, - }, - }, - "name": "Api::ProductsController#index", }, }, - "docType": "transaction", - "duration": 8634, - "id": "myTransactionId2", - "offset": 39298, + "docType": "span", + "duration": 532, + "id": "mySpanIdC", + "offset": 43899, "parent": Object { "doc": Object { "parent": Object { - "id": "myTransactionId1", + "id": "myTransactionId2", }, "processor": Object { "event": "span", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "span": Object { "duration": Object { - "us": 47557, + "us": 6161, }, - "id": "mySpanIdD", - "name": "GET opbeans-ruby:3000/api/products", + "id": "mySpanIdA", + "name": "Api::ProductsController#index", }, "timestamp": Object { - "us": 1549324795785760, + "us": 1549324795824504, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "id": "myTransactionId1", + "id": "myTransactionId2", }, }, "docType": "span", - "duration": 47557, - "id": "mySpanIdD", - "offset": 1754, + "duration": 6161, + "id": "mySpanIdA", + "offset": 40498, "parent": Object { "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, "processor": Object { "event": "transaction", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "timestamp": Object { - "us": 1549324795784006, + "us": 1549324795823304, }, "trace": Object { "id": "myTraceId", }, - "transaction": Object { - "duration": Object { - "us": 49660, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", + }, + }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 39298, + "parent": Object { + "doc": Object { + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "span", + }, + "service": Object { + "name": "opbeans-node", + }, + "span": Object { + "duration": Object { + "us": 47557, + }, + "id": "mySpanIdD", + "name": "GET opbeans-ruby:3000/api/products", + }, + "timestamp": Object { + "us": 1549324795785760, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "span", + "duration": 47557, + "id": "mySpanIdD", + "offset": 1754, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, }, + "docType": "transaction", + "duration": 49660, "id": "myTransactionId1", - "name": "GET /api", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, }, + "parentId": "myTransactionId1", + "skew": 0, }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, + "parentId": "mySpanIdD", "skew": 0, }, - "parentId": "myTransactionId1", + "parentId": "myTransactionId2", "skew": 0, }, - "parentId": "mySpanIdD", + "parentId": "mySpanIdA", "skew": 0, }, - Object { - "doc": Object { - "parent": Object { - "id": "myTransactionId2", - }, - "processor": Object { - "event": "span", - }, - "service": Object { - "name": "opbeans-ruby", - }, - "span": Object { - "duration": Object { - "us": 6161, - }, - "id": "mySpanIdA", - "name": "Api::ProductsController#index", - }, - "timestamp": Object { - "us": 1549324795824504, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "id": "myTransactionId2", - }, + ], + "rootTransaction": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, }, - "docType": "span", - "duration": 6161, - "id": "mySpanIdA", - "offset": 40498, - "parent": Object { + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "serviceColors": Object { + "opbeans-node": "#6092c0", + "opbeans-ruby": "#54b399", + }, +} +`; + +exports[`waterfall_helpers getWaterfall should return partial waterfall 1`] = ` +Object { + "childrenByParentId": Object { + "mySpanIdA": Array [ + Object { "doc": Object { "parent": Object { - "id": "mySpanIdD", + "id": "mySpanIdA", }, "processor": Object { - "event": "transaction", + "event": "span", }, "service": Object { "name": "opbeans-ruby", }, + "span": Object { + "duration": Object { + "us": 481, + }, + "id": "mySpanIdB", + "name": "SELECT FROM products", + }, "timestamp": Object { - "us": 1549324795823304, + "us": 1549324795825633, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "duration": Object { - "us": 8634, - }, "id": "myTransactionId2", - "marks": Object { - "agent": Object { - "domComplete": 383, - "domInteractive": 382, - "timeToFirstByte": 14, - }, - }, - "name": "Api::ProductsController#index", }, }, - "docType": "transaction", - "duration": 8634, - "id": "myTransactionId2", - "offset": 39298, + "docType": "span", + "duration": 481, + "id": "mySpanIdB", + "offset": 2329, "parent": Object { "doc": Object { "parent": Object { - "id": "myTransactionId1", + "id": "myTransactionId2", }, "processor": Object { "event": "span", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "span": Object { "duration": Object { - "us": 47557, + "us": 6161, }, - "id": "mySpanIdD", - "name": "GET opbeans-ruby:3000/api/products", + "id": "mySpanIdA", + "name": "Api::ProductsController#index", }, "timestamp": Object { - "us": 1549324795785760, + "us": 1549324795824504, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "id": "myTransactionId1", + "id": "myTransactionId2", }, }, "docType": "span", - "duration": 47557, - "id": "mySpanIdD", - "offset": 1754, + "duration": 6161, + "id": "mySpanIdA", + "offset": 1200, "parent": Object { "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, "processor": Object { "event": "transaction", }, "service": Object { - "name": "opbeans-node", + "name": "opbeans-ruby", }, "timestamp": Object { - "us": 1549324795784006, + "us": 1549324795823304, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { "duration": Object { - "us": 49660, + "us": 8634, }, - "id": "myTransactionId1", - "name": "GET /api", + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", }, }, "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", + "duration": 8634, + "id": "myTransactionId2", "offset": 0, "parent": undefined, - "parentId": undefined, + "parentId": "mySpanIdD", "skew": 0, }, - "parentId": "myTransactionId1", + "parentId": "myTransactionId2", "skew": 0, }, - "parentId": "mySpanIdD", + "parentId": "mySpanIdA", "skew": 0, }, - "parentId": "myTransactionId2", - "skew": 0, - }, - Object { - "doc": Object { - "parent": Object { - "id": "mySpanIdA", - }, - "processor": Object { - "event": "span", - }, - "service": Object { - "name": "opbeans-ruby", - }, - "span": Object { - "duration": Object { - "us": 481, - }, - "id": "mySpanIdB", - "name": "SELECT FROM products", - }, - "timestamp": Object { - "us": 1549324795825633, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "id": "myTransactionId2", - }, - }, - "docType": "span", - "duration": 481, - "id": "mySpanIdB", - "offset": 41627, - "parent": Object { + Object { "doc": Object { "parent": Object { - "id": "myTransactionId2", + "id": "mySpanIdA", }, "processor": Object { "event": "span", @@ -484,13 +1650,13 @@ Object { }, "span": Object { "duration": Object { - "us": 6161, + "us": 532, }, - "id": "mySpanIdA", - "name": "Api::ProductsController#index", + "id": "mySpanIdC", + "name": "SELECT FROM product", }, "timestamp": Object { - "us": 1549324795824504, + "us": 1549324795827905, }, "trace": Object { "id": "myTraceId", @@ -500,152 +1666,132 @@ Object { }, }, "docType": "span", - "duration": 6161, - "id": "mySpanIdA", - "offset": 40498, + "duration": 532, + "id": "mySpanIdC", + "offset": 4601, "parent": Object { "doc": Object { "parent": Object { - "id": "mySpanIdD", + "id": "myTransactionId2", }, "processor": Object { - "event": "transaction", + "event": "span", }, "service": Object { "name": "opbeans-ruby", }, + "span": Object { + "duration": Object { + "us": 6161, + }, + "id": "mySpanIdA", + "name": "Api::ProductsController#index", + }, "timestamp": Object { - "us": 1549324795823304, + "us": 1549324795824504, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "duration": Object { - "us": 8634, - }, "id": "myTransactionId2", - "marks": Object { - "agent": Object { - "domComplete": 383, - "domInteractive": 382, - "timeToFirstByte": 14, - }, - }, - "name": "Api::ProductsController#index", }, }, - "docType": "transaction", - "duration": 8634, - "id": "myTransactionId2", - "offset": 39298, + "docType": "span", + "duration": 6161, + "id": "mySpanIdA", + "offset": 1200, "parent": Object { "doc": Object { "parent": Object { - "id": "myTransactionId1", + "id": "mySpanIdD", }, "processor": Object { - "event": "span", + "event": "transaction", }, "service": Object { - "name": "opbeans-node", - }, - "span": Object { - "duration": Object { - "us": 47557, - }, - "id": "mySpanIdD", - "name": "GET opbeans-ruby:3000/api/products", + "name": "opbeans-ruby", }, "timestamp": Object { - "us": 1549324795785760, + "us": 1549324795823304, }, "trace": Object { "id": "myTraceId", }, "transaction": Object { - "id": "myTransactionId1", - }, - }, - "docType": "span", - "duration": 47557, - "id": "mySpanIdD", - "offset": 1754, - "parent": Object { - "doc": Object { - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-node", - }, - "timestamp": Object { - "us": 1549324795784006, - }, - "trace": Object { - "id": "myTraceId", + "duration": Object { + "us": 8634, }, - "transaction": Object { - "duration": Object { - "us": 49660, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, }, - "id": "myTransactionId1", - "name": "GET /api", }, + "name": "Api::ProductsController#index", }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, - "skew": 0, }, - "parentId": "myTransactionId1", + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 0, + "parent": undefined, + "parentId": "mySpanIdD", "skew": 0, }, - "parentId": "mySpanIdD", + "parentId": "myTransactionId2", "skew": 0, }, - "parentId": "myTransactionId2", + "parentId": "mySpanIdA", "skew": 0, }, - "parentId": "mySpanIdA", - "skew": 0, - }, - Object { - "doc": Object { - "parent": Object { - "id": "mySpanIdA", - }, - "processor": Object { - "event": "span", - }, - "service": Object { - "name": "opbeans-ruby", - }, - "span": Object { - "duration": Object { - "us": 532, + ], + "mySpanIdD": Array [ + Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", + }, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, + }, + "name": "Api::ProductsController#index", }, - "id": "mySpanIdC", - "name": "SELECT FROM product", - }, - "timestamp": Object { - "us": 1549324795827905, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "id": "myTransactionId2", }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 0, + "parent": undefined, + "parentId": "mySpanIdD", + "skew": 0, }, - "docType": "span", - "duration": 532, - "id": "mySpanIdC", - "offset": 43899, - "parent": Object { + ], + "myTransactionId2": Array [ + Object { "doc": Object { "parent": Object { "id": "myTransactionId2", @@ -676,7 +1822,7 @@ Object { "docType": "span", "duration": 6161, "id": "mySpanIdA", - "offset": 40498, + "offset": 1200, "parent": Object { "doc": Object { "parent": Object { @@ -712,143 +1858,56 @@ Object { "docType": "transaction", "duration": 8634, "id": "myTransactionId2", - "offset": 39298, - "parent": Object { - "doc": Object { - "parent": Object { - "id": "myTransactionId1", - }, - "processor": Object { - "event": "span", - }, - "service": Object { - "name": "opbeans-node", - }, - "span": Object { - "duration": Object { - "us": 47557, - }, - "id": "mySpanIdD", - "name": "GET opbeans-ruby:3000/api/products", - }, - "timestamp": Object { - "us": 1549324795785760, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "id": "myTransactionId1", - }, - }, - "docType": "span", - "duration": 47557, - "id": "mySpanIdD", - "offset": 1754, - "parent": Object { - "doc": Object { - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-node", - }, - "timestamp": Object { - "us": 1549324795784006, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "duration": Object { - "us": 49660, - }, - "id": "myTransactionId1", - "name": "GET /api", - }, - }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, - "skew": 0, - }, - "parentId": "myTransactionId1", - "skew": 0, - }, + "offset": 0, + "parent": undefined, "parentId": "mySpanIdD", "skew": 0, }, "parentId": "myTransactionId2", "skew": 0, }, - "parentId": "mySpanIdA", - "skew": 0, - }, - ], - "rootTransaction": Object { - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-node", - }, - "timestamp": Object { - "us": 1549324795784006, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "duration": Object { - "us": 49660, - }, - "id": "myTransactionId1", - "name": "GET /api", - }, - }, - "serviceColors": Object { - "opbeans-node": "#6092c0", - "opbeans-ruby": "#54b399", + ], }, -} -`; - -exports[`waterfall_helpers getWaterfall should return partial waterfall 1`] = ` -Object { "duration": 8634, - "entryTransaction": Object { - "parent": Object { - "id": "mySpanIdD", - }, - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-ruby", - }, - "timestamp": Object { - "us": 1549324795823304, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "duration": Object { - "us": 8634, + "entryWaterfallTransaction": Object { + "doc": Object { + "parent": Object { + "id": "mySpanIdD", }, - "id": "myTransactionId2", - "marks": Object { - "agent": Object { - "domComplete": 383, - "domInteractive": 382, - "timeToFirstByte": 14, + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795823304, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 8634, + }, + "id": "myTransactionId2", + "marks": Object { + "agent": Object { + "domComplete": 383, + "domInteractive": 382, + "timeToFirstByte": 14, + }, }, + "name": "Api::ProductsController#index", }, - "name": "Api::ProductsController#index", }, + "docType": "transaction", + "duration": 8634, + "id": "myTransactionId2", + "offset": 0, + "parent": undefined, + "parentId": "mySpanIdD", + "skew": 0, }, "errorItems": Array [], "errorsCount": 0, diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts index 441a51bcba646..44e5e09e506af 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts @@ -28,7 +28,7 @@ interface IWaterfallGroup { const ROOT_ID = 'root'; export interface IWaterfall { - entryTransaction?: Transaction; + entryWaterfallTransaction?: IWaterfallTransaction; rootTransaction?: Transaction; /** @@ -36,6 +36,7 @@ export interface IWaterfall { */ duration: number; items: IWaterfallItem[]; + childrenByParentId: Record; errorsPerTransaction: TraceAPIResponse['errorsPerTransaction']; errorsCount: number; serviceColors: IServiceColors; @@ -329,6 +330,7 @@ export function getWaterfall( errorsCount: sum(Object.values(errorsPerTransaction)), serviceColors: {}, errorItems: [], + childrenByParentId: {}, }; } @@ -357,10 +359,8 @@ export function getWaterfall( const duration = getWaterfallDuration(items); const serviceColors = getServiceColors(items); - const entryTransaction = entryWaterfallTransaction?.doc; - return { - entryTransaction, + entryWaterfallTransaction, rootTransaction, duration, items, @@ -368,5 +368,6 @@ export function getWaterfall( errorsCount: errorItems.length, serviceColors, errorItems, + childrenByParentId: getChildrenGroupedByParentId(items), }; } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx index 6fd139b470ce1..501ca6d33d5af 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx @@ -8,8 +8,8 @@ import { Location } from 'history'; import React from 'react'; import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; import { ServiceLegends } from './ServiceLegends'; -import { Waterfall } from './Waterfall'; import { IWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; +import { Waterfall } from './Waterfall'; interface Props { urlParams: IUrlParams; diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx index 12676b7c15f1c..392bd90ffbabc 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx @@ -64,8 +64,8 @@ export function WaterfallWithSummmary({ }); }; - const { entryTransaction } = waterfall; - if (!entryTransaction) { + const { entryWaterfallTransaction } = waterfall; + if (!entryWaterfallTransaction) { const content = isLoading ? ( ) : ( @@ -84,6 +84,8 @@ export function WaterfallWithSummmary({ return {content}; } + const entryTransaction = entryWaterfallTransaction.doc; + return ( diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx similarity index 70% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx index 9c514e429c374..28030dd509835 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx @@ -4,25 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { + fireEvent, + getByText, queryByLabelText, render, - getByText, - getByDisplayValue, - queryByDisplayValue, - fireEvent, } from '@testing-library/react'; import { omit } from 'lodash'; -import { history } from '../../../../utils/history'; -import { TransactionOverview } from '..'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import * as useServiceTransactionTypesHook from '../../../../hooks/useServiceTransactionTypes'; -import * as useFetcherHook from '../../../../hooks/useFetcher'; -import { fromQuery } from '../../../shared/Links/url_helpers'; +import React from 'react'; import { Router } from 'react-router-dom'; -import { UrlParamsProvider } from '../../../../context/UrlParamsContext'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { TransactionOverview } from './'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { UrlParamsProvider } from '../../../context/UrlParamsContext'; +import { IUrlParams } from '../../../context/UrlParamsContext/types'; +import * as useFetcherHook from '../../../hooks/useFetcher'; +import * as useServiceTransactionTypesHook from '../../../hooks/useServiceTransactionTypes'; +import { history } from '../../../utils/history'; +import { fromQuery } from '../../shared/Links/url_helpers'; jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); @@ -85,7 +83,7 @@ describe('TransactionOverview', () => { const FILTER_BY_TYPE_LABEL = 'Transaction type'; describe('when transactionType is selected and multiple transaction types are given', () => { - it('should render dropdown with transaction types', () => { + it('renders a radio group with transaction types', () => { const { container } = setup({ serviceTransactionTypes: ['firstType', 'secondType'], urlParams: { @@ -94,9 +92,8 @@ describe('TransactionOverview', () => { }, }); - // secondType is selected in the dropdown - expect(queryByDisplayValue(container, 'secondType')).not.toBeNull(); - expect(queryByDisplayValue(container, 'firstType')).toBeNull(); + expect(getByText(container, 'firstType')).toBeInTheDocument(); + expect(getByText(container, 'secondType')).toBeInTheDocument(); expect(getByText(container, 'firstType')).not.toBeNull(); }); @@ -110,22 +107,19 @@ describe('TransactionOverview', () => { }, }); - expect(queryByDisplayValue(container, 'firstType')).toBeNull(); + expect(history.location.search).toEqual('?transactionType=secondType'); + expect(getByText(container, 'firstType')).toBeInTheDocument(); + expect(getByText(container, 'secondType')).toBeInTheDocument(); - fireEvent.change(getByDisplayValue(container, 'secondType'), { - target: { value: 'firstType' }, - }); + fireEvent.click(getByText(container, 'firstType')); expect(history.push).toHaveBeenCalled(); - - getByDisplayValue(container, 'firstType'); - - expect(queryByDisplayValue(container, 'firstType')).not.toBeNull(); + expect(history.location.search).toEqual('?transactionType=firstType'); }); }); describe('when a transaction type is selected, and there are no other transaction types', () => { - it('should not render a dropdown with transaction types', () => { + it('does not render a radio group with transaction types', () => { const { container } = setup({ serviceTransactionTypes: ['firstType'], urlParams: { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index d9bd3e59d281f..f6eb131a8a733 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -121,7 +121,7 @@ export function TransactionOverview() { - + diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx index 2434d898389d8..7a63b9e767fe7 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx @@ -20,6 +20,7 @@ import { wait } from '@testing-library/react'; import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const mockHistoryPush = jest.spyOn(history, 'push'); +const mockHistoryReplace = jest.spyOn(history, 'replace'); const mockRefreshTimeRange = jest.fn(); function MockUrlParamsProvider({ params = {}, @@ -69,8 +70,8 @@ describe('DatePicker', () => { it('sets default query params in the URL', () => { mountDatePicker(); - expect(mockHistoryPush).toHaveBeenCalledTimes(1); - expect(mockHistoryPush).toHaveBeenCalledWith( + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now', }) @@ -82,8 +83,8 @@ describe('DatePicker', () => { rangeTo: 'now', refreshInterval: 5000, }); - expect(mockHistoryPush).toHaveBeenCalledTimes(1); - expect(mockHistoryPush).toHaveBeenCalledWith( + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', }) @@ -97,18 +98,19 @@ describe('DatePicker', () => { refreshPaused: false, refreshInterval: 5000, }); - expect(mockHistoryPush).toHaveBeenCalledTimes(0); + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); }); it('updates the URL when the date range changes', () => { const datePicker = mountDatePicker(); + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); datePicker.find(EuiSuperDatePicker).props().onTimeChange({ start: 'updated-start', end: 'updated-end', isInvalid: false, isQuickSelection: true, }); - expect(mockHistoryPush).toHaveBeenCalledTimes(2); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ search: 'rangeFrom=updated-start&rangeTo=updated-end', diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index 403a8cad854cd..35b9525733e99 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -89,7 +89,14 @@ export function DatePicker() { ...timePickerURLParams, }; if (!isEqual(nextParams, timePickerURLParams)) { - updateUrl(nextParams); + // When the default parameters are not availbale in the url, replace it adding the necessary parameters. + history.replace({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + ...nextParams, + }), + }); } return ( diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx index 2090a92bf0de4..ed8d865d2d288 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx @@ -5,8 +5,9 @@ */ import React from 'react'; -import { EuiFlexGrid, EuiFlexItem, EuiBadge, EuiIcon } from '@elastic/eui'; +import { EuiFlexGrid, EuiFlexItem, EuiBadge } from '@elastic/eui'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { unit, px, truncate } from '../../../../style/variables'; const BadgeText = styled.div` @@ -20,22 +21,31 @@ interface Props { onRemove: (val: string) => void; } +const removeFilterLabel = i18n.translate( + 'xpack.apm.uifilter.badge.removeFilter', + { defaultMessage: 'Remove filter' } +); + function FilterBadgeList({ onRemove, value }: Props) { return ( {value.map((val) => ( - + {val} + ))} diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx index c13439a3c5928..48ebc2add0053 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx @@ -164,7 +164,7 @@ function Filter({ name, title, options, onChange, value, showCount }: Props) { }} value={value} /> - + ) : null} diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx index afd2d023d16ba..54a08e9d45af5 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx @@ -9,7 +9,7 @@ import { EuiTitle, EuiHorizontalRule, EuiSpacer, - EuiSelect, + EuiRadioGroup, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useUrlParams } from '../../../../hooks/useUrlParams'; @@ -26,8 +26,8 @@ function TransactionTypeFilter({ transactionTypes }: Props) { } = useUrlParams(); const options = transactionTypes.map((type) => ({ - text: type, - value: type, + id: type, + label: type, })); return ( @@ -42,16 +42,15 @@ function TransactionTypeFilter({ transactionTypes }: Props) { - { + idSelected={transactionType} + onChange={(selectedTransactionType) => { const newLocation = { ...history.location, search: fromQuery({ ...toQuery(history.location.search), - transactionType: event.target.value, + transactionType: selectedTransactionType, }), }; history.push(newLocation); diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js index d489970b55f29..e49899da85e0d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js @@ -71,11 +71,29 @@ class StaticPlot extends PureComponent { const data = serie.data.map((value) => { return 'y' in value && isValidCoordinateValue(value.y) ? value - : { - ...value, - y: undefined, - }; + : { ...value, y: undefined }; }); + + // make sure individual markers are displayed in cases + // where there are gaps + + const markersForGaps = serie.data.map((value, index) => { + const prevHasData = getNull(serie.data[index - 1] ?? {}); + const nextHasData = getNull(serie.data[index + 1] ?? {}); + const thisHasData = getNull(value); + + const isGap = !prevHasData && !nextHasData && thisHasData; + + if (!isGap) { + return { + ...value, + y: undefined, + }; + } + + return value; + }); + return [ , + , ]; } @@ -132,7 +165,7 @@ class StaticPlot extends PureComponent { curve={'curveMonotoneX'} data={serie.data} color={serie.color} - size={0.5} + size={1} /> ); default: diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap index 8101b01a83b08..f413610ebd984 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap @@ -460,7 +460,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -477,7 +477,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -494,7 +494,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -511,7 +511,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -528,7 +528,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -545,7 +545,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -562,7 +562,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -579,7 +579,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -596,7 +596,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -613,7 +613,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -630,7 +630,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -647,7 +647,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -664,7 +664,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -681,7 +681,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -698,7 +698,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -715,7 +715,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -732,7 +732,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -749,7 +749,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -766,7 +766,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -783,7 +783,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -800,7 +800,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -817,7 +817,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -834,7 +834,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -851,7 +851,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -868,7 +868,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -885,7 +885,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -902,7 +902,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -919,7 +919,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -936,7 +936,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -953,7 +953,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -970,7 +970,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -1013,7 +1013,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1030,7 +1030,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1047,7 +1047,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1064,7 +1064,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1081,7 +1081,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1098,7 +1098,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1115,7 +1115,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1132,7 +1132,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1149,7 +1149,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1166,7 +1166,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1183,7 +1183,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1200,7 +1200,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1217,7 +1217,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1234,7 +1234,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1251,7 +1251,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1268,7 +1268,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1285,7 +1285,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1302,7 +1302,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1319,7 +1319,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1336,7 +1336,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1353,7 +1353,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1370,7 +1370,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1387,7 +1387,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1404,7 +1404,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1421,7 +1421,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1438,7 +1438,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1455,7 +1455,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1472,7 +1472,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1489,7 +1489,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1506,7 +1506,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1523,7 +1523,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1566,7 +1566,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1583,7 +1583,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1600,7 +1600,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1617,7 +1617,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1634,7 +1634,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1651,7 +1651,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1668,7 +1668,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1685,7 +1685,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1702,7 +1702,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1719,7 +1719,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1736,7 +1736,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1753,7 +1753,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1770,7 +1770,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1787,7 +1787,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1804,7 +1804,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1821,7 +1821,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1838,7 +1838,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1855,7 +1855,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1872,7 +1872,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1889,7 +1889,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1906,7 +1906,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1923,7 +1923,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1940,7 +1940,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1957,7 +1957,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1974,7 +1974,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1991,7 +1991,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2008,7 +2008,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2025,7 +2025,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2042,7 +2042,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2059,7 +2059,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2076,7 +2076,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -3396,7 +3396,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3413,7 +3413,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3430,7 +3430,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3447,7 +3447,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3464,7 +3464,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3481,7 +3481,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3498,7 +3498,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3515,7 +3515,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3532,7 +3532,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3549,7 +3549,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3566,7 +3566,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3583,7 +3583,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3600,7 +3600,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3617,7 +3617,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3634,7 +3634,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3651,7 +3651,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3668,7 +3668,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3685,7 +3685,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3702,7 +3702,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3719,7 +3719,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3736,7 +3736,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3753,7 +3753,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3770,7 +3770,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3787,7 +3787,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3804,7 +3804,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3821,7 +3821,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3838,7 +3838,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3855,7 +3855,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3872,7 +3872,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3889,7 +3889,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3906,7 +3906,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3949,7 +3949,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -3966,7 +3966,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -3983,7 +3983,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4000,7 +4000,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4017,7 +4017,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4034,7 +4034,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4051,7 +4051,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4068,7 +4068,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4085,7 +4085,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4102,7 +4102,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4119,7 +4119,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4136,7 +4136,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4153,7 +4153,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4170,7 +4170,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4187,7 +4187,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4204,7 +4204,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4221,7 +4221,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4238,7 +4238,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4255,7 +4255,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4272,7 +4272,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4289,7 +4289,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4306,7 +4306,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4323,7 +4323,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4340,7 +4340,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4357,7 +4357,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4374,7 +4374,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4391,7 +4391,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4408,7 +4408,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4425,7 +4425,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4442,7 +4442,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4459,7 +4459,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4502,7 +4502,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4519,7 +4519,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4536,7 +4536,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4553,7 +4553,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4570,7 +4570,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4587,7 +4587,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4604,7 +4604,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4621,7 +4621,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4638,7 +4638,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4655,7 +4655,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4672,7 +4672,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4689,7 +4689,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4706,7 +4706,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4723,7 +4723,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4740,7 +4740,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4757,7 +4757,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4774,7 +4774,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4791,7 +4791,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4808,7 +4808,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4825,7 +4825,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4842,7 +4842,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4859,7 +4859,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4876,7 +4876,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4893,7 +4893,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4910,7 +4910,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4927,7 +4927,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4944,7 +4944,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4961,7 +4961,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4978,7 +4978,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4995,7 +4995,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -5012,7 +5012,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index 8214c081e6ce1..3b6d1684e08e1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -88,7 +88,7 @@ export function ErroneousTransactionsRateChart() { }, { data: errorRates, - type: 'line', + type: 'linemark', color: theme.euiColorVis7, hideLegend: true, title: i18n.translate('xpack.apm.errorRateChart.rateLabel', { diff --git a/x-pack/plugins/apm/public/featureCatalogueEntry.ts b/x-pack/plugins/apm/public/featureCatalogueEntry.ts index dd07f5958d666..8b95c412e4c1c 100644 --- a/x-pack/plugins/apm/public/featureCatalogueEntry.ts +++ b/x-pack/plugins/apm/public/featureCatalogueEntry.ts @@ -17,6 +17,6 @@ export const featureCatalogueEntry = { }), icon: 'apmApp', path: '/app/apm', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }; diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index cf729fe0ff301..0d25ececd5156 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -45,7 +45,7 @@ export interface ApmPluginSetupDeps { alerts?: AlertingPluginPublicSetup; data: DataPublicPluginSetup; features: FeaturesPluginSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; observability?: ObservabilityPluginSetup; @@ -69,8 +69,10 @@ export class ApmPlugin implements Plugin { const config = this.initializerContext.config.get(); const pluginSetupDeps = plugins; - pluginSetupDeps.home.environment.update({ apmUi: true }); - pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); + if (pluginSetupDeps.home) { + pluginSetupDeps.home.environment.update({ apmUi: true }); + pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); + } if (plugins.observability) { const getApmDataHelper = async () => { @@ -143,8 +145,8 @@ export class ApmPlugin implements Plugin { defaultMessage: 'Error rate', }), iconClass: 'bell', - alertParamsExpression: lazy(() => - import('./components/shared/ErrorRateAlertTrigger') + alertParamsExpression: lazy( + () => import('./components/shared/ErrorRateAlertTrigger') ), validate: () => ({ errors: [], @@ -158,8 +160,8 @@ export class ApmPlugin implements Plugin { defaultMessage: 'Transaction duration', }), iconClass: 'bell', - alertParamsExpression: lazy(() => - import('./components/shared/TransactionDurationAlertTrigger') + alertParamsExpression: lazy( + () => import('./components/shared/TransactionDurationAlertTrigger') ), validate: () => ({ errors: [], diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 217e6a30a33b4..a750a9ea7af67 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -151,7 +151,20 @@ export async function inspectSearchParams( end: 1528977600000, apmEventClient: { search: spy } as any, internalClient: { search: spy } as any, - config: new Proxy({}, { get: () => 'myIndex' }) as APMConfig, + config: new Proxy( + {}, + { + get: (_, key) => { + switch (key) { + default: + return 'myIndex'; + + case 'xpack.apm.metricsInterval': + return 30; + } + }, + } + ) as APMConfig, uiFiltersES: [{ term: { 'my.custom.ui.filter': 'foo-bar' } }], indices: { /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/apm/scripts/package.json b/x-pack/plugins/apm/scripts/package.json index 4d0906514b5e1..d3e2d42f972a9 100644 --- a/x-pack/plugins/apm/scripts/package.json +++ b/x-pack/plugins/apm/scripts/package.json @@ -6,8 +6,10 @@ "dependencies": { "@elastic/elasticsearch": "7.9.0-rc.1", "@octokit/rest": "^16.35.0", - "@types/console-stamp": "^0.2.32", "console-stamp": "^0.2.9", "hdr-histogram-js": "^1.2.0" + }, + "devDependencies": { + "@types/console-stamp": "^0.2.32" } } diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json index 350db55e72446..39e88b693994f 100644 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ b/x-pack/plugins/apm/scripts/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "include": [ "./**/*" ], diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index fa4b8b821f9f8..29b2a77df348e 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -31,6 +31,7 @@ export const config = { maxTraceItems: schema.number({ defaultValue: 1000 }), }), telemetryCollectionEnabled: schema.boolean({ defaultValue: true }), + metricsInterval: schema.number({ defaultValue: 30 }), }), }; @@ -68,6 +69,7 @@ export function mergeConfigs( 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled, + 'xpack.apm.metricsInterval': apmConfig.metricsInterval, }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts index c57769e9e15da..9f5b5cdf47552 100644 --- a/x-pack/plugins/apm/server/lib/helpers/metrics.ts +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.ts @@ -6,13 +6,17 @@ import { getBucketSize } from './get_bucket_size'; -export function getMetricsDateHistogramParams(start: number, end: number) { +export function getMetricsDateHistogramParams( + start: number, + end: number, + metricsInterval: number +) { const { bucketSize } = getBucketSize(start, end, 'auto'); return { field: '@timestamp', - // ensure minimum bucket size of 30s since this is the default resolution for metric data - fixed_interval: `${Math.max(bucketSize, 30)}s`, + // ensure minimum bucket size of configured interval since this is the default resolution for metric data + fixed_interval: `${Math.max(bucketSize, metricsInterval)}s`, min_doc_count: 0, extended_bounds: { min: start, max: end }, diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index e5c573ba1ec02..551384da2cca7 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -42,7 +42,7 @@ export async function fetchAndTransformGcMetrics({ chartBase: ChartBase; fieldName: typeof METRIC_JAVA_GC_COUNT | typeof METRIC_JAVA_GC_TIME; }) { - const { start, end, apmEventClient } = setup; + const { start, end, apmEventClient, config } = setup; const { bucketSize } = getBucketSize(start, end, 'auto'); @@ -75,7 +75,11 @@ export async function fetchAndTransformGcMetrics({ }, aggs: { over_time: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs: { // get the max value max: { diff --git a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts index f6e201b395c37..a42a10d6518a0 100644 --- a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts @@ -65,7 +65,7 @@ export async function fetchAndTransformMetrics({ aggs: T; additionalFilters?: Filter[]; }) { - const { start, end, apmEventClient } = setup; + const { start, end, apmEventClient, config } = setup; const projection = getMetricsProjection({ setup, @@ -83,7 +83,11 @@ export async function fetchAndTransformMetrics({ }, aggs: { timeseriesData: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs, }, ...aggs, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index d4e0bd1d54da1..ec2d8144cf3ff 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -12,12 +12,12 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; -import { getMetricsDateHistogramParams } from '../helpers/metrics'; import { Setup, SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; +import { getBucketSize } from '../helpers/get_bucket_size'; export async function getErrorRate({ serviceName, @@ -57,7 +57,12 @@ export async function getErrorRate({ query: { bool: { filter } }, aggs: { total_transactions: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize(start, end, 'auto').intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, aggs: { erroneous_transactions: { filter: { range: { [HTTP_RESPONSE_STATUS_CODE]: { gte: 400 } } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts index 7248399d1f93f..fbdddea32deb4 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -36,7 +36,7 @@ export async function getTransactionBreakdown({ transactionName?: string; transactionType: string; }) { - const { uiFiltersES, apmEventClient, start, end } = setup; + const { uiFiltersES, apmEventClient, start, end, config } = setup; const subAggs = { sum_all_self_times: { @@ -104,7 +104,11 @@ export async function getTransactionBreakdown({ aggs: { ...subAggs, by_date: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs: subAggs, }, }, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.ts index 6b9464843fca4..9cbd5ed3ee68a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.ts @@ -20,9 +20,10 @@ export function ifFn(): ExpressionFunctionDefinition<'if', unknown, Arguments, u help, args: { condition: { - types: ['boolean', 'null'], + types: ['boolean'], aliases: ['_'], help: argHelp.condition, + required: true, }, then: { resolve: false, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.ts index 4066a35ea41f2..c4ba5771408a5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.ts @@ -20,6 +20,9 @@ export function neq(): ExpressionFunctionDefinition<'neq', Input, Arguments, boo name: 'neq', type: 'boolean', help, + context: { + types: ['boolean', 'number', 'string', 'null'], + }, args: { value: { aliases: ['_'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts index bb70bec561a11..453beb4c1106b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts @@ -25,6 +25,7 @@ export function switchFn(): ExpressionFunctionDefinition<'switch', unknown, Argu aliases: ['_'], resolve: false, multi: true, + required: true, help: argHelp.case, }, default: { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.ts index 5105beb586f72..568e67db7f86c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.ts @@ -26,6 +26,7 @@ export function tail(): ExpressionFunctionDefinition<'tail', Datatable, Argument aliases: ['_'], types: ['number'], help: argHelp.count, + default: 1, }, }, fn: (input, args) => ({ diff --git a/x-pack/plugins/canvas/i18n/functions/dict/alter_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/alter_column.ts index f201e73d717eb..5f206399b42da 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/alter_column.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/alter_column.ts @@ -13,14 +13,14 @@ import { DATATABLE_COLUMN_TYPES } from '../../../common/lib/constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.alterColumnHelpText', { defaultMessage: - 'Converts between core types, including {list}, and {end}, and rename columns. ' + + 'Converts between core types, including {list}, and {end}, and renames columns. ' + 'See also {mapColumnFn} and {staticColumnFn}.', values: { list: Object.values(DATATABLE_COLUMN_TYPES) .slice(0, -1) .map((type) => `\`${type}\``) .join(', '), - end: Object.values(DATATABLE_COLUMN_TYPES).slice(-1)[0], + end: `\`${Object.values(DATATABLE_COLUMN_TYPES).slice(-1)[0]}\``, mapColumnFn: '`mapColumn`', staticColumnFn: '`staticColumn`', }, @@ -33,7 +33,7 @@ export const help: FunctionHelp> = { defaultMessage: 'The resultant column name. Leave blank to not rename.', }), type: i18n.translate('xpack.canvas.functions.alterColumn.args.typeHelpText', { - defaultMessage: 'The type to convert the column to. Leave blank to not change type.', + defaultMessage: 'The type to convert the column to. Leave blank to not change the type.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/as.ts b/x-pack/plugins/canvas/i18n/functions/dict/as.ts index e95aa641c71b8..6154159d5e452 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/as.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/as.ts @@ -20,7 +20,7 @@ export const help: FunctionHelp> = { }), args: { name: i18n.translate('xpack.canvas.functions.as.args.nameHelpText', { - defaultMessage: 'A name to give the column.', + defaultMessage: 'The name to give the column.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/axis_config.ts b/x-pack/plugins/canvas/i18n/functions/dict/axis_config.ts index 7cf0ec6c58761..bedd677209baa 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/axis_config.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/axis_config.ts @@ -21,14 +21,14 @@ export const help: FunctionHelp> = { args: { max: i18n.translate('xpack.canvas.functions.axisConfig.args.maxHelpText', { defaultMessage: - 'The maximum value displayed in the axis. Must be a number or a date in milliseconds since epoch or {ISO8601} string.', + 'The maximum value displayed in the axis. Must be a number, a date in milliseconds since epoch, or an {ISO8601} string.', values: { ISO8601, }, }), min: i18n.translate('xpack.canvas.functions.axisConfig.args.minHelpText', { defaultMessage: - 'The minimum value displayed in the axis. Must be a number or a date in milliseconds since epoch or {ISO8601} string.', + 'The minimum value displayed in the axis. Must be a number, a date in milliseconds since epoch, or an {ISO8601} string.', values: { ISO8601, }, @@ -40,14 +40,14 @@ export const help: FunctionHelp> = { .slice(0, -1) .map((position) => `\`"${position}"\``) .join(', '), - end: Object.values(Position).slice(-1)[0], + end: `\`"${Object.values(Position).slice(-1)[0]}"\``, }, }), show: i18n.translate('xpack.canvas.functions.axisConfig.args.showHelpText', { defaultMessage: 'Show the axis labels?', }), tickSize: i18n.translate('xpack.canvas.functions.axisConfig.args.tickSizeHelpText', { - defaultMessage: 'The increment size between each tick. Use for `number` axes only', + defaultMessage: 'The increment size between each tick. Use for `number` axes only.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/case.ts b/x-pack/plugins/canvas/i18n/functions/dict/case.ts index 8f0689e5e3837..884542420999c 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/case.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/case.ts @@ -34,14 +34,14 @@ export const help: FunctionHelp> = { }), if: i18n.translate('xpack.canvas.functions.case.args.ifHelpText', { defaultMessage: - 'This value indicates whether the condition is met, usually using a sub-expression. The {IF_ARG} argument overrides the {WHEN_ARG} argument when both are provided.', + 'This value indicates whether the condition is met. The {IF_ARG} argument overrides the {WHEN_ARG} argument when both are provided.', values: { IF_ARG, WHEN_ARG, }, }), then: i18n.translate('xpack.canvas.functions.case.args.thenHelpText', { - defaultMessage: 'The value to return if the condition is met.', + defaultMessage: 'The value returned if the condition is met.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/compare.ts b/x-pack/plugins/canvas/i18n/functions/dict/compare.ts index 5697881503b84..cb57fde0cfcb6 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/compare.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/compare.ts @@ -22,20 +22,20 @@ export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.compareHelpText', { defaultMessage: 'Compares the {CONTEXT} to specified value to determine {BOOLEAN_TRUE} or {BOOLEAN_FALSE}. Usually used in combination with `{ifFn}` or `{caseFn}`. ' + - 'This only works with primitive types, such as {examples}. See also `{eqFn}`, `{gtFn}`, `{gteFn}`, `{ltFn}`, `{lteFn}`, `{neqFn}`', + 'This only works with primitive types, such as {examples}. See also {eqFn}, {gtFn}, {gteFn}, {ltFn}, {lteFn}, {neqFn}', values: { CONTEXT, BOOLEAN_TRUE, BOOLEAN_FALSE, - ifFn: 'if', + ifFn: '`if`', caseFn: 'case', examples: [TYPE_NUMBER, TYPE_STRING, TYPE_BOOLEAN, TYPE_NULL].join(', '), - eqFn: 'eq', - gtFn: 'gt', - gteFn: 'gte', - ltFn: 'lt', - lteFn: 'lte', - neqFn: 'neq', + eqFn: '`eq`', + gtFn: '`gt`', + gteFn: '`gte`', + ltFn: '`lt`', + lteFn: '`lte`', + neqFn: '`neq`', }, }), args: { @@ -44,13 +44,13 @@ export const help: FunctionHelp> = { 'The operator to use in the comparison: {eq} (equal to), {gt} (greater than), {gte} (greater than or equal to)' + ', {lt} (less than), {lte} (less than or equal to), {ne} or {neq} (not equal to).', values: { - eq: Operation.EQ, - gt: Operation.GT, - gte: Operation.GTE, - lt: Operation.LT, - lte: Operation.LTE, - ne: Operation.NE, - neq: Operation.NEQ, + eq: `\`"${Operation.EQ}"\``, + gt: `\`"${Operation.GT}"\``, + gte: `\`"${Operation.GTE}"\``, + lt: `\`"${Operation.LT}"\``, + lte: `\`"${Operation.LTE}"\``, + ne: `\`"${Operation.NE}"\``, + neq: `\`"${Operation.NEQ}"\``, }, }), to: i18n.translate('xpack.canvas.functions.compare.args.toHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/container_style.ts b/x-pack/plugins/canvas/i18n/functions/dict/container_style.ts index bef2ccc2a8e3b..f51206d7990a9 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/container_style.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/container_style.ts @@ -74,7 +74,7 @@ export const help: FunctionHelp> = { }, }), padding: i18n.translate('xpack.canvas.functions.containerStyle.args.paddingHelpText', { - defaultMessage: 'The distance of the content, in pixels, from border.', + defaultMessage: 'The distance of the content, in pixels, from the border.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/date.ts b/x-pack/plugins/canvas/i18n/functions/dict/date.ts index 6964b62bcc582..1ccab1eb775af 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/date.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/date.ts @@ -29,7 +29,8 @@ export const help: FunctionHelp> = { }, }), format: i18n.translate('xpack.canvas.functions.date.args.formatHelpText', { - defaultMessage: 'The {MOMENTJS} format used to parse the specified date string. See {url}.', + defaultMessage: + 'The {MOMENTJS} format used to parse the specified date string. For more information, see {url}.', values: { MOMENTJS, url: 'https://momentjs.com/docs/#/displaying/', diff --git a/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts b/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts index 0d051a4f5f068..312e0e795ed0b 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts @@ -11,7 +11,7 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.dropdownControlHelpText', { - defaultMessage: 'Configures a drop-down filter control element.', + defaultMessage: 'Configures a dropdown filter control element.', }), args: { filterColumn: i18n.translate( @@ -22,7 +22,7 @@ export const help: FunctionHelp> = { ), valueColumn: i18n.translate('xpack.canvas.functions.dropdownControl.args.valueColumnHelpText', { defaultMessage: - 'The column or field from which to extract the unique values for the drop-down control.', + 'The column or field from which to extract the unique values for the dropdown control.', }), filterGroup: i18n.translate('xpack.canvas.functions.dropdownControl.args.filterGroupHelpText', { defaultMessage: 'The group name for the filter.', diff --git a/x-pack/plugins/canvas/i18n/functions/dict/eq.ts b/x-pack/plugins/canvas/i18n/functions/dict/eq.ts index a856a81452cd7..23f74068afa74 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/eq.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/eq.ts @@ -12,7 +12,7 @@ import { CONTEXT } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.eqHelpText', { - defaultMessage: 'Return whether the {CONTEXT} is equal to the argument.', + defaultMessage: 'Returns whether the {CONTEXT} is equal to the argument.', values: { CONTEXT, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/filterrows.ts b/x-pack/plugins/canvas/i18n/functions/dict/filterrows.ts index 3c1b6d87a9be5..26f1cab51b459 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/filterrows.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/filterrows.ts @@ -12,7 +12,7 @@ import { DATATABLE, TYPE_BOOLEAN, BOOLEAN_TRUE, BOOLEAN_FALSE } from '../../cons export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.filterrowsHelpText', { - defaultMessage: 'Filter rows in a {DATATABLE} based on the return value of a sub-expression.', + defaultMessage: 'Filters rows in a {DATATABLE} based on the return value of a sub-expression.', values: { DATATABLE, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/formatdate.ts b/x-pack/plugins/canvas/i18n/functions/dict/formatdate.ts index 9b60c2f69f120..385403ce75573 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/formatdate.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/formatdate.ts @@ -25,7 +25,7 @@ export const help: FunctionHelp> = { defaultMessage: 'A {MOMENTJS} format. For example, {example}. See {url}.', values: { MOMENTJS, - example: `"MM/DD/YYYY"`, + example: '`"MM/DD/YYYY"`', url: 'https://momentjs.com/docs/#/displaying/', }, }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/formatnumber.ts b/x-pack/plugins/canvas/i18n/functions/dict/formatnumber.ts index f3e8a8858fc36..3dfcf3a9e476f 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/formatnumber.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/formatnumber.ts @@ -12,7 +12,7 @@ import { NUMERALJS } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.formatnumberHelpText', { - defaultMessage: 'Formats a number into a formatted number string using {NUMERALJS}.', + defaultMessage: 'Formats a number into a formatted number string using the {NUMERALJS}.', values: { NUMERALJS, }, @@ -22,8 +22,8 @@ export const help: FunctionHelp> = { format: i18n.translate('xpack.canvas.functions.formatnumber.args.formatHelpText', { defaultMessage: 'A {NUMERALJS} format string. For example, {example1} or {example2}.', values: { - example1: `"0.0a"`, - example2: `"0%"`, + example1: '`"0.0a"`', + example2: '`"0%"`', NUMERALJS, }, }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/get_cell.ts b/x-pack/plugins/canvas/i18n/functions/dict/get_cell.ts index 79cc4f7e5c303..1cd4cd054d5d9 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/get_cell.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/get_cell.ts @@ -12,7 +12,7 @@ import { DATATABLE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.getCellHelpText', { - defaultMessage: 'Fetchs a single cell from a {DATATABLE}.', + defaultMessage: 'Fetches a single cell from a {DATATABLE}.', values: { DATATABLE, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/head.ts b/x-pack/plugins/canvas/i18n/functions/dict/head.ts index 4c61339c29c28..8aef4afd63ef6 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/head.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/head.ts @@ -12,7 +12,7 @@ import { DATATABLE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.headHelpText', { - defaultMessage: 'Retrieves the first {n} rows from the {DATATABLE}. See also {tailFn}', + defaultMessage: 'Retrieves the first {n} rows from the {DATATABLE}. See also {tailFn}.', values: { n: 'N', DATATABLE, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/if.ts b/x-pack/plugins/canvas/i18n/functions/dict/if.ts index 9cac3d10b2834..5f840fad91e5c 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/if.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/if.ts @@ -12,7 +12,7 @@ import { BOOLEAN_TRUE, BOOLEAN_FALSE, CONTEXT } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.ifHelpText', { - defaultMessage: 'Perform conditional logic', + defaultMessage: 'Performs conditional logic.', }), args: { condition: i18n.translate('xpack.canvas.functions.if.args.conditionHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/join_rows.ts b/x-pack/plugins/canvas/i18n/functions/dict/join_rows.ts index 59684f7cf1cd8..36293d41a5279 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/join_rows.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/join_rows.ts @@ -11,20 +11,20 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.joinRowsHelpText', { - defaultMessage: 'Join values from rows in a datatable into a string', + defaultMessage: 'Concatenates values from rows in a `datatable` into a single string.', }), args: { column: i18n.translate('xpack.canvas.functions.joinRows.args.columnHelpText', { - defaultMessage: 'The column to join values from', + defaultMessage: 'The column or field from which to extract the values.', }), separator: i18n.translate('xpack.canvas.functions.joinRows.args.separatorHelpText', { - defaultMessage: 'The separator to use between row values', + defaultMessage: 'The delimiter to insert between each extracted value.', }), quote: i18n.translate('xpack.canvas.functions.joinRows.args.quoteHelpText', { - defaultMessage: 'The quote character around values', + defaultMessage: 'The quote character to wrap around each extracted value.', }), distinct: i18n.translate('xpack.canvas.functions.joinRows.args.distinctHelpText', { - defaultMessage: 'Removes duplicate values?', + defaultMessage: 'Extract only unique values?', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/location.ts b/x-pack/plugins/canvas/i18n/functions/dict/location.ts index 7c0497da8361d..3bd98914ecb11 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/location.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/location.ts @@ -14,10 +14,11 @@ export const help: FunctionHelp> = { defaultMessage: 'Find your current location using the {geolocationAPI} of the browser. ' + 'Performance can vary, but is fairly accurate. ' + - 'See {url}.', + 'See {url}. Don’t use {locationFn} if you plan to generate PDFs as this function requires user input.', values: { geolocationAPI: 'Geolocation API', url: 'https://developer.mozilla.org/en-US/docs/Web/API/Navigator/geolocation', + locationFn: '`location`', }, }), args: {}, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_center.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_center.ts index 3022ad07089d2..5409808752687 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_center.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/map_center.ts @@ -11,7 +11,7 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.mapCenterHelpText', { - defaultMessage: `Returns an object with the center coordinates and zoom level of the map`, + defaultMessage: `Returns an object with the center coordinates and zoom level of the map.`, }), args: { lat: i18n.translate('xpack.canvas.functions.mapCenter.args.latHelpText', { @@ -21,7 +21,7 @@ export const help: FunctionHelp> = { defaultMessage: `Longitude for the center of the map`, }), zoom: i18n.translate('xpack.canvas.functions.savedMap.args.zoomHelpText', { - defaultMessage: `The zoom level of the map`, + defaultMessage: `Zoom level of the map`, }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts index 589dd9b1dad87..2666a08999fb8 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts @@ -14,10 +14,10 @@ export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { defaultMessage: 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments. ' + - 'See also {mapColumnFn} and {staticColumnFn}.', + 'Changes are made only when you provide arguments.' + + 'See also {alterColumnFn} and {staticColumnFn}.', values: { - mapColumnFn: '`mapColumn`', + alterColumnFn: '`alterColumn`', staticColumnFn: '`staticColumn`', }, }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/markdown.ts b/x-pack/plugins/canvas/i18n/functions/dict/markdown.ts index aa2845ba4ec3a..093bdaecccb35 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/markdown.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/markdown.ts @@ -33,13 +33,13 @@ export const help: FunctionHelp> = { 'The {CSS} font properties for the content. For example, {fontFamily} or {fontWeight}.', values: { CSS, - fontFamily: 'font-family', - fontWeight: 'font-weight', + fontFamily: '"font-family"', + fontWeight: '"font-weight"', }, }), openLinksInNewTab: i18n.translate('xpack.canvas.functions.markdown.args.openLinkHelpText', { defaultMessage: - 'A true/false value for opening links in a new tab. Default value is false. Setting to true will open all links in a new tab.', + 'A true or false value for opening links in a new tab. The default value is `false`. Setting to `true` opens all links in a new tab.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/math.ts b/x-pack/plugins/canvas/i18n/functions/dict/math.ts index 752009fb9c320..4469c629fa6fd 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/math.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/math.ts @@ -8,12 +8,12 @@ import { i18n } from '@kbn/i18n'; import { math } from '../../../canvas_plugin_src/functions/common/math'; import { FunctionHelp } from '../function_help'; import { FunctionFactory } from '../../../types'; -import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL } from '../../constants'; +import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL, TYPE_NUMBER } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.mathHelpText', { defaultMessage: - 'Interprets a {TINYMATH} math expression using a number or {DATATABLE} as {CONTEXT}. ' + + 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + 'The {DATATABLE} columns are available by their column name. ' + 'If the {CONTEXT} is a number it is available as {value}.', values: { @@ -21,6 +21,7 @@ export const help: FunctionHelp> = { CONTEXT, DATATABLE, value: '`value`', + TYPE_NUMBER, }, }), args: { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/metric.ts b/x-pack/plugins/canvas/i18n/functions/dict/metric.ts index 8258226e5dfc3..f84456b03a86e 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/metric.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/metric.ts @@ -40,8 +40,8 @@ export const help: FunctionHelp> = { metricFormat: i18n.translate('xpack.canvas.functions.metric.args.metricFormatHelpText', { defaultMessage: 'A {NUMERALJS} format string. For example, {example1} or {example2}.', values: { - example1: `"0.0a"`, - example2: `"0%"`, + example1: '`"0.0a"`', + example2: '`"0%"`', NUMERALJS, }, }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/pie.ts b/x-pack/plugins/canvas/i18n/functions/dict/pie.ts index 2e4bfc88a273a..149c2f8f1e634 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/pie.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/pie.ts @@ -8,12 +8,12 @@ import { i18n } from '@kbn/i18n'; import { pie } from '../../../canvas_plugin_src/functions/common/pie'; import { FunctionHelp } from '../function_help'; import { FunctionFactory } from '../../../types'; -import { Position } from '../../../types'; +import { Legend } from '../../../types'; import { CSS, FONT_FAMILY, FONT_WEIGHT, BOOLEAN_FALSE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.pieHelpText', { - defaultMessage: 'Configure a pie chart element.', + defaultMessage: 'Configures a pie chart element.', }), args: { font: i18n.translate('xpack.canvas.functions.pie.args.fontHelpText', { @@ -38,20 +38,18 @@ export const help: FunctionHelp> = { }), legend: i18n.translate('xpack.canvas.functions.pie.args.legendHelpText', { defaultMessage: - 'The legend position. For example, {positions}, or {BOOLEAN_FALSE}. When {BOOLEAN_FALSE}, the legend is hidden.', + 'The legend position. For example, {legend}, or {BOOLEAN_FALSE}. When {BOOLEAN_FALSE}, the legend is hidden.', values: { - positions: Object.values(Position) + legend: Object.values(Legend) .map((position) => `\`"${position}"\``) .join(', '), BOOLEAN_FALSE, }, }), palette: i18n.translate('xpack.canvas.functions.pie.args.paletteHelpText', { - defaultMessage: - 'A {palette} object for describing the colors to use in this pie chart. See {paletteFn}.', + defaultMessage: 'A {palette} object for describing the colors to use in this pie chart.', values: { palette: '`palette`', - paletteFn: '`palette`', }, }), radius: i18n.translate('xpack.canvas.functions.pie.args.radiusHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/plot.ts b/x-pack/plugins/canvas/i18n/functions/dict/plot.ts index 068156f14c91b..aca2476a6592e 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/plot.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/plot.ts @@ -8,12 +8,12 @@ import { i18n } from '@kbn/i18n'; import { plot } from '../../../canvas_plugin_src/functions/common/plot'; import { FunctionHelp } from '../function_help'; import { FunctionFactory } from '../../../types'; -import { Position } from '../../../types'; +import { Legend } from '../../../types'; import { CSS, FONT_FAMILY, FONT_WEIGHT, BOOLEAN_FALSE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.plotHelpText', { - defaultMessage: 'Configure a chart element', + defaultMessage: 'Configures a chart element.', }), args: { defaultStyle: i18n.translate('xpack.canvas.functions.plot.args.defaultStyleHelpText', { @@ -30,20 +30,18 @@ export const help: FunctionHelp> = { }), legend: i18n.translate('xpack.canvas.functions.plot.args.legendHelpText', { defaultMessage: - 'The legend position. For example, {positions}, or {BOOLEAN_FALSE}. When {BOOLEAN_FALSE}, the legend is hidden.', + 'The legend position. For example, {legend}, or {BOOLEAN_FALSE}. When {BOOLEAN_FALSE}, the legend is hidden.', values: { - positions: Object.values(Position) + legend: Object.values(Legend) .map((position) => `\`"${position}"\``) .join(', '), BOOLEAN_FALSE, }, }), palette: i18n.translate('xpack.canvas.functions.plot.args.paletteHelpText', { - defaultMessage: - 'A {palette} object for describing the colors to use in this chart. See {paletteFn}.', + defaultMessage: 'A {palette} object for describing the colors to use in this chart.', values: { palette: '`palette`', - paletteFn: '`palette`', }, }), seriesStyle: i18n.translate('xpack.canvas.functions.plot.args.seriesStyleHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/ply.ts b/x-pack/plugins/canvas/i18n/functions/dict/ply.ts index f341965aaa8b2..3bb9c1b3e46a3 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/ply.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/ply.ts @@ -13,8 +13,8 @@ import { DATATABLE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.plyHelpText', { defaultMessage: - 'Subdivides a {DATATABLE} by the unique values of the specified column, ' + - 'and passes the resulting tables into an expression, then merges the outputs of each expression', + 'Subdivides a {DATATABLE} by the unique values of the specified columns, ' + + 'and passes the resulting tables into an expression, then merges the outputs of each expression.', values: { DATATABLE, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/pointseries.ts b/x-pack/plugins/canvas/i18n/functions/dict/pointseries.ts index 1e7c67bb750e3..2579db77ff1b9 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/pointseries.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/pointseries.ts @@ -35,10 +35,10 @@ export const help: FunctionHelp> = { defaultMessage: 'The text to show on the mark. Only applicable to supported elements.', }), x: i18n.translate('xpack.canvas.functions.pointseries.args.xHelpText', { - defaultMessage: 'The values along the x-axis.', + defaultMessage: 'The values along the X-axis.', }), y: i18n.translate('xpack.canvas.functions.pointseries.args.yHelpText', { - defaultMessage: 'The values along the y-axis.', + defaultMessage: 'The values along the Y-axis.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/progress.ts b/x-pack/plugins/canvas/i18n/functions/dict/progress.ts index 1880c5dc807f0..199d5d926f277 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/progress.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/progress.ts @@ -34,7 +34,7 @@ export const help: FunctionHelp> = { }), label: i18n.translate('xpack.canvas.functions.progress.args.labelHelpText', { defaultMessage: - 'To show or hide labels, use {BOOLEAN_TRUE} or {BOOLEAN_FALSE}. Alternatively, provide a string to display as a label.', + 'To show or hide the label, use {BOOLEAN_TRUE} or {BOOLEAN_FALSE}. Alternatively, provide a string to display as a label.', values: { BOOLEAN_TRUE, BOOLEAN_FALSE, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/render.ts b/x-pack/plugins/canvas/i18n/functions/dict/render.ts index bf0a5a50b8726..7ddb04de490e5 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/render.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/render.ts @@ -13,7 +13,7 @@ import { CONTEXT, CSS } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.renderHelpText', { defaultMessage: - 'Render the {CONTEXT} as a specific element and sets element level options, such as background and border styling.', + 'Renders the {CONTEXT} as a specific element and sets element level options, such as background and border styling.', values: { CONTEXT, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts b/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts index 222947779a758..4de92b0552bf5 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts @@ -17,7 +17,7 @@ export const help: FunctionHelp> = { args: { emptyImage: i18n.translate('xpack.canvas.functions.repeatImage.args.emptyImageHelpText', { defaultMessage: - 'Fills the difference between the {CONTEXT} and {maxArg} parameter for the element with this image' + + 'Fills the difference between the {CONTEXT} and {maxArg} parameter for the element with this image. ' + 'Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', values: { BASE64, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/replace.ts b/x-pack/plugins/canvas/i18n/functions/dict/replace.ts index 085f42b439c46..e99c9740c57d6 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/replace.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/replace.ts @@ -12,7 +12,7 @@ import { JS } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.replaceImageHelpText', { - defaultMessage: 'Use a regular expression to replace parts of a string.', + defaultMessage: 'Uses a regular expression to replace parts of a string.', }), args: { pattern: i18n.translate('xpack.canvas.functions.replace.args.patternHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts b/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts index 410ca29d7b4d4..6a8909f4acdde 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts @@ -13,7 +13,7 @@ import { BASE64, URL } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.revealImageHelpText', { - defaultMessage: 'Configure an image reveal element.', + defaultMessage: 'Configures an image reveal element.', }), args: { image: i18n.translate('xpack.canvas.functions.revealImage.args.imageHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/rounddate.ts b/x-pack/plugins/canvas/i18n/functions/dict/rounddate.ts index 4805fe16a94f0..d2728b6371398 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/rounddate.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/rounddate.ts @@ -21,7 +21,7 @@ export const help: FunctionHelp> = { args: { format: i18n.translate('xpack.canvas.functions.rounddate.args.formatHelpText', { defaultMessage: - 'The {MOMENTJS} format to use for bucketing. For example, {example} would round each date to months. See {url}.', + 'The {MOMENTJS} format to use for bucketing. For example, {example} rounds to months. See {url}.', values: { example: '`"YYYY-MM"`', MOMENTJS, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/row_count.ts b/x-pack/plugins/canvas/i18n/functions/dict/row_count.ts index 5b0cecd47fd79..fd7c651238c28 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/row_count.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/row_count.ts @@ -12,7 +12,7 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.rowCountHelpText', { defaultMessage: - 'Returns the number of rows. Pair with {plyFn} to get the count of unique column ' + + 'Returns the number of rows. Pairs with {plyFn} to get the count of unique column ' + 'values, or combinations of unique column values.', values: { plyFn: '`ply`', diff --git a/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts index e146a6ca68449..1121aa43f3509 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -11,17 +11,17 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { - defaultMessage: `Returns an embeddable for a saved lens object`, + defaultMessage: `Returns an embeddable for a saved Lens visualization object.`, }), args: { id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { - defaultMessage: `The ID of the Saved Lens Object`, + defaultMessage: `The ID of the saved Lens visualization object`, }), timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { defaultMessage: `The timerange of data that should be included`, }), title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { - defaultMessage: `The title for the lens emebeddable`, + defaultMessage: `The title for the Lens visualization object`, }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/saved_map.ts b/x-pack/plugins/canvas/i18n/functions/dict/saved_map.ts index 8615565897434..bacaca523ed2e 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/saved_map.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/saved_map.ts @@ -11,11 +11,11 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.savedMapHelpText', { - defaultMessage: `Returns an embeddable for a saved map object`, + defaultMessage: `Returns an embeddable for a saved map object.`, }), args: { id: i18n.translate('xpack.canvas.functions.savedMap.args.idHelpText', { - defaultMessage: `The ID of the Saved Map Object`, + defaultMessage: `The ID of the saved map object`, }), center: i18n.translate('xpack.canvas.functions.savedMap.args.centerHelpText', { defaultMessage: `The center and zoom level the map should have`, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/saved_visualization.ts b/x-pack/plugins/canvas/i18n/functions/dict/saved_visualization.ts index 30f88b51e7576..e8cbddc5c1102 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/saved_visualization.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/saved_visualization.ts @@ -11,22 +11,22 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.savedVisualizationHelpText', { - defaultMessage: `Returns an embeddable for a saved visualization object`, + defaultMessage: `Returns an embeddable for a saved visualization object.`, }), args: { id: i18n.translate('xpack.canvas.functions.savedVisualization.args.idHelpText', { - defaultMessage: `The ID of the Saved Visualization Object`, + defaultMessage: `The ID of the saved visualization object`, }), timerange: i18n.translate('xpack.canvas.functions.savedVisualization.args.timerangeHelpText', { defaultMessage: `The timerange of data that should be included`, }), colors: i18n.translate('xpack.canvas.functions.savedVisualization.args.colorsHelpText', { - defaultMessage: `Define the color to use for a specific series`, + defaultMessage: `Defines the color to use for a specific series`, }), hideLegend: i18n.translate( 'xpack.canvas.functions.savedVisualization.args.hideLegendHelpText', { - defaultMessage: `Should the legend be hidden`, + defaultMessage: `Specifies the option to hide the legend`, } ), }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/series_style.ts b/x-pack/plugins/canvas/i18n/functions/dict/series_style.ts index 7b3855b528201..3f6daa588b730 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/series_style.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/series_style.ts @@ -43,7 +43,7 @@ export const help: FunctionHelp> = { defaultMessage: 'The width of the line.', }), points: i18n.translate('xpack.canvas.functions.seriesStyle.args.pointsHelpText', { - defaultMessage: 'Size of points on line', + defaultMessage: 'The size of points on line.', }), stack: i18n.translate('xpack.canvas.functions.seriesStyle.args.stackHelpText', { defaultMessage: diff --git a/x-pack/plugins/canvas/i18n/functions/dict/shape.ts b/x-pack/plugins/canvas/i18n/functions/dict/shape.ts index bcd6d90faa3f0..ddc988873f113 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/shape.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/shape.ts @@ -12,7 +12,7 @@ import { SVG } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.shapeHelpText', { - defaultMessage: 'Create a shape.', + defaultMessage: 'Creates a shape.', }), args: { shape: i18n.translate('xpack.canvas.functions.shape.args.shapeHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/sort.ts b/x-pack/plugins/canvas/i18n/functions/dict/sort.ts index d539449253058..b768362dd0770 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/sort.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/sort.ts @@ -12,12 +12,15 @@ import { DATATABLE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.sortHelpText', { - defaultMessage: 'Sorts a datatable by the specified column.', + defaultMessage: 'Sorts a {DATATABLE} by the specified column.', + values: { + DATATABLE, + }, }), args: { by: i18n.translate('xpack.canvas.functions.sort.args.byHelpText', { defaultMessage: - 'The column to sort by. When unspecified, the `{DATATABLE}` ' + + 'The column to sort by. When unspecified, the {DATATABLE} ' + 'is sorted by the first column.', values: { DATATABLE, @@ -25,7 +28,7 @@ export const help: FunctionHelp> = { }), reverse: i18n.translate('xpack.canvas.functions.sort.args.reverseHelpText', { defaultMessage: - 'Reverses the sorting order. When unspecified, the `{DATATABLE}` ' + + 'Reverses the sorting order. When unspecified, the {DATATABLE} ' + 'is sorted in ascending order.', values: { DATATABLE, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/static_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/static_column.ts index 82dbd9910ea3b..f0f7b46a2c0bc 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/static_column.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/static_column.ts @@ -12,7 +12,7 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.staticColumnHelpText', { defaultMessage: - 'Add a column with the same static value in every row. See also {alterColumnFn} and {mapColumnFn}.', + 'Adds a column with the same static value in every row. See also {alterColumnFn} and {mapColumnFn}.', values: { alterColumnFn: '`alterColumn`', mapColumnFn: '`mapColumn`', @@ -20,11 +20,11 @@ export const help: FunctionHelp> = { }), args: { name: i18n.translate('xpack.canvas.functions.staticColumn.args.nameHelpText', { - defaultMessage: 'The name of the new column column.', + defaultMessage: 'The name of the new column.', }), value: i18n.translate('xpack.canvas.functions.staticColumn.args.valueHelpText', { defaultMessage: - 'The value to insert in each row in the new column. Tip: use a sub-expression to rollup ' + + 'The value to insert in each row in the new column. TIP: use a sub-expression to rollup ' + 'other columns into a static value.', }), }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/switch.ts b/x-pack/plugins/canvas/i18n/functions/dict/switch.ts index f65ff7c6fd240..aaf53d2c47c3a 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/switch.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/switch.ts @@ -14,7 +14,7 @@ export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.switchHelpText', { defaultMessage: 'Performs conditional logic with multiple conditions. ' + - 'See also {caseFn} which builds a {case} to pass to the {switchFn} function.', + 'See also {caseFn}, which builds a {case} to pass to the {switchFn} function.', values: { case: '`case`', caseFn: '`case`', @@ -23,7 +23,7 @@ export const help: FunctionHelp> = { }), args: { case: i18n.translate('xpack.canvas.functions.switch.args.caseHelpText', { - defaultMessage: 'The conditions to check', + defaultMessage: 'The conditions to check.', }), default: i18n.translate('xpack.canvas.functions.switch.args.defaultHelpText', { defaultMessage: diff --git a/x-pack/plugins/canvas/i18n/functions/dict/table.ts b/x-pack/plugins/canvas/i18n/functions/dict/table.ts index 91a9ae7488234..9fe93b2136fb5 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/table.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/table.ts @@ -12,7 +12,7 @@ import { CSS, FONT_FAMILY, FONT_WEIGHT, BOOLEAN_FALSE } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.tableHelpText', { - defaultMessage: 'Configures a table element', + defaultMessage: 'Configures a table element.', }), args: { font: i18n.translate('xpack.canvas.functions.table.args.fontHelpText', { @@ -35,7 +35,7 @@ export const help: FunctionHelp> = { defaultMessage: 'The number of rows to display on each page.', }), showHeader: i18n.translate('xpack.canvas.functions.table.args.showHeaderHelpText', { - defaultMessage: 'Show/hide the header row with titles for each column.', + defaultMessage: 'Show or hide the header row with titles for each column.', }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/time_range.ts b/x-pack/plugins/canvas/i18n/functions/dict/time_range.ts index 476a9978800df..e3fa931a8f07b 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/time_range.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/time_range.ts @@ -11,7 +11,7 @@ import { FunctionFactory } from '../../../types'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.timerangeHelpText', { - defaultMessage: `An object that represents a span of time`, + defaultMessage: `An object that represents a span of time.`, }), args: { from: i18n.translate('xpack.canvas.functions.timerange.args.fromHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/timefilter.ts b/x-pack/plugins/canvas/i18n/functions/dict/timefilter.ts index aedcdc9441885..80f2544e11a4e 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/timefilter.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/timefilter.ts @@ -12,7 +12,7 @@ import { ISO8601, ELASTICSEARCH, DATEMATH } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.timefilterHelpText', { - defaultMessage: 'Create a time filter for querying a source.', + defaultMessage: 'Creates a time filter for querying a source.', }), args: { column: i18n.translate('xpack.canvas.functions.timefilter.args.columnHelpText', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/timelion.ts b/x-pack/plugins/canvas/i18n/functions/dict/timelion.ts index 41bf86055f1e3..d76e30c1ef814 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/timelion.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/timelion.ts @@ -12,7 +12,7 @@ import { ELASTICSEARCH, DATEMATH, MOMENTJS_TIMEZONE_URL } from '../../constants' export const help: FunctionHelp>> = { help: i18n.translate('xpack.canvas.functions.timelionHelpText', { - defaultMessage: 'Use Timelion to extract one or more timeseries from many sources.', + defaultMessage: 'Uses Timelion to extract one or more time series from many sources.', }), args: { query: i18n.translate('xpack.canvas.functions.timelion.args.query', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/to.ts b/x-pack/plugins/canvas/i18n/functions/dict/to.ts index c618f84aeaf2b..177e4367b6ece 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/to.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/to.ts @@ -12,7 +12,8 @@ import { CONTEXT } from '../../constants'; export const help: FunctionHelp>> = { help: i18n.translate('xpack.canvas.functions.toHelpText', { - defaultMessage: 'Explicitly casts the type of the {CONTEXT} to the specified type.', + defaultMessage: + 'Explicitly casts the type of the {CONTEXT} from one type to the specified type.', values: { CONTEXT, }, diff --git a/x-pack/plugins/canvas/i18n/functions/dict/urlparam.ts b/x-pack/plugins/canvas/i18n/functions/dict/urlparam.ts index b8c044f521029..0331d239d48a9 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/urlparam.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/urlparam.ts @@ -13,11 +13,11 @@ import { TYPE_STRING, URL } from '../../constants'; export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.urlparamHelpText', { defaultMessage: - 'Retreives a {URL} parameter to use in an expression. ' + + 'Retrieves a {URL} parameter to use in an expression. ' + 'The {urlparamFn} function always returns a {TYPE_STRING}. ' + - 'For example, you can retrieve the value {value} from the parameter {myVar} from the {URL} {example}).', + 'For example, you can retrieve the value {value} from the parameter {myVar} from the {URL} {example}.', values: { - example: 'https://localhost:5601/app/canvas?myVar=20', + example: '`https://localhost:5601/app/canvas?myVar=20`', myVar: '`myVar`', TYPE_STRING, URL, diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 68fbec3e8429d..dbd93de6b50e0 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -5,7 +5,7 @@ "configPath": ["xpack", "canvas"], "server": true, "ui": true, - "requiredPlugins": ["bfetch", "data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting"] + "requiredPlugins": ["bfetch", "data", "embeddable", "expressions", "features", "inspector", "uiActions"], + "optionalPlugins": ["usageCollection", "home"], + "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting", "home"] } diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 482cd04373105..463fb1efbd3b5 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -22,7 +22,6 @@ import { registerLanguage } from './lib/monaco_language_def'; import { SetupRegistries } from './plugin_api'; import { initRegistries, populateRegistries, destroyRegistries } from './registries'; import { getDocumentationLinks } from './lib/documentation_links'; -// @ts-expect-error untyped component import { HelpMenu } from './components/help_menu/help_menu'; import { createStore } from './store'; @@ -128,7 +127,10 @@ export const initializeCanvas = async ( }, ], content: (domNode) => { - ReactDOM.render(, domNode); + ReactDOM.render( + , + domNode + ); return () => ReactDOM.unmountComponentAtNode(domNode); }, }); diff --git a/x-pack/plugins/canvas/public/components/function_reference_generator/function_examples.ts b/x-pack/plugins/canvas/public/components/function_reference_generator/function_examples.ts new file mode 100644 index 0000000000000..61c1c1588a290 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/function_examples.ts @@ -0,0 +1,444 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 FunctionExample { + syntax: string; + usage: { + expression: string; + help?: string; + }; +} + +interface FunctionExampleDict { + [key: string]: FunctionExample; +} + +export const getFunctionExamples = (): FunctionExampleDict => ({ + all: { + syntax: `all {neq "foo"} {neq "bar"} {neq "fizz"} +all condition={gt 10} condition={lt 20}`, + usage: { + expression: `filters +| demodata +| math "mean(percent_uptime)" +| formatnumber "0.0%" +| metric "Average uptime" + metricFont={ + font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" + color={ + if {all {gte 0} {lt 0.8}} then="red" else="green" + } + align="center" lHeight=48 + } +| render`, + help: + 'This sets the color of the metric text to `"red"` if the context passed into `metric` is greater than or equal to 0 and less than 0.8. Otherwise, the color is set to `"green"`.', + }, + }, + alterColumn: { + syntax: `alterColumn "cost" type="string" +alterColumn column="@timestamp" name="foo"`, + usage: { + expression: `filters +| demodata +| alterColumn "time" name="time_in_ms" type="number" +| table +| render`, + help: + 'This renames the `time` column to `time_in_ms` and converts the type of the column’s values from `date` to `number`.', + }, + }, + any: { + syntax: `any {eq "foo"} {eq "bar"} {eq "fizz"} +any condition={lte 10} condition={gt 30}`, + usage: { + expression: `filters +| demodata +| filterrows { + getCell "project" | any {eq "elasticsearch"} {eq "kibana"} {eq "x-pack"} + } +| pointseries color="project" size="max(price)" +| pie +| render`, + help: + 'This filters out any rows that don’t contain `"elasticsearch"`, `"kibana"` or `"x-pack"` in the `project` field.', + }, + }, + as: { + syntax: `as +as "foo" +as name="bar"`, + usage: { + expression: `filters +| demodata +| ply by="project" fn={math "count(username)" | as "num_users"} fn={math "mean(price)" | as "price"} +| pointseries x="project" y="num_users" size="price" color="project" +| plot +| render`, + help: `\`as\` casts any primitive value (\`string\`, \`number\`, \`date\`, \`null\`) into a \`datatable\` with a single row and a single column with the given name (or defaults to \`"value"\` if no name is provided). This is useful when piping a primitive value into a function that only takes \`datatable\` as an input. + +In the example, \`ply\` expects each \`fn\` subexpression to return a \`datatable\` in order to merge the results of each \`fn\` back into a \`datatable\`, but using a \`math\` aggregation in the subexpressions returns a single \`math\` value, which is then cast into a \`datatable\` using \`as\`.`, + }, + }, + asset: { + syntax: `asset "asset-52f14f2b-fee6-4072-92e8-cd2642665d02" +asset id="asset-498f7429-4d56-42a2-a7e4-8bf08d98d114"`, + usage: { + expression: `image dataurl={asset "asset-c661a7cc-11be-45a1-a401-d7592ea7917a"} mode="contain" +| render`, + help: + 'The image asset stored with the ID `"asset-c661a7cc-11be-45a1-a401-d7592ea7917a"` is passed into the `dataurl` argument of the `image` function to display the stored asset.', + }, + }, + axisConfig: { + syntax: `axisConfig show=false +axisConfig position="right" min=0 max=10 tickSize=1`, + usage: { + expression: `filters +| demodata +| pointseries x="size(cost)" y="project" color="project" +| plot defaultStyle={seriesStyle bars=0.75 horizontalBars=true} + legend=false + xaxis={axisConfig position="top" min=0 max=400 tickSize=100} + yaxis={axisConfig position="right"} +| render`, + help: + 'This sets the `x-axis` to display on the top of the chart and sets the range of values to `0-400` with ticks displayed at `100` intervals. The `y-axis` is configured to display on the `right`.', + }, + }, + case: { + syntax: `case 0 then="red" +case when=5 then="yellow" +case if={lte 50} then="green"`, + usage: { + expression: `math "random()" +| progress shape="gauge" label={formatnumber "0%"} + font={ + font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" + color={ + switch {case if={lte 0.5} then="green"} + {case if={all {gt 0.5} {lte 0.75}} then="orange"} + default="red" + } + } + valueColor={ + switch {case if={lte 0.5} then="green"} + {case if={all {gt 0.5} {lte 0.75}} then="orange"} + default="red" + } +| render`, + help: + 'This sets the color of the progress indicator and the color of the label to `"green"` if the value is less than or equal to `0.5`, `"orange"` if the value is greater than `0.5` and less than or equal to `0.75`, and `"red"` if `none` of the case conditions are met.', + }, + }, + columns: { + syntax: `columns include="@timestamp, projects, cost" +columns exclude="username, country, age"`, + usage: { + expression: `filters +| demodata +| columns include="price, cost, state, project" +| table +| render`, + help: + 'This only keeps the `price`, `cost`, `state`, and `project` columns from the `demodata` data source and removes all other columns.', + }, + }, + compare: { + syntax: `compare "neq" to="elasticsearch" +compare op="lte" to=100`, + usage: { + expression: `filters +| demodata +| mapColumn project + fn={getCell project | + switch + {case if={compare eq to=kibana} then=kibana} + {case if={compare eq to=elasticsearch} then=elasticsearch} + default="other" + } +| pointseries size="size(cost)" color="project" +| pie +| render`, + help: + 'This maps all `project` values that aren’t `"kibana"` and `"elasticsearch"` to `"other"`. Alternatively, you can use the individual comparator functions instead of compare.', + }, + }, + containerStyle: { + syntax: `containerStyle backgroundColor="red"’ +containerStyle borderRadius="50px" +containerStyle border="1px solid black" +containerStyle padding="5px" +containerStyle opacity="0.5" +containerStyle overflow="hidden" +containerStyle backgroundImage={asset id=asset-f40d2292-cf9e-4f2c-8c6f-a504a25e949c} + backgroundRepeat="no-repeat" + backgroundSize="cover"`, + usage: { + expression: `shape "star" fill="#E61D35" maintainAspect=true +| render containerStyle={ + containerStyle backgroundColor="#F8D546" + borderRadius="200px" + border="4px solid #05509F" + padding="0px" + opacity="0.9" + overflow="hidden" + }`, + }, + }, + context: { + syntax: `context`, + usage: { + expression: `date +| formatdate "LLLL" +| markdown "Last updated: " {context} +| render`, + help: + 'Using the `context` function allows us to pass the output, or _context_, of the previous function as a value to an argument in the next function. Here we get the formatted date string from the previous function and pass it as `content` for the markdown element.', + }, + }, + csv: { + syntax: `csv "fruit, stock + kiwi, 10 + Banana, 5"`, + usage: { + expression: `csv "fruit,stock + kiwi,10 + banana,5" +| pointseries color=fruit size=stock +| pie +| render`, + help: + 'This creates a `datatable` with `fruit` and `stock` columns with two rows. This is useful for quickly mocking data.', + }, + }, + date: { + syntax: `date +date value=1558735195 +date "2019-05-24T21:59:55+0000" +date "01/31/2019" format="MM/DD/YYYY"`, + usage: { + expression: `date +| formatdate "LLL" +| markdown {context} + font={font family="Arial, sans-serif" size=30 align="left" + color="#000000" + weight="normal" + underline=false + italic=false} +| render`, + help: 'Using `date` without passing any arguments will return the current date and time.', + }, + }, + demodata: { + syntax: `demodata +demodata "ci" +demodata type="shirts"`, + usage: { + expression: `filters +| demodata +| table +| render`, + help: '`demodata` is a mock data set that you can use to start playing around in Canvas.', + }, + }, + dropdownControl: { + syntax: `dropdownControl valueColumn=project filterColumn=project +dropdownControl valueColumn=agent filterColumn=agent.keyword filterGroup=group1`, + usage: { + expression: `demodata +| dropdownControl valueColumn=project filterColumn=project +| render`, + help: + 'This creates a dropdown filter element. It requires a data source and uses the unique values from the given `valueColumn` (i.e. `project`) and applies the filter to the `project` column. Note: `filterColumn` should point to a keyword type field for Elasticsearch data sources.', + }, + }, + eq: { + syntax: `eq true +eq null +eq 10 +eq "foo"`, + usage: { + expression: `filters +| demodata +| mapColumn project + fn={getCell project | + switch + {case if={eq kibana} then=kibana} + {case if={eq elasticsearch} then=elasticsearch} + default="other" + } +| pointseries size="size(cost)" color="project" +| pie +| render`, + help: + 'This changes all values in the project column that don’t equal `"kibana"` or `"elasticsearch"` to `"other"`.', + }, + }, + escount: { + syntax: `escount index="logstash-*" +escount "currency:\"EUR\"" index="kibana_sample_data_ecommerce" +escount query="response:404" index="kibana_sample_data_logs"`, + usage: { + expression: `filters +| escount "Cancelled:true" index="kibana_sample_data_flights" +| math "value" +| progress shape="semicircle" + label={formatnumber 0,0} + font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" color="#000000" align=center} + max={filters | escount index="kibana_sample_data_flights"} +| render`, + help: + 'The first `escount` expression retrieves the number of flights that were cancelled. The second `escount` expression retrieves the total number of flights.', + }, + }, + esdocs: { + syntax: `esdocs index="logstash-*" +esdocs "currency:\"EUR\"" index="kibana_sample_data_ecommerce" +esdocs query="response:404" index="kibana_sample_data_logs" +esdocs index="kibana_sample_data_flights" count=100 +esdocs index="kibana_sample_data_flights" sort="AvgTicketPrice, asc"`, + usage: { + expression: `filters +| esdocs index="kibana_sample_data_ecommerce" + fields="customer_gender, taxful_total_price, order_date" + sort="order_date, asc" + count=10000 +| mapColumn "order_date" + fn={getCell "order_date" | date {context} | rounddate "YYYY-MM-DD"} +| alterColumn "order_date" type="date" +| pointseries x="order_date" y="sum(taxful_total_price)" color="customer_gender" +| plot defaultStyle={seriesStyle lines=3} + palette={palette "#7ECAE3" "#003A4D" gradient=true} +| render`, + help: + 'This retrieves the first 10000 documents data from the `kibana_sample_data_ecommerce` index sorted by `order_date` in ascending order, and only requests the `customer_gender`, `taxful_total_price`, and `order_date` fields.', + }, + }, + essql: { + syntax: `essql query="SELECT * FROM \"logstash*\"" +essql "SELECT * FROM \"apm*\"" count=10000`, + usage: { + expression: `filters +| essql query="SELECT Carrier, FlightDelayMin, AvgTicketPrice FROM \"kibana_sample_data_flights\"" +| table +| render`, + help: + 'This retrieves the `Carrier`, `FlightDelayMin`, and `AvgTicketPrice` fields from the "kibana_sample_data_flights" index.', + }, + }, + exactly: { + syntax: `exactly "state" value="running" +exactly "age" value=50 filterGroup="group2" +exactly column="project" value="beats"`, + usage: { + expression: `filters +| exactly column=project value=elasticsearch +| demodata +| pointseries x=project y="mean(age)" +| plot defaultStyle={seriesStyle bars=1} +| render`, + help: + 'The `exactly` filter here is added to existing filters retrieved by the `filters` function and further filters down the data to only have `"elasticsearch"` data. The `exactly` filter only applies to this one specific element and will not affect other elements in the workpad.', + }, + }, + filterrows: { + syntax: `filterrows {getCell "project" | eq "kibana"} +filterrows fn={getCell "age" | gt 50}`, + usage: { + expression: `filters +| demodata +| filterrows {getCell "country" | any {eq "IN"} {eq "US"} {eq "CN"}} +| mapColumn "@timestamp" + fn={getCell "@timestamp" | rounddate "YYYY-MM"} +| alterColumn "@timestamp" type="date" +| pointseries x="@timestamp" y="mean(cost)" color="country" +| plot defaultStyle={seriesStyle points="2" lines="1"} + palette={palette "#01A4A4" "#CC6666" "#D0D102" "#616161" "#00A1CB" "#32742C" "#F18D05" "#113F8C" "#61AE24" "#D70060" gradient=false} +| render`, + help: + 'This uses `filterrows` to only keep data from India (`IN`), the United States (`US`), and China (`CN`).', + }, + }, + filters: { + syntax: `filters +filters group="timefilter1" +filters group="timefilter2" group="dropdownfilter1" ungrouped=true`, + usage: { + expression: `filters group=group2 ungrouped=true +| demodata +| pointseries x="project" y="size(cost)" color="project" +| plot defaultStyle={seriesStyle bars=0.75} legend=false + font={ + font size=14 + family="'Open Sans', Helvetica, Arial, sans-serif" + align="left" + color="#FFFFFF" + weight="lighter" + underline=true + italic=true + } +| render`, + help: + '`filters` sets the existing filters as context and accepts a `group` parameter to opt into specific filter groups. Setting `ungrouped` to `true` opts out of using global filters.', + }, + }, + font: { + syntax: `font size=12 +font family=Arial +font align=middle +font color=pink +font weight=lighter +font underline=true +font italic=false +font lHeight=32`, + usage: { + expression: `filters +| demodata +| pointseries x="project" y="size(cost)" color="project" +| plot defaultStyle={seriesStyle bars=0.75} legend=false + font={ + font size=14 + family="'Open Sans', Helvetica, Arial, sans-serif" + align="left" + color="#FFFFFF" + weight="lighter" + underline=true + italic=true + } +| render`, + }, + }, + formatdate: { + syntax: `formatdate format="YYYY-MM-DD" +formatdate "MM/DD/YYYY"`, + usage: { + expression: `filters +| demodata +| mapColumn "time" fn={getCell time | formatdate "MMM 'YY"} +| pointseries x="time" y="sum(price)" color="state" +| plot defaultStyle={seriesStyle points=5} +| render`, + help: + 'This transforms the dates in the `time` field into strings that look like `"Jan ‘19"`, `"Feb ‘19"`, etc. using a MomentJS format.', + }, + }, + formatnumber: { + syntax: `formatnumber format="$0,0.00" +formatnumber "0.0a"`, + usage: { + expression: `filters +| demodata +| math "mean(percent_uptime)" +| progress shape="gauge" + label={formatnumber "0%"} + font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" color="#000000" align="center"} +| render`, + help: + 'The `formatnumber` subexpression receives the same `context` as the `progress` function, which is the output of the `math` function. It formats the value into a percentage.', + }, + }, +}); diff --git a/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx b/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx new file mode 100644 index 0000000000000..c527b322dba57 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { ExpressionFunction } from 'src/plugins/expressions'; +import { EuiButtonEmpty } from '@elastic/eui'; +import copy from 'copy-to-clipboard'; +import { notifyService } from '../../services'; +import { generateFunctionReference } from './generate_function_reference'; + +interface Props { + functionRegistry: Record; +} + +export const FunctionReferenceGenerator: FC = ({ functionRegistry }) => { + const functionDefinitions = Object.values(functionRegistry); + + const copyDocs = () => { + copy(generateFunctionReference(functionDefinitions)); + notifyService + .getService() + .success( + `Please paste updated docs into '/kibana/docs/canvas/canvas-function-reference.asciidoc' and commit your changes.`, + { title: 'Copied function docs to clipboard' } + ); + }; + + return ( + + Generate function reference + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts b/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts new file mode 100644 index 0000000000000..bd77fbf62ec5a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts @@ -0,0 +1,259 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-expect-error untyped lib +import pluralize from 'pluralize'; +import { ExpressionFunction, ExpressionFunctionParameter } from 'src/plugins/expressions'; +import { functions as browserFunctions } from '../../../canvas_plugin_src/functions/browser'; +import { functions as serverFunctions } from '../../../canvas_plugin_src/functions/server'; +import { isValidDataUrl, DATATABLE_COLUMN_TYPES } from '../../../common/lib'; +import { getFunctionExamples, FunctionExample } from './function_examples'; + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'.split(''); +const REQUIRED_ARG_ANNOTATION = '***'; +const MULTI_ARG_ANNOTATION = '†'; +const UNNAMED_ARG = '_Unnamed_'; +const ANY_TYPE = '`any`'; + +const examplesDict = getFunctionExamples(); + +const fnList = [ + ...browserFunctions.map((fn) => fn().name), + ...serverFunctions.map((fn) => fn().name), + 'asset', + 'filters', + 'timelion', + 'to', + 'font', + 'var', + 'var_set', + // ignore unsupported embeddables functions for now +].filter((fn) => !['savedSearch'].includes(fn)); + +interface FunctionDictionary { + [key: string]: ExpressionFunction[]; +} + +const wrapInBackTicks = (str: string) => `\`${str}\``; +const wrapInDoubleQuotes = (str: string) => (str.includes('"') ? str : `"${str}"`); +const stringSorter = (a: string, b: string) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +}; + +// Converts reference to another function in a function's help text into an Asciidoc link +const addFunctionLinks = (help: string, options?: { ignoreList?: string[] }) => { + const { ignoreList = [] } = options || {}; + fnList.forEach((name: string) => { + const nameWithBackTicks = wrapInBackTicks(name); + + // ignore functions with the same name as data types, i.e. string, date + if ( + !ignoreList.includes(name) && + !DATATABLE_COLUMN_TYPES.includes(name) && + help.includes(nameWithBackTicks) + ) { + help = help.replace(nameWithBackTicks, `<<${name}_fn>>`); + } + }); + + return help; +}; + +export const generateFunctionReference = (functionDefinitions: ExpressionFunction[]) => { + const functionDefs = functionDefinitions.filter((fn: ExpressionFunction) => + fnList.includes(fn.name) + ); + const functionDictionary: FunctionDictionary = {}; + functionDefs.forEach((fn: ExpressionFunction) => { + const firstLetter = fn.name[0]; + + if (!functionDictionary[firstLetter]) { + functionDictionary[firstLetter] = []; + } + + functionDictionary[firstLetter].push(fn); + }); + return `[role="xpack"] +[[canvas-function-reference]] +== Canvas function reference + +Behind the scenes, Canvas is driven by a powerful expression language, +with dozens of functions and other capabilities, including table transforms, +type casting, and sub-expressions. + +The Canvas expression language also supports <>, which +perform complex math calculations. + +A ${REQUIRED_ARG_ANNOTATION} denotes a required argument. + +A ${MULTI_ARG_ANNOTATION} denotes an argument can be passed multiple times. + +${createAlphabetLinks(functionDictionary)} + +${createFunctionDocs(functionDictionary)}`; +}; + +const createAlphabetLinks = (functionDictionary: FunctionDictionary) => { + return ALPHABET.map((letter: string) => + functionDictionary[letter] ? `<<${letter}_fns>>` : letter.toUpperCase() + ).join(' | '); +}; + +const createFunctionDocs = (functionDictionary: FunctionDictionary) => { + return Object.keys(functionDictionary) + .sort() + .map( + (letter: string) => `[float] +[[${letter}_fns]] +== ${letter.toUpperCase()} + +${functionDictionary[letter] + .sort((a, b) => stringSorter(a.name, b.name)) + .map(getDocBlock) + .join('\n')}` + ) + .join(''); +}; + +const getDocBlock = (fn: ExpressionFunction) => { + const header = `[float] +[[${fn.name}_fn]] +=== \`${fn.name}\``; + + const input = fn.inputTypes; + const output = fn.type; + const args = fn.args; + const examples = examplesDict[fn.name]; + const help = addFunctionLinks(fn.help); + + const argBlock = + !args || Object.keys(args).length === 0 + ? '' + : `\n[cols="3*^<"] +|=== +|Argument |Type |Description + +${getArgsTable(args)} +|===\n`; + + const examplesBlock = !examples ? `` : `${getExamplesBlock(examples)}`; + + return `${header}\n +${help} +${examplesBlock} +*Accepts:* ${input ? input.map(wrapInBackTicks).join(', ') : ANY_TYPE}\n${argBlock} +*Returns:* ${output ? wrapInBackTicks(output) : 'Depends on your input and arguments'}\n\n`; +}; + +const getArgsTable = (args: { [key: string]: ExpressionFunctionParameter }) => { + if (!args || Object.keys(args).length === 0) { + return 'None'; + } + + const argNames = Object.keys(args); + + return argNames + .sort((a: string, b: string) => { + const argA = args[a]; + const argB = args[b]; + + // sorts unnamed arg to the front + if (a === '_' || (argA.aliases && argA.aliases.includes('_'))) { + return -1; + } + if (b === '_' || (argB.aliases && argB.aliases.includes('_'))) { + return 1; + } + return stringSorter(a, b); + }) + .map((argName: string) => { + const arg = args[argName]; + const types = arg.types; + const aliases = arg.aliases ? [...arg.aliases] : []; + let defaultValue = arg.default; + const requiredAnnotation = arg.required === true ? ` ${REQUIRED_ARG_ANNOTATION}` : ''; + const multiAnnotation = arg.multi === true ? ` ${MULTI_ARG_ANNOTATION}` : ''; + + if (typeof defaultValue === 'string') { + defaultValue = defaultValue.replace('{', '${').replace(/[\r\n/]+/g, ''); + if (types && types.includes('string')) { + defaultValue = wrapInDoubleQuotes(defaultValue); + } + } + + let displayName = ''; + + if (argName === '_') { + displayName = UNNAMED_ARG; + } else if (aliases && aliases.includes('_')) { + displayName = UNNAMED_ARG; + aliases[aliases.indexOf('_')] = argName; + } else { + displayName = wrapInBackTicks(argName); + } + + const aliasList = + aliases && aliases.length + ? `\n\n${pluralize('Alias', aliases.length)}: ${aliases + .sort() + .map(wrapInBackTicks) + .join(', ')}` + : ''; + + let defaultBlock = ''; + + if (isValidDataUrl(arg.default)) { + defaultBlock = getDataUrlExampleBlock(displayName, arg.default); + } else { + defaultBlock = + typeof defaultValue !== 'undefined' ? `\n\nDefault: \`${defaultValue}\`` : ''; + } + + return `|${displayName}${requiredAnnotation}${multiAnnotation}${aliasList} +|${types && types.length ? types.map(wrapInBackTicks).join(', ') : ANY_TYPE} +|${arg.help ? addFunctionLinks(arg.help, { ignoreList: argNames }) : ''}${defaultBlock}`; + }) + .join('\n\n'); +}; + +const getDataUrlExampleBlock = ( + argName: string, + value: string +) => `\n\nExample value for the ${argName} argument, formatted as a \`base64\` data URL: +[source, url] +------------ +${value} +------------`; + +const getExamplesBlock = (examples: FunctionExample) => { + const { syntax, usage } = examples; + const { expression, help } = usage || {}; + const syntaxBlock = syntax + ? `\n*Expression syntax* +[source,js] +---- +${syntax} +----\n` + : ''; + + const codeBlock = expression + ? `\n*Code example* +[source,text] +---- +${expression} +----\n` + : ''; + + const codeHelp = help ? `${help}\n` : ''; + + return `${syntaxBlock}${codeBlock}${codeHelp}`; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js b/x-pack/plugins/canvas/public/components/function_reference_generator/index.ts similarity index 66% rename from x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js rename to x-pack/plugins/canvas/public/components/function_reference_generator/index.ts index f5661eae91a8c..337809238bb59 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/index.ts @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './delete_phase'; -export * from './cold_phase'; -export * from './hot_phase'; -export * from './warm_phase'; +export { FunctionReferenceGenerator } from './function_reference_generator'; diff --git a/x-pack/plugins/canvas/public/components/help_menu/help_menu.js b/x-pack/plugins/canvas/public/components/help_menu/help_menu.js deleted file mode 100644 index 4512ce2b4992e..0000000000000 --- a/x-pack/plugins/canvas/public/components/help_menu/help_menu.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, PureComponent } from 'react'; -import { EuiButtonEmpty, EuiPortal } from '@elastic/eui'; -import { KeyboardShortcutsDoc } from '../keyboard_shortcuts_doc'; -import { ComponentStrings } from '../../../i18n'; - -const { HelpMenu: strings } = ComponentStrings; - -export class HelpMenu extends PureComponent { - state = { isFlyoutVisible: false }; - - showFlyout = () => { - this.setState({ isFlyoutVisible: true }); - }; - - hideFlyout = () => { - this.setState({ isFlyoutVisible: false }); - }; - - render() { - return ( - - - {strings.getKeyboardShortcutsLinkLabel()} - - - {this.state.isFlyoutVisible && ( - - - - )} - - ); - } -} diff --git a/x-pack/plugins/canvas/public/components/help_menu/help_menu.tsx b/x-pack/plugins/canvas/public/components/help_menu/help_menu.tsx new file mode 100644 index 0000000000000..7122ec88f68a9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/help_menu/help_menu.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, lazy, Suspense } from 'react'; +import { EuiButtonEmpty, EuiPortal, EuiSpacer } from '@elastic/eui'; +import { ExpressionFunction } from 'src/plugins/expressions'; +import { ComponentStrings } from '../../../i18n'; +import { KeyboardShortcutsDoc } from '../keyboard_shortcuts_doc'; + +let FunctionReferenceGenerator: null | React.LazyExoticComponent = null; +if (process.env.NODE_ENV === 'development') { + FunctionReferenceGenerator = lazy(() => + import('../function_reference_generator').then((module) => ({ + default: module.FunctionReferenceGenerator, + })) + ); +} + +const { HelpMenu: strings } = ComponentStrings; + +interface Props { + functionRegistry: Record; +} + +export const HelpMenu: FC = ({ functionRegistry }) => { + const [isFlyoutVisible, setFlyoutVisible] = useState(false); + + const showFlyout = () => { + setFlyoutVisible(true); + }; + + const hideFlyout = () => { + setFlyoutVisible(false); + }; + + return ( + <> + + {strings.getKeyboardShortcutsLinkLabel()} + + + {FunctionReferenceGenerator ? ( + + + + + ) : null} + + {isFlyoutVisible && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/help_menu/index.js b/x-pack/plugins/canvas/public/components/help_menu/index.ts similarity index 100% rename from x-pack/plugins/canvas/public/components/help_menu/index.js rename to x-pack/plugins/canvas/public/components/help_menu/index.ts diff --git a/x-pack/plugins/canvas/public/feature_catalogue_entry.ts b/x-pack/plugins/canvas/public/feature_catalogue_entry.ts index 41506bdd71e3c..82237a5ddffed 100644 --- a/x-pack/plugins/canvas/public/feature_catalogue_entry.ts +++ b/x-pack/plugins/canvas/public/feature_catalogue_entry.ts @@ -15,6 +15,6 @@ export const featureCatalogueEntry = { }), icon: 'canvasApp', path: '/app/canvas', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js index c2c6f77fb167c..0bc6c099106a6 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js @@ -106,18 +106,18 @@ const cornerVertices = [ const resizeMultiplierHorizontal = { left: -1, center: 0, right: 1 }; const resizeMultiplierVertical = { top: -1, center: 0, bottom: 1 }; -const xNames = { '-1': 'left', '0': 'center', '1': 'right' }; -const yNames = { '-1': 'top', '0': 'center', '1': 'bottom' }; +const xNames = { '-1': 'left', 0: 'center', 1: 'right' }; +const yNames = { '-1': 'top', 0: 'center', 1: 'bottom' }; const bidirectionalCursors = { - '0': 'ns-resize', - '45': 'nesw-resize', - '90': 'ew-resize', - '135': 'nwse-resize', - '180': 'ns-resize', - '225': 'nesw-resize', - '270': 'ew-resize', - '315': 'nwse-resize', + 0: 'ns-resize', + 45: 'nesw-resize', + 90: 'ew-resize', + 135: 'nwse-resize', + 180: 'ns-resize', + 225: 'nesw-resize', + 270: 'ew-resize', + 315: 'nwse-resize', }; const identityAABB = () => [ diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 0269774a446c1..b02fb9db28612 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -40,7 +40,7 @@ export { CoreStart, CoreSetup }; export interface CanvasSetupDeps { data: DataPublicPluginSetup; expressions: ExpressionsSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; usageCollection?: UsageCollectionSetup; bfetch: BfetchPublicSetup; } @@ -116,7 +116,9 @@ export class CanvasPlugin }, }); - plugins.home.featureCatalogue.register(featureCatalogueEntry); + if (plugins.home) { + plugins.home.featureCatalogue.register(featureCatalogueEntry); + } canvasApi.addArgumentUIs(argTypeSpecs); canvasApi.addTransitions(transitions); diff --git a/x-pack/plugins/canvas/public/state/actions/elements.test.js b/x-pack/plugins/canvas/public/state/actions/elements.test.js index a790e81e65e25..fc27e422682e1 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.test.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.test.js @@ -12,7 +12,7 @@ describe('getSiblingContext', () => { resolvedArgs: { 'element-foo': { expressionContext: { - '0': { + 0: { state: 'ready', value: { type: 'datatable', @@ -28,7 +28,7 @@ describe('getSiblingContext', () => { }, error: null, }, - '1': { + 1: { state: 'ready', value: { type: 'datatable', @@ -44,7 +44,7 @@ describe('getSiblingContext', () => { }, error: null, }, - '2': { + 2: { state: 'ready', value: { type: 'pointseries', diff --git a/x-pack/plugins/canvas/public/state/reducers/resolved_args.test.js b/x-pack/plugins/canvas/public/state/reducers/resolved_args.test.js index 74f1544403e67..987e468031713 100644 --- a/x-pack/plugins/canvas/public/state/reducers/resolved_args.test.js +++ b/x-pack/plugins/canvas/public/state/reducers/resolved_args.test.js @@ -162,22 +162,22 @@ describe('resolved args reducer', () => { resolvedArgs: { 'element-1': { expressionContext: { - '1': { + 1: { state: 'ready', value: 'test-1', error: null, }, - '2': { + 2: { state: 'ready', value: 'test-2', error: null, }, - '3': { + 3: { state: 'ready', value: 'test-3', error: null, }, - '4': { + 4: { state: 'ready', value: 'test-4', error: null, diff --git a/x-pack/plugins/canvas/storybook/addon/tsconfig.json b/x-pack/plugins/canvas/storybook/addon/tsconfig.json index 9cab0af235f2e..deffd8ae605f8 100644 --- a/x-pack/plugins/canvas/storybook/addon/tsconfig.json +++ b/x-pack/plugins/canvas/storybook/addon/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "include": [ "src/**/*.ts", "src/**/*.tsx" diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx index a17d95c37c5ce..056feeb2b2167 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -25,6 +25,7 @@ import { import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public'; import { StartDependencies } from '../../../plugin'; import { Config, FactoryContext } from './types'; +import { SearchInput } from '../../../../../../../src/plugins/discover/public'; export interface Params { start: StartServicesGetter>; @@ -89,7 +90,7 @@ export class DashboardToDashboardDrilldown }; if (context.embeddable) { - const input = context.embeddable.getInput(); + const input = context.embeddable.getInput() as Readonly; if (isQuery(input.query) && config.useCurrentFilters) state.query = input.query; // if useCurrentDashboardDataRange is enabled, then preserve current time range diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts index 1f1cd938c97d1..d6a3c73aaf363 100644 --- a/x-pack/plugins/data_enhanced/common/index.ts +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EnhancedSearchParams, IEnhancedEsSearchRequest, IAsyncSearchRequest } from './search'; +export { + EnhancedSearchParams, + IEnhancedEsSearchRequest, + IAsyncSearchRequest, + ENHANCED_ES_SEARCH_STRATEGY, +} from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts index 129e412a47ccf..2ae422bd6b7d7 100644 --- a/x-pack/plugins/data_enhanced/common/search/index.ts +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EnhancedSearchParams, IEnhancedEsSearchRequest, IAsyncSearchRequest } from './types'; +export { + EnhancedSearchParams, + IEnhancedEsSearchRequest, + IAsyncSearchRequest, + ENHANCED_ES_SEARCH_STRATEGY, +} from './types'; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts index a5d7d326cecd5..0d3d3a69e1e57 100644 --- a/x-pack/plugins/data_enhanced/common/search/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/types.ts @@ -6,6 +6,8 @@ import { IEsSearchRequest, ISearchRequestParams } from '../../../../../src/plugins/data/common'; +export const ENHANCED_ES_SEARCH_STRATEGY = 'ese'; + export interface EnhancedSearchParams extends ISearchRequestParams { ignoreThrottled: boolean; } diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index 47099e32fcc72..6f7899d1188b4 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -14,7 +14,7 @@ import { } from '../../../../../src/plugins/data/public'; import { AbortError, toPromise } from '../../../../../src/plugins/data/common'; import { IAsyncSearchOptions } from '.'; -import { IAsyncSearchRequest } from '../../common'; +import { IAsyncSearchRequest, ENHANCED_ES_SEARCH_STRATEGY } from '../../common'; export class EnhancedSearchInterceptor extends SearchInterceptor { /** @@ -76,10 +76,11 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { const { combinedSignal, cleanup } = this.setupTimers(options); const aborted$ = from(toPromise(combinedSignal)); + const strategy = options?.strategy || ENHANCED_ES_SEARCH_STRATEGY; this.pendingCount$.next(this.pendingCount$.getValue() + 1); - return this.runSearch(request, combinedSignal, options?.strategy).pipe( + return this.runSearch(request, combinedSignal, strategy).pipe( expand((response) => { // If the response indicates of an error, stop polling and complete the observable if (!response || (!response.isRunning && response.isPartial)) { @@ -96,7 +97,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { return timer(pollInterval).pipe( // Send future requests using just the ID from the response mergeMap(() => { - return this.runSearch({ ...request, id }, combinedSignal, options?.strategy); + return this.runSearch({ ...request, id }, combinedSignal, strategy); }) ); }), diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 0e9731a414119..f9b6fd4e9ad64 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -11,7 +11,6 @@ import { Plugin, Logger, } from '../../../../src/core/server'; -import { ES_SEARCH_STRATEGY } from '../../../../src/plugins/data/common'; import { PluginSetup as DataPluginSetup, PluginStart as DataPluginStart, @@ -19,6 +18,7 @@ import { } from '../../../../src/plugins/data/server'; import { enhancedEsSearchStrategyProvider } from './search'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../common'; interface SetupDependencies { data: DataPluginSetup; @@ -36,13 +36,19 @@ export class EnhancedDataServerPlugin implements Plugin +export class ExploreDataChartAction + extends AbstractExploreDataAction implements Action { public readonly id = ACTION_EXPLORE_DATA_CHART; @@ -59,7 +63,7 @@ export class ExploreDataChartAction extends AbstractExploreDataAction; if (input.timeRange && !state.timeRange) state.timeRange = input.timeRange; if (input.query) state.query = input.query; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index bc74748806beb..6e748030fe107 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -17,7 +17,8 @@ export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; * This is "Explore underlying data" action which appears in the context * menu of a dashboard panel. */ -export class ExploreDataContextMenuAction extends AbstractExploreDataAction +export class ExploreDataContextMenuAction + extends AbstractExploreDataAction implements Action { public readonly id = ACTION_EXPLORE_DATA; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 99361107047c2..82d6bb9be15f6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -198,12 +198,15 @@ export class EncryptedSavedObjectsService { if (typeDefinition === undefined) { return attributes; } + let encryptionAAD: string | undefined; - const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); const encryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; if (attributeValue != null) { + if (!encryptionAAD) { + encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + } try { encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { @@ -376,8 +379,7 @@ export class EncryptedSavedObjectsService { if (typeDefinition === undefined) { return attributes; } - - const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + let encryptionAAD: string | undefined; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -393,7 +395,9 @@ export class EncryptedSavedObjectsService { )}` ); } - + if (!encryptionAAD) { + encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + } try { decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 5d4ea5a6370e4..f8d66b8ecac27 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -42,6 +42,19 @@ beforeEach(() => { afterEach(() => jest.clearAllMocks()); +describe('#checkConflicts', () => { + it('redirects request to underlying base client', async () => { + const objects = [{ type: 'foo', id: 'bar' }]; + const options = { namespace: 'some-namespace' }; + const mockedResponse = { errors: [] }; + mockBaseClient.checkConflicts.mockResolvedValue(mockedResponse); + + await expect(wrapper.checkConflicts(objects, options)).resolves.toEqual(mockedResponse); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledTimes(1); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledWith(objects, options); + }); +}); + describe('#create', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 3246457179f68..a2725cbc6a274 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -13,6 +13,7 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -48,6 +49,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options?: SavedObjectsBaseOptions + ) { + return await this.options.baseClient.checkConflicts(objects, options); + } + public async create( type: string, attributes: T = {} as T, diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts index 79e1efc425b4e..2d31be65dd30e 100644 --- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts +++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts @@ -29,6 +29,8 @@ export const DEFAULT_INITIAL_APP_DATA = { }, }, workplaceSearch: { + canCreateInvitations: true, + isFederatedAuth: false, organization: { name: 'ACME Donuts', defaultOrgName: 'My Organization', diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts index 52e468b741a07..008afb234a376 100644 --- a/x-pack/plugins/enterprise_search/common/types/index.ts +++ b/x-pack/plugins/enterprise_search/common/types/index.ts @@ -5,17 +5,14 @@ */ import { IAccount as IAppSearchAccount } from './app_search'; -import { IAccount as IWorkplaceSearchAccount, IOrganization } from './workplace_search'; +import { IWorkplaceSearchInitialData } from './workplace_search'; export interface IInitialAppData { readOnlyMode?: boolean; ilmEnabled?: boolean; configuredLimits?: IConfiguredLimits; appSearch?: IAppSearchAccount; - workplaceSearch?: { - organization: IOrganization; - fpAccount: IWorkplaceSearchAccount; - }; + workplaceSearch?: IWorkplaceSearchInitialData; } export interface IConfiguredLimits { diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index fd8fa6daf81ac..bc4e39b0788d9 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -17,3 +17,10 @@ export interface IOrganization { name: string; defaultOrgName: string; } + +export interface IWorkplaceSearchInitialData { + canCreateInvitations: boolean; + isFederatedAuth: boolean; + organization: IOrganization; + fpAccount: IAccount; +} diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 9a2daefcd8c6e..063c7a6a1fa19 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,9 +2,10 @@ "id": "enterpriseSearch", "version": "kibana", "kibanaVersion": "kibana", - "requiredPlugins": ["home", "features", "licensing"], + "requiredPlugins": ["features", "licensing"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection", "security"], + "optionalPlugins": ["usageCollection", "security", "home"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["home"] } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx similarity index 55% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx index 25a9fa7430c40..7e6876bc9b3a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx @@ -4,28 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../__mocks__/shallow_usecontext.mock'; +import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; -import { ErrorStatePrompt } from '../../../shared/error_state'; +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; -jest.mock('../../../shared/telemetry', () => ({ +jest.mock('../../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), SendAppSearchTelemetry: jest.fn(), })); -import { sendTelemetry } from '../../../shared/telemetry'; +import { sendTelemetry } from '../../../../shared/telemetry'; -import { ErrorState, EmptyState, LoadingState } from './'; - -describe('ErrorState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); - }); -}); +import { EmptyState } from './'; describe('EmptyState', () => { it('renders', () => { @@ -44,11 +35,3 @@ describe('EmptyState', () => { (sendTelemetry as jest.Mock).mockClear(); }); }); - -describe('LoadingState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx similarity index 84% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx index 9b0edb423bc52..58691cf09b4a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx @@ -8,14 +8,14 @@ import React, { useContext } from 'react'; import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { sendTelemetry } from '../../../shared/telemetry'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { KibanaContext, IKibanaContext } from '../../../index'; -import { CREATE_ENGINES_PATH } from '../../routes'; +import { sendTelemetry } from '../../../../shared/telemetry'; +import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { KibanaContext, IKibanaContext } from '../../../../index'; +import { CREATE_ENGINES_PATH } from '../../../routes'; -import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineOverviewHeader } from './header'; -import './empty_states.scss'; +import './empty_state.scss'; export const EmptyState: React.FC = () => { const { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx similarity index 77% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx index 7d2106f2a56f7..7f22ce132d405 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../__mocks__/shallow_usecontext.mock'; +import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; -jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); -import { sendTelemetry } from '../../../shared/telemetry'; +jest.mock('../../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../../shared/telemetry'; -import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineOverviewHeader } from './'; describe('EngineOverviewHeader', () => { it('renders', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx similarity index 92% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx index 7f67d00f5df91..1a1ae295d4828 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx @@ -15,8 +15,8 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { sendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; +import { sendTelemetry } from '../../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../../index'; export const EngineOverviewHeader: React.FC = () => { const { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/index.ts similarity index 87% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/index.ts index e92bf214c4cc7..794053f184f8c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export { EngineOverviewHeader } from './header'; export { LoadingState } from './loading_state'; export { EmptyState } from './empty_state'; -export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.test.tsx new file mode 100644 index 0000000000000..c894500550a0b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; +import { EuiLoadingContent } from '@elastic/eui'; + +import { LoadingState } from './'; + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.tsx similarity index 73% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.tsx index 221091b79dc54..07643560df3c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/loading_state.tsx @@ -7,17 +7,15 @@ import React from 'react'; import { EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { EngineOverviewHeader } from '../engine_overview_header'; - -import './empty_states.scss'; +import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { EngineOverviewHeader } from './header'; export const LoadingState: React.FC = () => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 45ab5dc5b9ab1..c2379fb33bd71 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -12,7 +12,7 @@ import { shallow, ReactWrapper } from 'enzyme'; import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; -import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { LoadingState, EmptyState } from './components'; import { EngineTable } from './engine_table'; import { EngineOverview } from './'; @@ -40,16 +40,6 @@ describe('EngineOverview', () => { expect(wrapper.find(EmptyState)).toHaveLength(1); }); - - it('hasErrorConnecting', async () => { - const wrapper = await mountWithAsyncContext(, { - http: { - ...mockHttp, - get: () => ({ invalidPayload: true }), - }, - }); - expect(wrapper.find(ErrorState)).toHaveLength(1); - }); }); describe('happy-path states', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index acac5d17665b7..74bcd9aeafb28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -22,8 +22,7 @@ import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; import MetaEnginesIcon from '../../assets/meta_engine.svg'; -import { LoadingState, EmptyState, ErrorState } from '../empty_states'; -import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineOverviewHeader, LoadingState, EmptyState } from './components'; import { EngineTable } from './engine_table'; import './engine_overview.scss'; @@ -42,8 +41,6 @@ export const EngineOverview: React.FC = () => { const { license } = useContext(LicenseContext) as ILicenseContext; const [isLoading, setIsLoading] = useState(true); - const [hasErrorConnecting, setHasErrorConnecting] = useState(false); - const [engines, setEngines] = useState([]); const [enginesPage, setEnginesPage] = useState(1); const [enginesTotal, setEnginesTotal] = useState(0); @@ -57,16 +54,12 @@ export const EngineOverview: React.FC = () => { }); }; const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => { - try { - const response = await getEnginesData(params); + const response = await getEnginesData(params); - callbacks.setResults(response.results); - callbacks.setResultsTotal(response.meta.page.total_results); + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); - setIsLoading(false); - } catch (error) { - setHasErrorConnecting(true); - } + setIsLoading(false); }; useEffect(() => { @@ -85,7 +78,6 @@ export const EngineOverview: React.FC = () => { } }, [license, metaEnginesPage]); - if (hasErrorConnecting) return ; if (isLoading) return ; if (!engines.length) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx new file mode 100644 index 0000000000000..8d48875a8e1f5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { ErrorConnecting } from './'; + +describe('ErrorConnecting', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx index c5a5f1fbb921f..34eb76d11a663 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx @@ -10,17 +10,13 @@ import { EuiPageContent } from '@elastic/eui'; import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { EngineOverviewHeader } from '../engine_overview_header'; -import './empty_states.scss'; - -export const ErrorState: React.FC = () => { +export const ErrorConnecting: React.FC = () => { return ( <> - diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/index.ts similarity index 80% rename from x-pack/plugins/uptime/public/lib/__mocks__/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/index.ts index 45ef5787927e1..c8b71e1a6e791 100644 --- a/x-pack/plugins/uptime/public/lib/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { mockHistory } from './react_router_history.mock'; +export { ErrorConnecting } from './error_connecting'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 0f4072c591bc7..94e9127bbed74 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -12,8 +12,10 @@ import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; import { useValues, useActions } from 'kea'; -import { SetupGuide } from './components/setup_guide'; import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { SetupGuide } from './components/setup_guide'; +import { ErrorConnecting } from './components/error_connecting'; +import { EngineOverview } from './components/engine_overview'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; describe('AppSearch', () => { @@ -42,12 +44,17 @@ describe('AppSearchUnconfigured', () => { }); describe('AppSearchConfigured', () => { - it('renders with layout', () => { + beforeEach(() => { + // Mock resets + (useValues as jest.Mock).mockImplementation(() => ({})); (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData: () => {} })); + }); + it('renders with layout', () => { const wrapper = shallow(); expect(wrapper.find(Layout)).toHaveLength(1); + expect(wrapper.find(EngineOverview)).toHaveLength(1); }); it('initializes app data with passed props', () => { @@ -62,12 +69,20 @@ describe('AppSearchConfigured', () => { it('does not re-initialize app data', () => { const initializeAppData = jest.fn(); (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); - (useValues as jest.Mock).mockImplementationOnce(() => ({ hasInitialized: true })); + (useValues as jest.Mock).mockImplementation(() => ({ hasInitialized: true })); shallow(); expect(initializeAppData).not.toHaveBeenCalled(); }); + + it('renders ErrorConnecting', () => { + (useValues as jest.Mock).mockImplementation(() => ({ errorConnecting: true })); + + const wrapper = shallow(); + + expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + }); }); describe('AppSearchNav', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 5f4734630624c..234201a157ec9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -11,6 +11,7 @@ import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { KibanaContext, IKibanaContext } from '../index'; +import { HttpLogic, IHttpLogicValues } from '../shared/http'; import { AppLogic, IAppLogicActions, IAppLogicValues } from './app_logic'; import { IInitialAppData } from '../../../common/types'; @@ -27,6 +28,7 @@ import { } from './routes'; import { SetupGuide } from './components/setup_guide'; +import { ErrorConnecting } from './components/error_connecting'; import { EngineOverview } from './components/engine_overview'; export const AppSearch: React.FC = (props) => { @@ -48,6 +50,7 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { hasInitialized } = useValues(AppLogic) as IAppLogicValues; const { initializeAppData } = useActions(AppLogic) as IAppLogicActions; + const { errorConnecting } = useValues(HttpLogic) as IHttpLogicValues; useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -60,14 +63,18 @@ export const AppSearchConfigured: React.FC = (props) => { }> - - - - - - - - + {errorConnecting ? ( + + ) : ( + + + + + + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index d6cc6e81509b2..60e4cedf413f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -22,6 +22,7 @@ import { } from 'src/core/public'; import { ClientConfigType, ClientData, PluginsSetup } from '../plugin'; import { LicenseProvider } from './shared/licensing'; +import { HttpProvider } from './shared/http'; import { IExternalUrl } from './shared/enterprise_search_url'; import { IInitialAppData } from '../../common/types'; @@ -48,7 +49,7 @@ export const renderApp = ( core: CoreStart, plugins: PluginsSetup, config: ClientConfigType, - { externalUrl, ...initialData }: ClientData + { externalUrl, errorConnecting, ...initialData }: ClientData ) => { resetContext({ createStore: true }); const store = getContext().store as Store; @@ -67,6 +68,7 @@ export const renderApp = ( > + @@ -77,7 +79,6 @@ export const renderApp = ( params.element ); return () => { - resetContext({}); ReactDOM.unmountComponentAtNode(params.element); }; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts new file mode 100644 index 0000000000000..a6957340d33d3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resetContext } from 'kea'; + +import { httpServiceMock } from 'src/core/public/mocks'; + +import { HttpLogic } from './http_logic'; + +describe('HttpLogic', () => { + const mockHttp = httpServiceMock.createSetupContract(); + const DEFAULT_VALUES = { + http: null, + httpInterceptors: [], + errorConnecting: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + }); + + it('has expected default values', () => { + HttpLogic.mount(); + expect(HttpLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('initializeHttp()', () => { + it('sets values based on passed props', () => { + HttpLogic.mount(); + HttpLogic.actions.initializeHttp({ http: mockHttp, errorConnecting: true }); + + expect(HttpLogic.values).toEqual({ + http: mockHttp, + httpInterceptors: [], + errorConnecting: true, + }); + }); + }); + + describe('setErrorConnecting()', () => { + it('sets errorConnecting value', () => { + HttpLogic.mount(); + HttpLogic.actions.setErrorConnecting(true); + expect(HttpLogic.values.errorConnecting).toEqual(true); + + HttpLogic.actions.setErrorConnecting(false); + expect(HttpLogic.values.errorConnecting).toEqual(false); + }); + }); + + describe('http interceptors', () => { + describe('initializeHttpInterceptors()', () => { + beforeEach(() => { + HttpLogic.mount(); + jest.spyOn(HttpLogic.actions, 'setHttpInterceptors'); + jest.spyOn(HttpLogic.actions, 'setErrorConnecting'); + HttpLogic.actions.initializeHttp({ http: mockHttp }); + + HttpLogic.actions.initializeHttpInterceptors(); + }); + + it('calls http.intercept and sets an array of interceptors', () => { + mockHttp.intercept.mockImplementationOnce(() => 'removeInterceptorFn' as any); + HttpLogic.actions.initializeHttpInterceptors(); + + expect(mockHttp.intercept).toHaveBeenCalled(); + expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith(['removeInterceptorFn']); + }); + + describe('errorConnectingInterceptor', () => { + it('handles errors connecting to Enterprise Search', async () => { + const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; + await responseError({ response: { url: '/api/app_search/engines', status: 502 } }); + + expect(HttpLogic.actions.setErrorConnecting).toHaveBeenCalled(); + }); + + it('does not handle non-502 Enterprise Search errors', async () => { + const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; + await responseError({ response: { url: '/api/workplace_search/overview', status: 404 } }); + + expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); + }); + + it('does not handle errors for unrelated calls', async () => { + const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; + await responseError({ response: { url: '/api/some_other_plugin/', status: 502 } }); + + expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); + }); + }); + }); + + it('sets httpInterceptors and calls all valid remove functions on unmount', () => { + const unmount = HttpLogic.mount(); + const httpInterceptors = [jest.fn(), undefined, jest.fn()] as any; + + HttpLogic.actions.setHttpInterceptors(httpInterceptors); + expect(HttpLogic.values.httpInterceptors).toEqual(httpInterceptors); + + unmount(); + expect(httpInterceptors[0]).toHaveBeenCalledTimes(1); + expect(httpInterceptors[2]).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts new file mode 100644 index 0000000000000..7bf7a19ed451f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.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 { kea } from 'kea'; + +import { HttpSetup } from 'src/core/public'; + +import { IKeaLogic, IKeaParams, TKeaReducers } from '../../shared/types'; + +export interface IHttpLogicValues { + http: HttpSetup; + httpInterceptors: Function[]; + errorConnecting: boolean; +} +export interface IHttpLogicActions { + initializeHttp({ http, errorConnecting }: { http: HttpSetup; errorConnecting?: boolean }): void; + initializeHttpInterceptors(): void; + setHttpInterceptors(httpInterceptors: Function[]): void; + setErrorConnecting(errorConnecting: boolean): void; +} + +export const HttpLogic = kea({ + actions: (): IHttpLogicActions => ({ + initializeHttp: ({ http, errorConnecting }) => ({ http, errorConnecting }), + initializeHttpInterceptors: () => null, + setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }), + setErrorConnecting: (errorConnecting) => ({ errorConnecting }), + }), + reducers: (): TKeaReducers => ({ + http: [ + (null as unknown) as HttpSetup, + { + initializeHttp: (_, { http }) => http, + }, + ], + httpInterceptors: [ + [], + { + setHttpInterceptors: (_, { httpInterceptors }) => httpInterceptors, + }, + ], + errorConnecting: [ + false, + { + initializeHttp: (_, { errorConnecting }) => !!errorConnecting, + setErrorConnecting: (_, { errorConnecting }) => errorConnecting, + }, + ], + }), + listeners: ({ values, actions }) => ({ + initializeHttpInterceptors: () => { + const httpInterceptors = []; + + const errorConnectingInterceptor = values.http.intercept({ + responseError: async (httpResponse) => { + const { url, status } = httpResponse.response!; + const hasErrorConnecting = status === 502; + const isApiResponse = + url.includes('/api/app_search/') || url.includes('/api/workplace_search/'); + + if (isApiResponse && hasErrorConnecting) { + actions.setErrorConnecting(true); + } + return httpResponse; + }, + }); + httpInterceptors.push(errorConnectingInterceptor); + + // TODO: Read only mode interceptor + actions.setHttpInterceptors(httpInterceptors); + }, + }), + events: ({ values }) => ({ + beforeUnmount: () => { + values.httpInterceptors.forEach((removeInterceptorFn?: Function) => { + if (removeInterceptorFn) removeInterceptorFn(); + }); + }, + }), +} as IKeaParams) as IKeaLogic< + IHttpLogicValues, + IHttpLogicActions +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx new file mode 100644 index 0000000000000..81106235780d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../__mocks__/shallow_usecontext.mock'; +import '../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { useActions } from 'kea'; + +import { HttpProvider } from './'; + +describe('HttpProvider', () => { + const props = { + http: {} as any, + errorConnecting: false, + }; + const initializeHttp = jest.fn(); + const initializeHttpInterceptors = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActions as jest.Mock).mockImplementationOnce(() => ({ + initializeHttp, + initializeHttpInterceptors, + })); + }); + + it('does not render', () => { + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('calls initialization actions on mount', () => { + shallow(); + + expect(initializeHttp).toHaveBeenCalledWith(props); + expect(initializeHttpInterceptors).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx new file mode 100644 index 0000000000000..6febc1869054f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx @@ -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 React, { useEffect } from 'react'; +import { useActions } from 'kea'; + +import { HttpSetup } from 'src/core/public'; + +import { HttpLogic, IHttpLogicActions } from './http_logic'; + +interface IHttpProviderProps { + http: HttpSetup; + errorConnecting?: boolean; +} + +export const HttpProvider: React.FC = (props) => { + const { initializeHttp, initializeHttpInterceptors } = useActions(HttpLogic) as IHttpLogicActions; + + useEffect(() => { + initializeHttp(props); + initializeHttpInterceptors(); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts new file mode 100644 index 0000000000000..449ff9d56debf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/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 { HttpLogic, IHttpLogicValues, IHttpLogicActions } from './http_logic'; +export { HttpProvider } from './http_provider'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index 74bb53ef3a954..a8e08323c5e3b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -14,7 +14,7 @@ export interface IFlashMessagesProps { } export interface IKeaLogic { - mount(): void; + mount(): Function; values: IKeaValues; actions: IKeaActions; } @@ -33,6 +33,7 @@ export interface IKeaLogic { export interface IKeaParams { selectors?(params: { selectors: IKeaValues }): void; listeners?(params: { actions: IKeaActions; values: IKeaValues }): void; + events?(params: { actions: IKeaActions; values: IKeaValues }): void; } /** @@ -47,7 +48,10 @@ export type TKeaReducers = { [Value in keyof IKeaValues]?: [ IKeaValues[Value], { - [Action in keyof IKeaActions]?: (state: IKeaValues, payload: IKeaValues) => IKeaValues[Value]; + [Action in keyof IKeaActions]?: ( + state: IKeaValues[Value], + payload: IKeaValues + ) => IKeaValues[Value]; } ]; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts new file mode 100644 index 0000000000000..bc31b7df5d971 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resetContext } from 'kea'; + +import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; +import { AppLogic } from './app_logic'; + +describe('AppLogic', () => { + beforeEach(() => { + resetContext({}); + AppLogic.mount(); + }); + + const DEFAULT_VALUES = { + hasInitialized: false, + }; + + it('has expected default values', () => { + expect(AppLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('initializeAppData()', () => { + it('sets values based on passed props', () => { + AppLogic.actions.initializeAppData(DEFAULT_INITIAL_APP_DATA); + + expect(AppLogic.values).toEqual({ + hasInitialized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts new file mode 100644 index 0000000000000..b7116f02663c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.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 { kea } from 'kea'; + +import { IInitialAppData } from '../../../common/types'; +import { IWorkplaceSearchInitialData } from '../../../common/types/workplace_search'; +import { IKeaLogic } from '../shared/types'; + +export interface IAppValues extends IWorkplaceSearchInitialData { + hasInitialized: boolean; +} +export interface IAppActions { + initializeAppData(props: IInitialAppData): void; +} + +export const AppLogic = kea({ + actions: (): IAppActions => ({ + initializeAppData: ({ workplaceSearch }) => workplaceSearch, + }), + reducers: () => ({ + hasInitialized: [ + false, + { + initializeAppData: () => true, + }, + ], + }), +}) as IKeaLogic; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index a55ff64014130..39280ad6f4be4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -5,35 +5,81 @@ */ import '../__mocks__/shallow_usecontext.mock'; +import '../__mocks__/kea.mock'; import React, { useContext } from 'react'; import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; +import { useValues, useActions } from 'kea'; -import { Overview } from './components/overview'; +import { SetupGuide } from './views/setup_guide'; +import { ErrorState } from './views/error_state'; +import { Overview } from './views/overview'; -import { WorkplaceSearch } from './'; +import { WorkplaceSearch, WorkplaceSearchUnconfigured, WorkplaceSearchConfigured } from './'; -describe('Workplace Search', () => { - describe('/', () => { - it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ - config: { host: '' }, - })); - const wrapper = shallow(); +describe('WorkplaceSearch', () => { + it('renders WorkplaceSearchUnconfigured when config.host is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: '' } })); + const wrapper = shallow(); - expect(wrapper.find(Redirect)).toHaveLength(1); - expect(wrapper.find(Overview)).toHaveLength(0); - }); + expect(wrapper.find(WorkplaceSearchUnconfigured)).toHaveLength(1); + }); + + it('renders WorkplaceSearchConfigured when config.host set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: 'some.url' } })); + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchConfigured)).toHaveLength(1); + }); +}); + +describe('WorkplaceSearchUnconfigured', () => { + it('renders the Setup Guide and redirects to the Setup Guide', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(1); + }); +}); + +describe('WorkplaceSearchConfigured', () => { + beforeEach(() => { + // Mock resets + (useValues as jest.Mock).mockImplementation(() => ({})); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData: () => {} })); + }); + + it('renders with layout', () => { + const wrapper = shallow(); + + expect(wrapper.find(Overview)).toHaveLength(1); + }); + + it('initializes app data with passed props', () => { + const initializeAppData = jest.fn(); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); + + shallow(); + + expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true }); + }); + + it('does not re-initialize app data', () => { + const initializeAppData = jest.fn(); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); + (useValues as jest.Mock).mockImplementation(() => ({ hasInitialized: true })); + + shallow(); + + expect(initializeAppData).not.toHaveBeenCalled(); + }); + + it('renders ErrorState', () => { + (useValues as jest.Mock).mockImplementation(() => ({ errorConnecting: true })); - it('renders the Overview when enterpriseSearchUrl is set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ - config: { host: 'https://foo.bar' }, - })); - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(Overview)).toHaveLength(1); - expect(wrapper.find(Redirect)).toHaveLength(0); - }); + expect(wrapper.find(ErrorState)).toHaveLength(2); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 94462aa8de7d1..c0a51d5670a14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -4,32 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { Route, Redirect, Switch } from 'react-router-dom'; +import { useActions, useValues } from 'kea'; import { IInitialAppData } from '../../../common/types'; import { KibanaContext, IKibanaContext } from '../index'; +import { HttpLogic, IHttpLogicValues } from '../shared/http'; +import { AppLogic, IAppActions, IAppValues } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav } from './components/layout/nav'; import { SETUP_GUIDE_PATH } from './routes'; -import { SetupGuide } from './components/setup_guide'; -import { Overview } from './components/overview'; +import { SetupGuide } from './views/setup_guide'; +import { ErrorState } from './views/error_state'; +import { Overview } from './views/overview'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useContext(KibanaContext) as IKibanaContext; - if (!config.host) - return ( - - - - - - - - - ); + return !config.host ? : ; +}; + +export const WorkplaceSearchConfigured: React.FC = (props) => { + const { hasInitialized } = useValues(AppLogic) as IAppValues; + const { initializeAppData } = useActions(AppLogic) as IAppActions; + const { errorConnecting } = useValues(HttpLogic) as IHttpLogicValues; + + useEffect(() => { + if (!hasInitialized) initializeAppData(props); + }, [hasInitialized]); return ( @@ -37,18 +41,33 @@ export const WorkplaceSearch: React.FC = (props) => { - + {errorConnecting ? : } }> - - - {/* Will replace with groups component subsequent PR */} -
- - + {errorConnecting ? ( + + ) : ( + + + {/* Will replace with groups component subsequent PR */} +
+ + + )} ); }; + +export const WorkplaceSearchUnconfigured: React.FC = () => ( + + + + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx index 53f3a7a274429..9ad649c292fb7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx @@ -13,7 +13,7 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; export const ErrorState: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/overview_logic.mock.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts index 395d2044e7dbc..5588c4fc53b67 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/overview_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts @@ -22,7 +22,6 @@ export const mockLogicValues = { personalSourcesCount: 0, sourcesCount: 0, dataLoading: true, - hasErrorConnecting: false, flashMessages: {}, } as IOverviewValues; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index d0f5893bdb88a..fa4decccb34b1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -21,12 +21,12 @@ import { EuiButtonEmptyProps, EuiLinkProps, } from '@elastic/eui'; -import sharedSourcesIcon from '../shared/assets/share_circle.svg'; +import sharedSourcesIcon from '../../components/shared/assets/share_circle.svg'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { OverviewLogic, IOverviewValues } from './overview_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 4c5efce9baf12..53549cfcdbce7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -11,7 +11,7 @@ import { useValues } from 'kea'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; import { OverviewLogic, IOverviewValues } from './overview_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx similarity index 84% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index 744fd8aeb1951..e4531ff03587b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -11,9 +11,8 @@ import { mockLogicActions, setMockValues } from './__mocks__'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { ErrorState } from '../error_state'; -import { Loading } from '../shared/loading'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { Loading } from '../../components/shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; @@ -27,13 +26,6 @@ describe('Overview', () => { expect(wrapper.find(Loading)).toHaveLength(1); }); - - it('hasErrorConnecting', () => { - setMockValues({ hasErrorConnecting: true }); - const wrapper = shallow(); - - expect(wrapper.find(ErrorState)).toHaveLength(1); - }); }); describe('happy-path states', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx similarity index 84% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index b816eb2973207..134fc9389694d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -6,22 +6,19 @@ // TODO: Remove EuiPage & EuiPageBody before exposing full app -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useActions, useValues } from 'kea'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; import { OverviewLogic, IOverviewActions, IOverviewValues } from './overview_logic'; -import { ErrorState } from '../error_state'; - -import { Loading } from '../shared/loading'; -import { ProductButton } from '../shared/product_button'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { Loading } from '../../components/shared/loading'; +import { ProductButton } from '../../components/shared/product_button'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; @@ -47,13 +44,10 @@ const HEADER_DESCRIPTION = i18n.translate( ); export const Overview: React.FC = () => { - const { http } = useContext(KibanaContext) as IKibanaContext; - const { initializeOverview } = useActions(OverviewLogic) as IOverviewActions; const { dataLoading, - hasErrorConnecting, hasUsers, hasOrgSources, isOldAccount, @@ -61,10 +55,9 @@ export const Overview: React.FC = () => { } = useValues(OverviewLogic) as IOverviewValues; useEffect(() => { - initializeOverview({ http }); + initializeOverview(); }, [initializeOverview]); - if (hasErrorConnecting) return ; if (dataLoading) return ; const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts similarity index 68% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts index 7df4de4719f31..3fbf0e60b5b49 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts @@ -5,24 +5,18 @@ */ import { resetContext } from 'kea'; -import { act } from 'react-dom/test-utils'; -import { mockKibanaContext } from '../../../__mocks__'; +jest.mock('../../../shared/http', () => ({ HttpLogic: { values: { http: { get: jest.fn() } } } })); +import { HttpLogic } from '../../../shared/http'; import { mockLogicValues } from './__mocks__'; import { OverviewLogic } from './overview_logic'; describe('OverviewLogic', () => { - let unmount: any; - beforeEach(() => { - resetContext({}); - unmount = OverviewLogic.mount() as any; jest.clearAllMocks(); - }); - - afterEach(() => { - unmount(); + resetContext({}); + OverviewLogic.mount(); }); it('has expected default values', () => { @@ -91,48 +85,14 @@ describe('OverviewLogic', () => { }); }); - describe('setHasErrorConnecting', () => { - it('will set `hasErrorConnecting`', () => { - OverviewLogic.actions.setHasErrorConnecting(true); - - expect(OverviewLogic.values.hasErrorConnecting).toEqual(true); - expect(OverviewLogic.values.dataLoading).toEqual(false); - }); - }); - describe('initializeOverview', () => { it('calls API and sets values', async () => { - const mockHttp = mockKibanaContext.http; - const mockApi = jest.fn(() => mockLogicValues as any); const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData'); - await act(async () => - OverviewLogic.actions.initializeOverview({ - http: { - ...mockHttp, - get: mockApi, - }, - }) - ); + await OverviewLogic.actions.initializeOverview(); - expect(mockApi).toHaveBeenCalledWith('/api/workplace_search/overview'); + expect(HttpLogic.values.http.get).toHaveBeenCalledWith('/api/workplace_search/overview'); expect(setServerDataSpy).toHaveBeenCalled(); }); - - it('handles error state', async () => { - const mockHttp = mockKibanaContext.http; - const setHasErrorConnectingSpy = jest.spyOn(OverviewLogic.actions, 'setHasErrorConnecting'); - - await act(async () => - OverviewLogic.actions.initializeOverview({ - http: { - ...mockHttp, - get: () => Promise.reject(), - }, - }) - ); - - expect(setHasErrorConnectingSpy).toHaveBeenCalled(); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 8bb177a2e742b..057bce1b4056c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'src/core/public'; - import { kea } from 'kea'; +import { HttpLogic } from '../../../shared/http'; import { IAccount, IOrganization } from '../../types'; import { IFlashMessagesProps, IKeaLogic, TKeaReducers, IKeaParams } from '../../../shared/types'; @@ -32,13 +31,11 @@ export interface IOverviewServerData { export interface IOverviewActions { setServerData(serverData: IOverviewServerData): void; setFlashMessages(flashMessages: IFlashMessagesProps): void; - setHasErrorConnecting(hasErrorConnecting: boolean): void; - initializeOverview({ http }: { http: HttpSetup }): void; + initializeOverview(): void; } export interface IOverviewValues extends IOverviewServerData { dataLoading: boolean; - hasErrorConnecting: boolean; flashMessages: IFlashMessagesProps; } @@ -46,8 +43,7 @@ export const OverviewLogic = kea({ actions: (): IOverviewActions => ({ setServerData: (serverData) => serverData, setFlashMessages: (flashMessages) => ({ flashMessages }), - setHasErrorConnecting: (hasErrorConnecting) => ({ hasErrorConnecting }), - initializeOverview: ({ http }) => ({ http }), + initializeOverview: () => null, }), reducers: (): TKeaReducers => ({ organization: [ @@ -138,24 +134,13 @@ export const OverviewLogic = kea({ true, { setServerData: () => false, - setHasErrorConnecting: () => false, - }, - ], - hasErrorConnecting: [ - false, - { - setHasErrorConnecting: (_, { hasErrorConnecting }) => hasErrorConnecting, }, ], }), listeners: ({ actions }): Partial => ({ - initializeOverview: async ({ http }: { http: HttpSetup }) => { - try { - const response = await http.get('/api/workplace_search/overview'); - actions.setServerData(response); - } catch (error) { - actions.setHasErrorConnecting(true); - } + initializeOverview: async () => { + const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); + actions.setServerData(response); }, }), } as IKeaParams) as IKeaLogic; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx similarity index 98% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 0f4f6c65d083c..ada89c33be7e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -12,7 +12,7 @@ import { useValues } from 'kea'; import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { getSourcePath } from '../../routes'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 148a50fb4a5ce..1ce6bae8ff603 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -12,7 +12,7 @@ import { AppMountParameters, HttpSetup, } from 'src/core/public'; - +import { i18n } from '@kbn/i18n'; import { FeatureCatalogueCategory, HomePublicPluginSetup, @@ -21,7 +21,11 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { IInitialAppData } from '../common/types'; -import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../common/constants'; +import { + ENTERPRISE_SEARCH_PLUGIN, + APP_SEARCH_PLUGIN, + WORKPLACE_SEARCH_PLUGIN, +} from '../common/constants'; import { ExternalUrl, IExternalUrl } from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; @@ -31,10 +35,11 @@ export interface ClientConfigType { } export interface ClientData extends IInitialAppData { externalUrl: IExternalUrl; + errorConnecting?: boolean; } export interface PluginsSetup { - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; } @@ -87,25 +92,48 @@ export class EnterpriseSearchPlugin implements Plugin { }, }); - plugins.home.featureCatalogue.register({ - id: APP_SEARCH_PLUGIN.ID, - title: APP_SEARCH_PLUGIN.NAME, - icon: AppSearchLogo, - description: APP_SEARCH_PLUGIN.DESCRIPTION, - path: APP_SEARCH_PLUGIN.URL, - category: FeatureCatalogueCategory.DATA, - showOnHomePage: true, - }); - - plugins.home.featureCatalogue.register({ - id: WORKPLACE_SEARCH_PLUGIN.ID, - title: WORKPLACE_SEARCH_PLUGIN.NAME, - icon: WorkplaceSearchLogo, - description: WORKPLACE_SEARCH_PLUGIN.DESCRIPTION, - path: WORKPLACE_SEARCH_PLUGIN.URL, - category: FeatureCatalogueCategory.DATA, - showOnHomePage: true, - }); + if (plugins.home) { + plugins.home.featureCatalogue.registerSolution({ + id: ENTERPRISE_SEARCH_PLUGIN.ID, + title: ENTERPRISE_SEARCH_PLUGIN.NAME, + subtitle: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', { + defaultMessage: 'Search everything', + }), + icon: 'logoEnterpriseSearch', + descriptions: [ + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', { + defaultMessage: 'Build a powerful search experience.', + }), + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', { + defaultMessage: 'Connect your users to relevant data.', + }), + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', { + defaultMessage: 'Unify your team content.', + }), + ], + path: APP_SEARCH_PLUGIN.URL, // TODO: Change this to enterprise search overview page once available + }); + + plugins.home.featureCatalogue.register({ + id: APP_SEARCH_PLUGIN.ID, + title: APP_SEARCH_PLUGIN.NAME, + icon: AppSearchLogo, + description: APP_SEARCH_PLUGIN.DESCRIPTION, + path: APP_SEARCH_PLUGIN.URL, + category: FeatureCatalogueCategory.DATA, + showOnHomePage: false, + }); + + plugins.home.featureCatalogue.register({ + id: WORKPLACE_SEARCH_PLUGIN.ID, + title: WORKPLACE_SEARCH_PLUGIN.NAME, + icon: WorkplaceSearchLogo, + description: WORKPLACE_SEARCH_PLUGIN.DESCRIPTION, + path: WORKPLACE_SEARCH_PLUGIN.URL, + category: FeatureCatalogueCategory.DATA, + showOnHomePage: false, + }); + } } public start(core: CoreStart) {} @@ -123,6 +151,7 @@ export class EnterpriseSearchPlugin implements Plugin { this.hasInitialized = true; } catch { + this.data.errorConnecting = true; // The plugin will attempt to re-fetch config data on page change } } diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index c26ada77f504f..323f79e63bc6f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -47,6 +47,8 @@ describe('callEnterpriseSearchConfigAPI', () => { onboarding_complete: true, }, workplace_search: { + can_create_invitations: true, + is_federated_auth: false, organization: { name: 'ACME Donuts', default_org_name: 'My Organization', @@ -136,6 +138,8 @@ describe('callEnterpriseSearchConfigAPI', () => { }, }, workplaceSearch: { + canCreateInvitations: false, + isFederatedAuth: false, organization: { name: undefined, defaultOrgName: undefined, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 1dbec76806ba8..c9cbec15169d9 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -90,6 +90,8 @@ export const callEnterpriseSearchConfigAPI = async ({ }, }, workplaceSearch: { + canCreateInvitations: !!data?.settings?.workplace_search?.can_create_invitations, + isFederatedAuth: !!data?.settings?.workplace_search?.is_federated_auth, organization: { name: data?.settings?.workplace_search?.organization?.name, defaultOrgName: data?.settings?.workplace_search?.organization?.default_org_name, diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 770ea8d420c20..a0d3a57eabb7a 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -75,7 +75,7 @@ export class EnterpriseSearchPlugin implements Plugin { icon: 'logoEnterpriseSearch', navLinkId: APP_SEARCH_PLUGIN.ID, // TODO - remove this once functional tests no longer rely on navLinkId app: ['kibana', APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], - catalogue: [APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], + catalogue: [ENTERPRISE_SEARCH_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], privileges: null, }); @@ -93,6 +93,7 @@ export class EnterpriseSearchPlugin implements Plugin { workplaceSearch: hasWorkplaceSearchAccess, }, catalogue: { + enterpriseSearch: hasAppSearchAccess || hasWorkplaceSearchAccess, appSearch: hasAppSearchAccess, workplaceSearch: hasWorkplaceSearchAccess, }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 968ecb95fd931..1ea023ecacdbe 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -68,10 +68,11 @@ describe('engine routes', () => { ).andReturnError(); }); - it('should return 404 with a message', async () => { + it('should return 502 with a message', async () => { await mockRouter.callRoute(mockRequest); - expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.customError).toHaveBeenCalledWith({ + statusCode: 502, body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); @@ -87,10 +88,11 @@ describe('engine routes', () => { ).andReturnInvalidData(); }); - it('should return 404 with a message', async () => { + it('should return 502 with a message', async () => { await mockRouter.callRoute(mockRequest); - expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.customError).toHaveBeenCalledWith({ + statusCode: 502, body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith( diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index ca83c0e187ddb..7190772fb92bb 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -52,7 +52,7 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies log.error(`Cannot connect to App Search: ${e.toString()}`); if (e instanceof Error) log.debug(e.stack as string); - return response.notFound({ body: 'cannot-connect' }); + return response.customError({ statusCode: 502, body: 'cannot-connect' }); } } ); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index 3a4e28b0de5ff..f6534b27b5da0 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -63,10 +63,11 @@ describe('engine routes', () => { }).andReturnError(); }); - it('should return 404 with a message', async () => { + it('should return 502 with a message', async () => { await mockRouter.callRoute(mockRequest); - expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.customError).toHaveBeenCalledWith({ + statusCode: 502, body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed'); @@ -81,10 +82,11 @@ describe('engine routes', () => { }).andReturnInvalidData(); }); - it('should return 404 with a message', async () => { + it('should return 502 with a message', async () => { await mockRouter.callRoute(mockRequest); - expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + expect(mockRouter.response.customError).toHaveBeenCalledWith({ + statusCode: 502, body: 'cannot-connect', }); expect(mockLogger.error).toHaveBeenCalledWith( diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts index d1e2f4f5f180d..9e5d94ac1b4fe 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -39,7 +39,7 @@ export function registerWSOverviewRoute({ router, config, log }: IRouteDependenc log.error(`Cannot connect to Workplace Search: ${e.toString()}`); if (e instanceof Error) log.debug(e.stack as string); - return response.notFound({ body: 'cannot-connect' }); + return response.customError({ statusCode: 502, body: 'cannot-connect' }); } } ); diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 1353877fa4629..4439a4fb9fdbb 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -85,7 +85,7 @@ export class Plugin implements CorePlugin { - toggleNavLink(checkLicense(license), core.chrome.navLinks); + const licenseInformation = checkLicense(license); + toggleNavLink(licenseInformation, core.chrome.navLinks); }); } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index e5037a6477aca..acf642f250a7b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -4,21 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PolicyFromES } from '../../../public/application/services/policies/types'; + export const POLICY_NAME = 'my_policy'; export const SNAPSHOT_POLICY_NAME = 'my_snapshot_policy'; export const NEW_SNAPSHOT_POLICY_NAME = 'my_new_snapshot_policy'; -export const DELETE_PHASE_POLICY = { +export const DELETE_PHASE_POLICY: PolicyFromES = { version: 1, - modified_date: Date.now(), + modified_date: Date.now().toString(), policy: { phases: { hot: { min_age: '0ms', actions: { - set_priority: { - priority: null, - }, rollover: { max_size: '50gb', }, @@ -36,6 +35,7 @@ export const DELETE_PHASE_POLICY = { }, }, }, + name: POLICY_NAME, }, name: POLICY_NAME, }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index ebe1c12e2a079..6365bb8caa963 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -13,7 +13,6 @@ import { POLICY_NAME } from './constants'; import { TestSubjects } from '../helpers'; import { EditPolicy } from '../../../public/application/sections/edit_policy'; -import { indexLifecycleManagementStore } from '../../../public/application/store'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -35,7 +34,6 @@ jest.mock('@elastic/eui', () => { }); const testBedConfig: TestBedConfig = { - store: () => indexLifecycleManagementStore(), memoryRouter: { initialEntries: [`/policies/edit/${POLICY_NAME}`], componentRoutePath: `/policies/edit/:policyName`, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index c6024ae3b90ae..36feb3f6203c8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -40,8 +40,8 @@ describe('', () => { test('wait for snapshot policy field should correctly display snapshot policy name', () => { expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ { - label: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, - value: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, + label: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, + value: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, }, ]); }); @@ -59,7 +59,7 @@ describe('', () => { delete: { ...DELETE_PHASE_POLICY.policy.phases.delete, actions: { - ...DELETE_PHASE_POLICY.policy.phases.delete.actions, + ...DELETE_PHASE_POLICY.policy.phases.delete?.actions, wait_for_snapshot: { policy: NEW_SNAPSHOT_POLICY_NAME, }, @@ -96,12 +96,11 @@ describe('', () => { delete: { ...DELETE_PHASE_POLICY.policy.phases.delete, actions: { - ...DELETE_PHASE_POLICY.policy.phases.delete.actions, + ...DELETE_PHASE_POLICY.policy.phases.delete?.actions, }, }, }, }; - // @ts-expect-error delete expected.phases.delete.actions.wait_for_snapshot; const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 4fe3d5c66696e..81c30579cd4dd 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -7,7 +7,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import moment from 'moment-timezone'; -import { Provider } from 'react-redux'; // axios has a $http like interface so using it to simulate $http import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; @@ -21,9 +20,7 @@ import { import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; -import { fetchedPolicies } from '../../public/application/store/actions'; -import { indexLifecycleManagementStore } from '../../public/application/store'; -import { EditPolicy } from '../../public/application/sections/edit_policy'; +import { EditPolicy } from '../../public/application/sections/edit_policy/edit_policy'; import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; import { init as initNotification } from '../../public/application/services/notification'; @@ -40,7 +37,7 @@ import { policyNameMustBeDifferentErrorMessage, policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, -} from '../../public/application/store/selectors'; +} from '../../public/application/services/policies/policy_validation'; initHttp(axios.create({ adapter: axiosXhrAdapter })); initUiMetric(usageCollectionPluginMock.createSetupContract()); @@ -51,7 +48,6 @@ initNotification( let server; let httpRequestsMockHelpers; -let store; const policy = { phases: { hot: { @@ -128,13 +124,14 @@ const save = (rendered) => { }; describe('edit policy', () => { beforeEach(() => { - store = indexLifecycleManagementStore(); component = ( - - {} }} getUrlForApp={() => {}} /> - + {} }} + getUrlForApp={() => {}} + policies={policies} + policyName={''} + /> ); - store.dispatch(fetchedPolicies(policies)); ({ server, httpRequestsMockHelpers } = initHttpRequests()); httpRequestsMockHelpers.setPoliciesResponse(policies); @@ -162,9 +159,12 @@ describe('edit policy', () => { }); test('should show error when trying to save as new policy but using the same name', () => { component = ( - - {}} /> - + {}} + history={{ push: () => {} }} + /> ); const rendered = mountWithIntl(component); findTestSubject(rendered, 'saveAsNewSwitch').simulate('click'); diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index 1a9f133b846fb..f899287642786 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -4,18 +4,19 @@ "server": true, "ui": true, "requiredPlugins": [ - "home", "licensing", "management" ], "optionalPlugins": [ "usageCollection", - "indexManagement" + "indexManagement", + "home" ], "configPath": ["xpack", "ilm"], "requiredBundles": [ "indexManagement", "kibanaReact", - "esUiShared" + "esUiShared", + "home" ] } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/app.tsx b/x-pack/plugins/index_lifecycle_management/public/application/app.tsx index 14b0e72317c66..f7f8b30324bca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/app.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/app.tsx @@ -9,7 +9,7 @@ import { Router, Switch, Route, Redirect } from 'react-router-dom'; import { ScopedHistory, ApplicationStart } from 'kibana/public'; import { METRIC_TYPE } from '@kbn/analytics'; -import { UIM_APP_LOAD } from './constants'; +import { UIM_APP_LOAD } from './constants/ui_metric'; import { EditPolicy } from './sections/edit_policy'; import { PolicyTable } from './sections/policy_table'; import { trackUiMetric } from './services/ui_metric'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts index 6319fc0d68543..61c197f2ba149 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts @@ -4,102 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './policy'; export * from './ui_metric'; - -export const SET_PHASE_DATA: string = 'SET_PHASE_DATA'; -export const SET_SELECTED_NODE_ATTRS: string = 'SET_SELECTED_NODE_ATTRS'; -export const PHASE_HOT: string = 'hot'; -export const PHASE_WARM: string = 'warm'; -export const PHASE_COLD: string = 'cold'; -export const PHASE_DELETE: string = 'delete'; - -export const PHASE_ENABLED: string = 'phaseEnabled'; - -export const PHASE_ROLLOVER_ENABLED: string = 'rolloverEnabled'; -export const WARM_PHASE_ON_ROLLOVER: string = 'warmPhaseOnRollover'; -export const PHASE_ROLLOVER_ALIAS: string = 'selectedAlias'; -export const PHASE_ROLLOVER_MAX_AGE: string = 'selectedMaxAge'; -export const PHASE_ROLLOVER_MAX_AGE_UNITS: string = 'selectedMaxAgeUnits'; -export const PHASE_ROLLOVER_MAX_SIZE_STORED: string = 'selectedMaxSizeStored'; -export const PHASE_ROLLOVER_MAX_DOCUMENTS: string = 'selectedMaxDocuments'; -export const PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS: string = 'selectedMaxSizeStoredUnits'; -export const PHASE_ROLLOVER_MINIMUM_AGE: string = 'selectedMinimumAge'; -export const PHASE_ROLLOVER_MINIMUM_AGE_UNITS: string = 'selectedMinimumAgeUnits'; - -export const PHASE_FORCE_MERGE_SEGMENTS: string = 'selectedForceMergeSegments'; -export const PHASE_FORCE_MERGE_ENABLED: string = 'forceMergeEnabled'; -export const PHASE_FREEZE_ENABLED: string = 'freezeEnabled'; - -export const PHASE_SHRINK_ENABLED: string = 'shrinkEnabled'; - -export const PHASE_NODE_ATTRS: string = 'selectedNodeAttrs'; -export const PHASE_PRIMARY_SHARD_COUNT: string = 'selectedPrimaryShardCount'; -export const PHASE_REPLICA_COUNT: string = 'selectedReplicaCount'; -export const PHASE_INDEX_PRIORITY: string = 'phaseIndexPriority'; - -export const PHASE_WAIT_FOR_SNAPSHOT_POLICY = 'waitForSnapshotPolicy'; - -export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE: string[] = [ - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_INDEX_PRIORITY, -]; -export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS: string[] = [ - ...PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_DOCUMENTS, -]; - -export const STRUCTURE_INDEX_TEMPLATE: string = 'indexTemplate'; -export const STRUCTURE_TEMPLATE_SELECTION: string = 'templateSelection'; -export const STRUCTURE_TEMPLATE_NAME: string = 'templateName'; -export const STRUCTURE_CONFIGURATION: string = 'configuration'; -export const STRUCTURE_NODE_ATTRS: string = 'node_attrs'; -export const STRUCTURE_PRIMARY_NODES: string = 'primary_nodes'; -export const STRUCTURE_REPLICAS: string = 'replicas'; - -export const STRUCTURE_POLICY_CONFIGURATION: string = 'policyConfiguration'; - -export const STRUCTURE_REVIEW: string = 'review'; -export const STRUCTURE_POLICY_NAME: string = 'policyName'; -export const STRUCTURE_INDEX_NAME: string = 'indexName'; -export const STRUCTURE_ALIAS_NAME: string = 'aliasName'; - -export const ERROR_STRUCTURE: any = { - [PHASE_HOT]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MAX_AGE]: [], - [PHASE_ROLLOVER_MAX_AGE_UNITS]: [], - [PHASE_ROLLOVER_MAX_SIZE_STORED]: [], - [PHASE_ROLLOVER_MAX_DOCUMENTS]: [], - [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_WARM]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - [PHASE_NODE_ATTRS]: [], - [PHASE_PRIMARY_SHARD_COUNT]: [], - [PHASE_REPLICA_COUNT]: [], - [PHASE_FORCE_MERGE_SEGMENTS]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_COLD]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - [PHASE_NODE_ATTRS]: [], - [PHASE_REPLICA_COUNT]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_DELETE]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - }, - [STRUCTURE_POLICY_NAME]: [], -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts new file mode 100644 index 0000000000000..3a19f03547b5b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SerializedPhase, + ColdPhase, + DeletePhase, + HotPhase, + WarmPhase, +} from '../services/policies/types'; + +export const defaultNewHotPhase: HotPhase = { + phaseEnabled: true, + rolloverEnabled: true, + selectedMaxAge: '30', + selectedMaxAgeUnits: 'd', + selectedMaxSizeStored: '50', + selectedMaxSizeStoredUnits: 'gb', + phaseIndexPriority: '100', + selectedMaxDocuments: '', +}; + +export const defaultNewWarmPhase: WarmPhase = { + phaseEnabled: false, + forceMergeEnabled: false, + selectedForceMergeSegments: '', + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + shrinkEnabled: false, + selectedPrimaryShardCount: '', + selectedReplicaCount: '', + warmPhaseOnRollover: true, + phaseIndexPriority: '50', +}; + +export const defaultNewColdPhase: ColdPhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + freezeEnabled: false, + phaseIndexPriority: '0', +}; + +export const defaultNewDeletePhase: DeletePhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + waitForSnapshotPolicy: '', +}; + +export const serializedPhaseInitialization: SerializedPhase = { + min_age: '0ms', + actions: {}, +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx index a3278b6c231b9..9db40ebf5521f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx @@ -8,28 +8,22 @@ import React, { cloneElement, Children, Fragment, ReactElement } from 'react'; import { EuiFormRow, EuiFormRowProps } from '@elastic/eui'; type Props = EuiFormRowProps & { - errorKey: string; isShowingErrors: boolean; - errors: Record; + errors?: string[]; }; export const ErrableFormRow: React.FunctionComponent = ({ - errorKey, isShowingErrors, errors, children, ...rest }) => { return ( - 0} - error={errors[errorKey]} - {...rest} - > + 0} error={errors} {...rest}> {Children.map(children, (child) => cloneElement(child as ReactElement, { - isInvalid: isShowingErrors && errors[errorKey].length > 0, + isInvalid: errors && isShowingErrors && errors.length > 0, }) )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx index c9732f2311758..11b743ecc4bb6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx @@ -9,40 +9,35 @@ import { i18n } from '@kbn/i18n'; import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; -import { - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, -} from '../../../constants'; import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { ColdPhase, DeletePhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; -function getTimingLabelForPhase(phase: string) { +function getTimingLabelForPhase(phase: keyof Phases) { // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. switch (phase) { - case PHASE_WARM: + case 'warm': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel', { defaultMessage: 'Timing for warm phase', }); - case PHASE_COLD: + case 'cold': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel', { defaultMessage: 'Timing for cold phase', }); - case PHASE_DELETE: + case 'delete': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { defaultMessage: 'Timing for delete phase', }); } } -function getUnitsAriaLabelForPhase(phase: string) { +function getUnitsAriaLabelForPhase(phase: keyof Phases) { // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. switch (phase) { - case PHASE_WARM: + case 'warm': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel', { @@ -50,7 +45,7 @@ function getUnitsAriaLabelForPhase(phase: string) { } ); - case PHASE_COLD: + case 'cold': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel', { @@ -58,7 +53,7 @@ function getUnitsAriaLabelForPhase(phase: string) { } ); - case PHASE_DELETE: + case 'delete': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel', { @@ -68,24 +63,23 @@ function getUnitsAriaLabelForPhase(phase: string) { } } -interface Props { +interface Props { rolloverEnabled: boolean; - errors: Record; - phase: string; - // TODO add types for phaseData and setPhaseData after policy is typed - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; + errors?: PhaseValidationErrors; + phase: keyof Phases & string; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: string) => void; isShowingErrors: boolean; } -export const MinAgeInput: React.FunctionComponent = ({ +export const MinAgeInput = ({ rolloverEnabled, errors, phaseData, phase, setPhaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>): React.ReactElement => { let daysOptionLabel; let hoursOptionLabel; let minutesOptionLabel; @@ -192,15 +186,17 @@ export const MinAgeInput: React.FunctionComponent = ({ ); } + // check that these strings are valid properties + const selectedMinimumAgeProperty = propertyof('selectedMinimumAge'); + const selectedMinimumAgeUnitsProperty = propertyof('selectedMinimumAgeUnits'); return ( = ({ } > { - setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); + setPhaseData(selectedMinimumAgeProperty, e.target.value); }} min={0} /> @@ -227,8 +223,8 @@ export const MinAgeInput: React.FunctionComponent = ({ setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE_UNITS, e.target.value)} + value={phaseData.selectedMinimumAgeUnits} + onChange={(e) => setPhaseData(selectedMinimumAgeUnitsProperty, e.target.value)} options={[ { value: 'd', diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx index 576483a5ab9c2..0ce2c0d7ea566 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx @@ -16,20 +16,12 @@ import { EuiButton, } from '@elastic/eui'; -import { PHASE_NODE_ATTRS } from '../../../constants'; import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; import { useLoadNodes } from '../../../services/api'; import { NodeAttrsDetails } from './node_attrs_details'; - -interface Props { - phase: string; - errors: Record; - // TODO add types for phaseData and setPhaseData after policy is typed - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; - isShowingErrors: boolean; -} +import { ColdPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; const learnMoreLink = ( @@ -46,13 +38,20 @@ const learnMoreLink = ( ); -export const NodeAllocation: React.FunctionComponent = ({ +interface Props { + phase: keyof Phases & string; + errors?: PhaseValidationErrors; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: string) => void; + isShowingErrors: boolean; +} +export const NodeAllocation = ({ phase, setPhaseData, errors, phaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>) => { const { isLoading, data: nodes, error, sendRequest } = useLoadNodes(); const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( @@ -140,33 +139,35 @@ export const NodeAllocation: React.FunctionComponent = ({ ); } + // check that this string is a valid property + const nodeAttrsProperty = propertyof('selectedNodeAttrs'); + return ( { - setPhaseData(PHASE_NODE_ATTRS, e.target.value); + setPhaseData(nodeAttrsProperty, e.target.value); }} /> - {!!phaseData[PHASE_NODE_ATTRS] ? ( + {!!phaseData.selectedNodeAttrs ? ( setSelectedNodeAttrsForDetails(phaseData[PHASE_NODE_ATTRS])} + onClick={() => setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} > void; - // TODO add types for lifecycle after policy is typed - lifecycle: any; + policy: Policy; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ - close, - lifecycle, - policyName, -}) => { - // @ts-ignore until store is typed - const getEsJson = ({ phases }) => { +export const PolicyJsonFlyout: React.FunctionComponent = ({ close, policy, policyName }) => { + const getEsJson = ({ phases }: Policy) => { return JSON.stringify( { policy: { @@ -45,7 +40,7 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ }; const endpoint = `PUT _ilm/policy/${policyName || ''}`; - const request = `${endpoint}\n${getEsJson(lifecycle)}`; + const request = `${endpoint}\n${getEsJson(policy)}`; return ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx index 0034de85fce17..1da7508049f24 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx @@ -7,27 +7,27 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFieldNumber, EuiTextColor, EuiDescribedFormGroup } from '@elastic/eui'; -import { PHASE_INDEX_PRIORITY } from '../../../constants'; - import { LearnMoreLink } from './'; import { OptionalLabel } from './'; import { ErrableFormRow } from './'; +import { ColdPhase, HotPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -interface Props { - errors: Record; - // TODO add types for phaseData and setPhaseData after policy is typed - phase: string; - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; +interface Props { + errors?: PhaseValidationErrors; + phase: keyof Phases & string; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: any) => void; isShowingErrors: boolean; } -export const SetPriorityInput: React.FunctionComponent = ({ +export const SetPriorityInput = ({ errors, phaseData, phase, setPhaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>) => { + const phaseIndexPriorityProperty = propertyof('phaseIndexPriority'); return ( = ({ fullWidth > = ({ } - errorKey={PHASE_INDEX_PRIORITY} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.phaseIndexPriority} > { - setPhaseData(PHASE_INDEX_PRIORITY, e.target.value); + setPhaseData(phaseIndexPriorityProperty, e.target.value); }} min={0} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js deleted file mode 100644 index e7f20a66d09f0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js +++ /dev/null @@ -1,58 +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 { connect } from 'react-redux'; - -import { - getSaveAsNewPolicy, - getSelectedPolicy, - validateLifecycle, - getLifecycle, - getPolicies, - isPolicyListLoaded, - getIsNewPolicy, - getSelectedOriginalPolicyName, - getPhases, -} from '../../store/selectors'; - -import { - setSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - saveLifecyclePolicy, - fetchPolicies, - setPhaseData, -} from '../../store/actions'; - -import { findFirstError } from '../../services/find_errors'; -import { EditPolicy as PresentationComponent } from './edit_policy'; - -export const EditPolicy = connect( - (state) => { - const errors = validateLifecycle(state); - const firstError = findFirstError(errors); - return { - firstError, - errors, - selectedPolicy: getSelectedPolicy(state), - saveAsNewPolicy: getSaveAsNewPolicy(state), - lifecycle: getLifecycle(state), - policies: getPolicies(state), - isPolicyListLoaded: isPolicyListLoaded(state), - isNewPolicy: getIsNewPolicy(state), - originalPolicyName: getSelectedOriginalPolicyName(state), - phases: getPhases(state), - }; - }, - { - setSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - saveLifecyclePolicy, - fetchPolicies, - setPhaseData, - } -)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx new file mode 100644 index 0000000000000..359134e015f7f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RouteComponentProps } from 'react-router-dom'; +import { EuiButton, EuiCallOut, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useLoadPoliciesList } from '../../services/api'; + +import { EditPolicy as PresentationComponent } from './edit_policy'; + +interface RouterProps { + policyName: string; +} + +interface Props { + getUrlForApp: ( + appId: string, + options?: { + path?: string; + absolute?: boolean; + } + ) => string; +} + +export const EditPolicy: React.FunctionComponent> = ({ + match: { + params: { policyName }, + }, + getUrlForApp, + history, +}) => { + const { error, isLoading, data: policies, sendRequest } = useLoadPoliciesList(false); + if (isLoading) { + return ( + } + body={ + + } + /> + ); + } + if (error || !policies) { + const { statusCode, message } = error ? error : { statusCode: '', message: '' }; + return ( + + } + color="danger" + > +

+ {message} ({statusCode}) +

+ + + +
+ ); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js deleted file mode 100644 index a29ecd07c5e45..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { - EuiPage, - EuiPageBody, - EuiFieldText, - EuiPageContent, - EuiFormRow, - EuiTitle, - EuiText, - EuiSpacer, - EuiSwitch, - EuiHorizontalRule, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiDescribedFormGroup, -} from '@elastic/eui'; - -import { - PHASE_HOT, - PHASE_COLD, - PHASE_DELETE, - PHASE_WARM, - STRUCTURE_POLICY_NAME, - WARM_PHASE_ON_ROLLOVER, - PHASE_ROLLOVER_ENABLED, -} from '../../constants'; - -import { toasts } from '../../services/notification'; -import { findFirstError } from '../../services/find_errors'; -import { LearnMoreLink, PolicyJsonFlyout, ErrableFormRow } from './components'; - -import { HotPhase, WarmPhase, ColdPhase, DeletePhase } from './phases'; - -export class EditPolicy extends Component { - static propTypes = { - selectedPolicy: PropTypes.object.isRequired, - errors: PropTypes.object.isRequired, - }; - - constructor(props) { - super(props); - this.state = { - isShowingErrors: false, - isShowingPolicyJsonFlyout: false, - }; - } - - selectPolicy = (policyName) => { - const { setSelectedPolicy, policies } = this.props; - - const selectedPolicy = policies.find((policy) => { - return policy.name === policyName; - }); - - if (selectedPolicy) { - setSelectedPolicy(selectedPolicy); - } - }; - - componentDidMount() { - window.scrollTo(0, 0); - - const { - isPolicyListLoaded, - fetchPolicies, - match: { params: { policyName } } = { params: {} }, - } = this.props; - - if (policyName) { - const decodedPolicyName = decodeURIComponent(policyName); - if (isPolicyListLoaded) { - this.selectPolicy(decodedPolicyName); - } else { - fetchPolicies(true, () => { - this.selectPolicy(decodedPolicyName); - }); - } - } else { - this.props.setSelectedPolicy(null); - } - } - - backToPolicyList = () => { - this.props.setSelectedPolicy(null); - this.props.history.push('/policies'); - }; - - submit = async () => { - this.setState({ isShowingErrors: true }); - const { saveLifecyclePolicy, lifecycle, saveAsNewPolicy, firstError } = this.props; - if (firstError) { - toasts.addDanger( - i18n.translate('xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage', { - defaultMessage: 'Please fix the errors on this page.', - }) - ); - const errorRowId = `${firstError.replace('.', '-')}-row`; - const element = document.getElementById(errorRowId); - if (element) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } - } else { - const success = await saveLifecyclePolicy(lifecycle, saveAsNewPolicy); - if (success) { - this.backToPolicyList(); - } - } - }; - - togglePolicyJsonFlyout = () => { - this.setState(({ isShowingPolicyJsonFlyout }) => ({ - isShowingPolicyJsonFlyout: !isShowingPolicyJsonFlyout, - })); - }; - - render() { - const { - selectedPolicy, - errors, - setSaveAsNewPolicy, - saveAsNewPolicy, - setSelectedPolicyName, - isNewPolicy, - lifecycle, - originalPolicyName, - phases, - setPhaseData, - } = this.props; - const selectedPolicyName = selectedPolicy.name; - const { isShowingErrors, isShowingPolicyJsonFlyout } = this.state; - - return ( - - - - -

- {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} -

-
- -
- - -

- {' '} - - } - /> -

-
- - - - - {isNewPolicy ? null : ( - - - -

- - - - .{' '} - -

-
- -
- - - { - await setSaveAsNewPolicy(e.target.checked); - }} - label={ - - - - } - /> - -
- )} - - {saveAsNewPolicy || isNewPolicy ? ( - - - - -
- } - titleSize="s" - fullWidth - > - - } - > - { - await setSelectedPolicyName(e.target.value); - }} - /> - - - ) : null} - - - - - setPhaseData(PHASE_HOT, key, value)} - phaseData={phases[PHASE_HOT]} - setWarmPhaseOnRollover={(value) => - setPhaseData(PHASE_WARM, WARM_PHASE_ON_ROLLOVER, value) - } - /> - - - - setPhaseData(PHASE_WARM, key, value)} - phaseData={phases[PHASE_WARM]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - setPhaseData(PHASE_COLD, key, value)} - phaseData={phases[PHASE_COLD]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - setPhaseData(PHASE_DELETE, key, value)} - phaseData={phases[PHASE_DELETE]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - - - - - - {saveAsNewPolicy ? ( - - ) : ( - - )} - - - - - - - - - - - - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( - - )} - - - - - {this.state.isShowingPolicyJsonFlyout ? ( - this.setState({ isShowingPolicyJsonFlyout: false })} - /> - ) : null} -
- - - - ); - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx new file mode 100644 index 0000000000000..6cffde577b35e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -0,0 +1,383 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { toasts } from '../../services/notification'; + +import { Policy, PolicyFromES } from '../../services/policies/types'; +import { + validatePolicy, + ValidationErrors, + findFirstError, +} from '../../services/policies/policy_validation'; +import { savePolicy } from '../../services/policies/policy_save'; +import { + deserializePolicy, + getPolicyByName, + initializeNewPolicy, +} from '../../services/policies/policy_serialization'; + +import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; +import { ColdPhase, DeletePhase, HotPhase, WarmPhase } from './phases'; + +interface Props { + policies: PolicyFromES[]; + policyName: string; + getUrlForApp: ( + appId: string, + options?: { + path?: string; + absolute?: boolean; + } + ) => string; + history: any; +} +export const EditPolicy: React.FunctionComponent = ({ + policies, + policyName, + history, + getUrlForApp, +}) => { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + const [isShowingErrors, setIsShowingErrors] = useState(false); + const [errors, setErrors] = useState(); + const [isShowingPolicyJsonFlyout, setIsShowingPolicyJsonFlyout] = useState(false); + + const existingPolicy = getPolicyByName(policies, policyName); + + const [policy, setPolicy] = useState( + existingPolicy ? deserializePolicy(existingPolicy) : initializeNewPolicy(policyName) + ); + + const isNewPolicy: boolean = !Boolean(existingPolicy); + const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const originalPolicyName: string = existingPolicy ? existingPolicy.name : ''; + + const backToPolicyList = () => { + history.push('/policies'); + }; + + const submit = async () => { + setIsShowingErrors(true); + const [isValid, validationErrors] = validatePolicy( + saveAsNew, + policy, + policies, + originalPolicyName + ); + setErrors(validationErrors); + + if (!isValid) { + toasts.addDanger( + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage', { + defaultMessage: 'Please fix the errors on this page.', + }) + ); + const firstError = findFirstError(validationErrors); + const errorRowId = `${firstError ? firstError.replace('.', '-') : ''}-row`; + const element = document.getElementById(errorRowId); + if (element) { + element.scrollIntoView({ block: 'center', inline: 'nearest' }); + } + } else { + const success = await savePolicy(policy, isNewPolicy || saveAsNew, existingPolicy); + if (success) { + backToPolicyList(); + } + } + }; + + const togglePolicyJsonFlyout = () => { + setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); + }; + + const setPhaseData = (phase: 'hot' | 'warm' | 'cold' | 'delete', key: string, value: any) => { + setPolicy({ + ...policy, + phases: { + ...policy.phases, + [phase]: { ...policy.phases[phase], [key]: value }, + }, + }); + }; + + const setWarmPhaseOnRollover = (value: boolean) => { + setPolicy({ + ...policy, + phases: { + ...policy.phases, + hot: { + ...policy.phases.hot, + rolloverEnabled: value, + }, + warm: { + ...policy.phases.warm, + warmPhaseOnRollover: value, + }, + }, + }); + }; + + return ( + + + + +

+ {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create an index lifecycle policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + values: { originalPolicyName }, + })} +

+
+ +
+ + +

+ {' '} + + } + /> +

+
+ + + + {isNewPolicy ? null : ( + + +

+ + + + .{' '} + +

+
+ + + + { + setSaveAsNew(e.target.checked); + }} + label={ + + + + } + /> + +
+ )} + + {saveAsNew || isNewPolicy ? ( + + + + +
+ } + titleSize="s" + fullWidth + > + + } + > + { + setPolicy({ ...policy, name: e.target.value }); + }} + /> + + + ) : null} + + + + 0} + setPhaseData={(key, value) => setPhaseData('hot', key, value)} + phaseData={policy.phases.hot} + setWarmPhaseOnRollover={setWarmPhaseOnRollover} + /> + + + + 0} + setPhaseData={(key, value) => setPhaseData('warm', key, value)} + phaseData={policy.phases.warm} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + 0} + setPhaseData={(key, value) => setPhaseData('cold', key, value)} + phaseData={policy.phases.cold} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + 0} + getUrlForApp={getUrlForApp} + setPhaseData={(key, value) => setPhaseData('delete', key, value)} + phaseData={policy.phases.delete} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + + + + + + {saveAsNew ? ( + + ) : ( + + )} + + + + + + + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + + + + {isShowingPolicyJsonFlyout ? ( + setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} +
+
+ + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index babbbf7638ebe..fb32752fe24ea 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -18,12 +18,9 @@ import { EuiTextColor, } from '@elastic/eui'; -import { - PHASE_COLD, - PHASE_ENABLED, - PHASE_REPLICA_COUNT, - PHASE_FREEZE_ENABLED, -} from '../../../constants'; +import { ColdPhase as ColdPhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { LearnMoreLink, ActiveBadge, @@ -35,14 +32,21 @@ import { SetPriorityInput, } from '../components'; +const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', +}); + +const coldProperty = propertyof('cold'); +const phaseProperty = (propertyName: keyof ColdPhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof ColdPhaseInterface & string, value: string | boolean) => void; + phaseData: ColdPhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } - export class ColdPhase extends PureComponent { render() { const { @@ -53,10 +57,6 @@ export class ColdPhase extends PureComponent { hotPhaseRolloverEnabled, } = this.props; - const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', - }); - return (
{ defaultMessage="Cold phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null}
} @@ -91,10 +91,10 @@ export class ColdPhase extends PureComponent { defaultMessage="Activate cold phase" /> } - id={`${PHASE_COLD}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${coldProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="coldPhaseContent" /> @@ -103,20 +103,20 @@ export class ColdPhase extends PureComponent { fullWidth > - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_COLD} + phase={coldProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} /> - + phase={coldProperty} setPhaseData={setPhaseData} errors={errors} phaseData={phaseData} @@ -126,7 +126,7 @@ export class ColdPhase extends PureComponent { { } - errorKey={PHASE_REPLICA_COUNT} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.freezeEnabled} helpText={i18n.translate( 'xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText', { @@ -147,10 +146,10 @@ export class ColdPhase extends PureComponent { )} > { - setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); }} min={0} /> @@ -163,7 +162,7 @@ export class ColdPhase extends PureComponent { )} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( { > { - setPhaseData(PHASE_FREEZE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} label={freezeLabel} aria-label={freezeLabel} /> - errors={errors} phaseData={phaseData} - phase={PHASE_COLD} + phase={coldProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx index 0143cc4af24e3..d3c73090f25f2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx @@ -8,7 +8,9 @@ import React, { PureComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { PHASE_DELETE, PHASE_ENABLED, PHASE_WAIT_FOR_SNAPSHOT_POLICY } from '../../../constants'; +import { DeletePhase as DeletePhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { ActiveBadge, LearnMoreLink, @@ -18,11 +20,15 @@ import { SnapshotPolicies, } from '../components'; +const deleteProperty = propertyof('delete'); +const phaseProperty = (propertyName: keyof DeletePhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; + phaseData: DeletePhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; getUrlForApp: ( appId: string, @@ -55,7 +61,7 @@ export class DeletePhase extends PureComponent { defaultMessage="Delete phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null} } @@ -76,10 +82,10 @@ export class DeletePhase extends PureComponent { defaultMessage="Activate delete phase" /> } - id={`${PHASE_DELETE}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${deleteProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="deletePhaseContent" /> @@ -87,11 +93,11 @@ export class DeletePhase extends PureComponent { } fullWidth > - {phaseData[PHASE_ENABLED] ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_DELETE} + phase={deleteProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} @@ -100,7 +106,7 @@ export class DeletePhase extends PureComponent {
)} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( @@ -135,8 +141,8 @@ export class DeletePhase extends PureComponent { } > setPhaseData(PHASE_WAIT_FOR_SNAPSHOT_POLICY, value)} + value={phaseData.waitForSnapshotPolicy} + onChange={(value) => setPhaseData(phaseProperty('waitForSnapshotPolicy'), value)} getUrlForApp={getUrlForApp} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx index dbd48f3a85634..22f0114d16afe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx @@ -7,7 +7,6 @@ import React, { Fragment, PureComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; - import { EuiFlexGroup, EuiFlexItem, @@ -19,15 +18,9 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - PHASE_HOT, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_ROLLOVER_ENABLED, -} from '../../../constants'; +import { HotPhase as HotPhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { LearnMoreLink, ActiveBadge, @@ -36,11 +29,98 @@ import { SetPriorityInput, } from '../components'; +const maxSizeStoredUnits = [ + { + value: 'gb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.gigabytesLabel', { + defaultMessage: 'gigabytes', + }), + }, + { + value: 'mb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.megabytesLabel', { + defaultMessage: 'megabytes', + }), + }, + { + value: 'b', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.bytesLabel', { + defaultMessage: 'bytes', + }), + }, + { + value: 'kb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.kilobytesLabel', { + defaultMessage: 'kilobytes', + }), + }, + { + value: 'tb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.terabytesLabel', { + defaultMessage: 'terabytes', + }), + }, + { + value: 'pb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.petabytesLabel', { + defaultMessage: 'petabytes', + }), + }, +]; + +const maxAgeUnits = [ + { + value: 'd', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.daysLabel', { + defaultMessage: 'days', + }), + }, + { + value: 'h', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.hoursLabel', { + defaultMessage: 'hours', + }), + }, + { + value: 'm', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.minutesLabel', { + defaultMessage: 'minutes', + }), + }, + { + value: 's', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.secondsLabel', { + defaultMessage: 'seconds', + }), + }, + { + value: 'ms', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.millisecondsLabel', { + defaultMessage: 'milliseconds', + }), + }, + { + value: 'micros', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.microsecondsLabel', { + defaultMessage: 'microseconds', + }), + }, + { + value: 'nanos', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.nanosecondsLabel', { + defaultMessage: 'nanoseconds', + }), + }, +]; +const hotProperty = propertyof('hot'); +const phaseProperty = (propertyName: keyof HotPhaseInterface) => + propertyof(propertyName); + interface Props { - errors: Record; + errors?: PhaseValidationErrors; isShowingErrors: boolean; - phaseData: any; - setPhaseData: (key: string, value: any) => void; + phaseData: HotPhaseInterface; + setPhaseData: (key: keyof HotPhaseInterface & string, value: string | boolean) => void; setWarmPhaseOnRollover: (value: boolean) => void; } @@ -104,39 +184,36 @@ export class HotPhase extends PureComponent { > { - const { checked } = e.target; - setPhaseData(PHASE_ROLLOVER_ENABLED, checked); - setWarmPhaseOnRollover(checked); + setWarmPhaseOnRollover(e.target.checked); }} label={i18n.translate('xpack.indexLifecycleMgmt.hotPhase.enableRolloverLabel', { defaultMessage: 'Enable rollover', })} /> - {phaseData[PHASE_ROLLOVER_ENABLED] ? ( + {phaseData.rolloverEnabled ? ( { - setPhaseData(PHASE_ROLLOVER_MAX_SIZE_STORED, e.target.value); + setPhaseData(phaseProperty('selectedMaxSizeStored'), e.target.value); }} min={1} /> @@ -144,11 +221,10 @@ export class HotPhase extends PureComponent { { defaultMessage: 'Maximum index size units', } )} - value={phaseData[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]} + value={phaseData.selectedMaxSizeStoredUnits} onChange={(e) => { - setPhaseData(PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, e.target.value); + setPhaseData(phaseProperty('selectedMaxSizeStoredUnits'), e.target.value); }} - options={[ - { - value: 'gb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.gigabytesLabel', { - defaultMessage: 'gigabytes', - }), - }, - { - value: 'mb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.megabytesLabel', { - defaultMessage: 'megabytes', - }), - }, - { - value: 'b', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.bytesLabel', { - defaultMessage: 'bytes', - }), - }, - { - value: 'kb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.kilobytesLabel', { - defaultMessage: 'kilobytes', - }), - }, - { - value: 'tb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.terabytesLabel', { - defaultMessage: 'terabytes', - }), - }, - { - value: 'pb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.petabytesLabel', { - defaultMessage: 'petabytes', - }), - }, - ]} + options={maxSizeStoredUnits} /> @@ -207,22 +246,21 @@ export class HotPhase extends PureComponent { { - setPhaseData(PHASE_ROLLOVER_MAX_DOCUMENTS, e.target.value); + setPhaseData(phaseProperty('selectedMaxDocuments'), e.target.value); }} min={1} /> @@ -233,19 +271,18 @@ export class HotPhase extends PureComponent { { - setPhaseData(PHASE_ROLLOVER_MAX_AGE, e.target.value); + setPhaseData(phaseProperty('selectedMaxAge'), e.target.value); }} min={1} /> @@ -253,11 +290,10 @@ export class HotPhase extends PureComponent { { defaultMessage: 'Maximum age units', } )} - value={phaseData[PHASE_ROLLOVER_MAX_AGE_UNITS]} + value={phaseData.selectedMaxAgeUnits} onChange={(e) => { - setPhaseData(PHASE_ROLLOVER_MAX_AGE_UNITS, e.target.value); + setPhaseData(phaseProperty('selectedMaxAgeUnits'), e.target.value); }} - options={[ - { - value: 'd', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.daysLabel', { - defaultMessage: 'days', - }), - }, - { - value: 'h', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.hoursLabel', { - defaultMessage: 'hours', - }), - }, - { - value: 'm', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.minutesLabel', { - defaultMessage: 'minutes', - }), - }, - { - value: 's', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.secondsLabel', { - defaultMessage: 'seconds', - }), - }, - { - value: 'ms', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.millisecondsLabel', - { - defaultMessage: 'milliseconds', - } - ), - }, - { - value: 'micros', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.microsecondsLabel', - { - defaultMessage: 'microseconds', - } - ), - }, - { - value: 'nanos', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.nanosecondsLabel', - { - defaultMessage: 'nanoseconds', - } - ), - }, - ]} + options={maxAgeUnits} /> @@ -330,10 +314,10 @@ export class HotPhase extends PureComponent { ) : null} - errors={errors} phaseData={phaseData} - phase={PHASE_HOT} + phase={hotProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index 6ed81bf8f45d5..f7b8c60a5c71f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -18,16 +18,6 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - PHASE_WARM, - PHASE_ENABLED, - WARM_PHASE_ON_ROLLOVER, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_SHRINK_ENABLED, -} from '../../../constants'; import { LearnMoreLink, ActiveBadge, @@ -39,11 +29,33 @@ import { MinAgeInput, } from '../components'; +import { Phases, WarmPhase as WarmPhaseInterface } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + +const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { + defaultMessage: 'Shrink index', +}); + +const moveToWarmPhaseOnRolloverLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + { + defaultMessage: 'Move to warm phase on rollover', + } +); + +const forcemergeLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.forceMergeDataLabel', { + defaultMessage: 'Force merge data', +}); + +const warmProperty = propertyof('warm'); +const phaseProperty = (propertyName: keyof WarmPhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; + phaseData: WarmPhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } export class WarmPhase extends PureComponent { @@ -56,24 +68,6 @@ export class WarmPhase extends PureComponent { hotPhaseRolloverEnabled, } = this.props; - const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { - defaultMessage: 'Shrink index', - }); - - const moveToWarmPhaseOnRolloverLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', - { - defaultMessage: 'Move to warm phase on rollover', - } - ); - - const forcemergeLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.forceMergeDataLabel', - { - defaultMessage: 'Force merge data', - } - ); - return (
{ defaultMessage="Warm phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null}
} @@ -108,10 +102,10 @@ export class WarmPhase extends PureComponent { defaultMessage="Activate warm phase" /> } - id={`${PHASE_WARM}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${warmProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="warmPhaseContent" /> @@ -120,28 +114,28 @@ export class WarmPhase extends PureComponent { fullWidth > - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( {hotPhaseRolloverEnabled ? ( - + { - setPhaseData(WARM_PHASE_ON_ROLLOVER, e.target.checked); + setPhaseData(phaseProperty('warmPhaseOnRollover'), e.target.checked); }} /> ) : null} - {!phaseData[WARM_PHASE_ON_ROLLOVER] ? ( + {!phaseData.warmPhaseOnRollover ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_WARM} + phase={warmProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} @@ -151,8 +145,8 @@ export class WarmPhase extends PureComponent { - + phase={warmProperty} setPhaseData={setPhaseData} errors={errors} phaseData={phaseData} @@ -162,7 +156,7 @@ export class WarmPhase extends PureComponent { { } - errorKey={PHASE_REPLICA_COUNT} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.selectedReplicaCount} helpText={i18n.translate( 'xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText', { @@ -183,10 +176,10 @@ export class WarmPhase extends PureComponent { )} > { - setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + setPhaseData('selectedReplicaCount', e.target.value); }} min={0} /> @@ -199,7 +192,7 @@ export class WarmPhase extends PureComponent { ) : null} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( { { - setPhaseData(PHASE_SHRINK_ENABLED, e.target.checked); + setPhaseData(phaseProperty('shrinkEnabled'), e.target.checked); }} label={shrinkLabel} aria-label={shrinkLabel} @@ -235,28 +228,30 @@ export class WarmPhase extends PureComponent { />
- {phaseData[PHASE_SHRINK_ENABLED] ? ( + {phaseData.shrinkEnabled ? ( { - setPhaseData(PHASE_PRIMARY_SHARD_COUNT, e.target.value); + setPhaseData( + phaseProperty('selectedPrimaryShardCount'), + e.target.value + ); }} min={1} /> @@ -294,33 +289,32 @@ export class WarmPhase extends PureComponent { data-test-subj="forceMergeSwitch" label={forcemergeLabel} aria-label={forcemergeLabel} - checked={phaseData[PHASE_FORCE_MERGE_ENABLED]} + checked={phaseData.forceMergeEnabled} onChange={(e) => { - setPhaseData(PHASE_FORCE_MERGE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('forceMergeEnabled'), e.target.checked); }} aria-controls="forcemergeContent" />
- {phaseData[PHASE_FORCE_MERGE_ENABLED] ? ( + {phaseData.forceMergeEnabled ? ( { - setPhaseData(PHASE_FORCE_MERGE_SEGMENTS, e.target.value); + setPhaseData(phaseProperty('selectedForceMergeSegments'), e.target.value); }} min={1} /> @@ -328,10 +322,10 @@ export class WarmPhase extends PureComponent { ) : null}
- errors={errors} phaseData={phaseData} - phase={PHASE_WARM} + phase={warmProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js index 500ab44d96694..ec1cdb987f4b3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js @@ -38,7 +38,7 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; import { getIndexListUri } from '../../../../../../../index_management/public'; -import { UIM_EDIT_CLICK } from '../../../../constants'; +import { UIM_EDIT_CLICK } from '../../../../constants/ui_metric'; import { getPolicyPath } from '../../../../services/navigation'; import { flattenPanelTree } from '../../../../services/flatten_panel_tree'; import { trackUiMetric } from '../../../../services/ui_metric'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 61de37bbfad11..b80e9e70c54fa 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -12,10 +12,11 @@ import { UIM_POLICY_ATTACH_INDEX_TEMPLATE, UIM_POLICY_DETACH_INDEX, UIM_INDEX_RETRY_STEP, -} from '../constants'; +} from '../constants/ui_metric'; import { trackUiMetric } from './ui_metric'; import { sendGet, sendPost, sendDelete, useRequest } from './http'; +import { PolicyFromES, SerializedPolicy } from './policies/types'; interface GenericObject { [key: string]: any; @@ -44,7 +45,15 @@ export async function loadPolicies(withIndices: boolean) { return await sendGet('policies', { withIndices }); } -export async function savePolicy(policy: GenericObject) { +export const useLoadPoliciesList = (withIndices: boolean) => { + return useRequest({ + path: `policies`, + method: 'get', + query: { withIndices }, + }); +}; + +export async function savePolicy(policy: SerializedPolicy) { return await sendPost(`policies`, policy); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js deleted file mode 100644 index 12b53ad1eaf52..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js +++ /dev/null @@ -1,24 +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. - */ - -export const findFirstError = (object, topLevel = true) => { - let firstError; - const keys = topLevel ? ['policyName', 'hot', 'warm', 'cold', 'delete'] : Object.keys(object); - for (const key of keys) { - const value = object[key]; - if (Array.isArray(value) && value.length > 0) { - firstError = key; - break; - } else if (value) { - firstError = findFirstError(value, false); - if (firstError) { - firstError = `${key}.${firstError}`; - break; - } - } - } - return firstError; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts new file mode 100644 index 0000000000000..6cc43042ed4ff --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { isEmpty } from 'lodash'; +import { serializedPhaseInitialization } from '../../constants'; +import { AllocateAction, ColdPhase, SerializedColdPhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, +} from './policy_validation'; + +const coldPhaseInitialization: ColdPhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + freezeEnabled: false, + phaseIndexPriority: '', +}; + +export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhase => { + const phase = { ...coldPhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.freeze) { + phase.freezeEnabled = true; + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const coldPhaseToES = ( + phase: ColdPhase, + originalPhase: SerializedColdPhase | undefined +): SerializedColdPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.freezeEnabled) { + esPhase.actions.freeze = {}; + } else { + delete esPhase.actions.freeze; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateColdPhase = (phase: ColdPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts new file mode 100644 index 0000000000000..70e7c21da8cb6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.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 { serializedPhaseInitialization } from '../../constants'; +import { DeletePhase, SerializedDeletePhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, +} from './policy_validation'; + +const deletePhaseInitialization: DeletePhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + waitForSnapshotPolicy: '', +}; + +export const deletePhaseFromES = (phaseSerialized?: SerializedDeletePhase): DeletePhase => { + const phase = { ...deletePhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + + if (actions.wait_for_snapshot) { + phase.waitForSnapshotPolicy = actions.wait_for_snapshot.policy; + } + } + + return phase; +}; + +export const deletePhaseToES = ( + phase: DeletePhase, + originalEsPhase?: SerializedDeletePhase +): SerializedDeletePhase => { + if (!originalEsPhase) { + originalEsPhase = { ...serializedPhaseInitialization }; + } + const esPhase = { ...originalEsPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.waitForSnapshotPolicy) { + esPhase.actions.wait_for_snapshot = { + policy: phase.waitForSnapshotPolicy, + }; + } else { + delete esPhase.actions.wait_for_snapshot; + } + + return esPhase; +}; + +export const validateDeletePhase = (phase: DeletePhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts new file mode 100644 index 0000000000000..34ac8f3e270e6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { serializedPhaseInitialization } from '../../constants'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { HotPhase, SerializedHotPhase } from './types'; +import { + maximumAgeRequiredMessage, + maximumDocumentsRequiredMessage, + maximumSizeRequiredMessage, + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, + positiveNumbersAboveZeroErrorMessage, +} from './policy_validation'; + +const hotPhaseInitialization: HotPhase = { + phaseEnabled: false, + rolloverEnabled: false, + selectedMaxAge: '', + selectedMaxAgeUnits: 'd', + selectedMaxSizeStored: '', + selectedMaxSizeStoredUnits: 'gb', + phaseIndexPriority: '', + selectedMaxDocuments: '', +}; + +export const hotPhaseFromES = (phaseSerialized?: SerializedHotPhase): HotPhase => { + const phase: HotPhase = { ...hotPhaseInitialization }; + + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + + if (actions.rollover) { + const rollover = actions.rollover; + phase.rolloverEnabled = true; + if (rollover.max_age) { + const { size: maxAge, units: maxAgeUnits } = splitSizeAndUnits(rollover.max_age); + phase.selectedMaxAge = maxAge; + phase.selectedMaxAgeUnits = maxAgeUnits; + } + if (rollover.max_size) { + const { size: maxSize, units: maxSizeUnits } = splitSizeAndUnits(rollover.max_size); + phase.selectedMaxSizeStored = maxSize; + phase.selectedMaxSizeStoredUnits = maxSizeUnits; + } + if (rollover.max_docs) { + phase.selectedMaxDocuments = rollover.max_docs.toString(); + } + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const hotPhaseToES = ( + phase: HotPhase, + originalPhase?: SerializedHotPhase +): SerializedHotPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.rolloverEnabled) { + if (!esPhase.actions.rollover) { + esPhase.actions.rollover = {}; + } + if (isNumber(phase.selectedMaxAge)) { + esPhase.actions.rollover.max_age = `${phase.selectedMaxAge}${phase.selectedMaxAgeUnits}`; + } + if (isNumber(phase.selectedMaxSizeStored)) { + esPhase.actions.rollover.max_size = `${phase.selectedMaxSizeStored}${phase.selectedMaxSizeStoredUnits}`; + } + if (isNumber(phase.selectedMaxDocuments)) { + esPhase.actions.rollover.max_docs = parseInt(phase.selectedMaxDocuments, 10); + } + } else { + delete esPhase.actions.rollover; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateHotPhase = (phase: HotPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // if rollover is enabled + if (phase.rolloverEnabled) { + // either max_age, max_size or max_documents need to be set + if ( + !isNumber(phase.selectedMaxAge) && + !isNumber(phase.selectedMaxSizeStored) && + !isNumber(phase.selectedMaxDocuments) + ) { + phaseErrors.selectedMaxAge = [maximumAgeRequiredMessage]; + phaseErrors.selectedMaxSizeStored = [maximumSizeRequiredMessage]; + phaseErrors.selectedMaxDocuments = [maximumDocumentsRequiredMessage]; + } + + // max age, max size and max docs need to be above zero if set + if (isNumber(phase.selectedMaxAge) && parseInt(phase.selectedMaxAge, 10) < 1) { + phaseErrors.selectedMaxAge = [positiveNumbersAboveZeroErrorMessage]; + } + if (isNumber(phase.selectedMaxSizeStored) && parseInt(phase.selectedMaxSizeStored, 10) < 1) { + phaseErrors.selectedMaxSizeStored = [positiveNumbersAboveZeroErrorMessage]; + } + if (isNumber(phase.selectedMaxDocuments) && parseInt(phase.selectedMaxDocuments, 10) < 1) { + phaseErrors.selectedMaxDocuments = [positiveNumbersAboveZeroErrorMessage]; + } + } + + return { + ...phaseErrors, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts similarity index 58% rename from x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts index 0bb6543482bd6..12df071544952 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts @@ -5,28 +5,36 @@ */ import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; -import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants'; -import { showApiError } from '../../services/api_errors'; -import { toasts } from '../../services/notification'; -import { savePolicy as savePolicyApi } from '../../services/api'; -import { trackUiMetric, getUiMetricsForPhases } from '../../services/ui_metric'; +import { savePolicy as savePolicyApi } from '../api'; +import { showApiError } from '../api_errors'; +import { getUiMetricsForPhases, trackUiMetric } from '../ui_metric'; +import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants/ui_metric'; +import { toasts } from '../notification'; +import { Policy, PolicyFromES } from './types'; +import { serializePolicy } from './policy_serialization'; -export const saveLifecyclePolicy = (lifecycle, isNew) => async () => { +export const savePolicy = async ( + policy: Policy, + isNew: boolean, + originalEsPolicy?: PolicyFromES +): Promise => { + const serializedPolicy = serializePolicy(policy, originalEsPolicy?.policy); try { - await savePolicyApi(lifecycle); + await savePolicyApi(serializedPolicy); } catch (err) { const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.saveErrorMessage', { defaultMessage: 'Error saving lifecycle policy {lifecycleName}', - values: { lifecycleName: lifecycle.name }, + values: { lifecycleName: policy.name }, }); showApiError(err, title); return false; } - const uiMetrics = getUiMetricsForPhases(lifecycle.phases); + const uiMetrics = getUiMetricsForPhases(serializedPolicy.phases); uiMetrics.push(isNew ? UIM_POLICY_CREATE : UIM_POLICY_UPDATE); - trackUiMetric('count', uiMetrics); + trackUiMetric(METRIC_TYPE.COUNT, uiMetrics); const message = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.successfulSaveMessage', { defaultMessage: '{verb} lifecycle policy "{lifecycleName}"', @@ -38,7 +46,7 @@ export const saveLifecyclePolicy = (lifecycle, isNew) => async () => { : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.updatedMessage', { defaultMessage: 'Updated', }), - lifecycleName: lifecycle.name, + lifecycleName: policy.name, }, }); toasts.addSuccess(message); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts new file mode 100644 index 0000000000000..3953521df1817 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + defaultNewColdPhase, + defaultNewDeletePhase, + defaultNewHotPhase, + defaultNewWarmPhase, + serializedPhaseInitialization, +} from '../../constants'; + +import { Policy, PolicyFromES, SerializedPolicy } from './types'; + +import { hotPhaseFromES, hotPhaseToES } from './hot_phase'; +import { warmPhaseFromES, warmPhaseToES } from './warm_phase'; +import { coldPhaseFromES, coldPhaseToES } from './cold_phase'; +import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; + +export const splitSizeAndUnits = (field: string): { size: string; units: string } => { + let size = ''; + let units = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + units = result[2]; + } + + return { + size, + units, + }; +}; + +export const isNumber = (value: any): boolean => value !== '' && value !== null && isFinite(value); + +export const getPolicyByName = ( + policies: PolicyFromES[] | null | undefined, + policyName: string = '' +): PolicyFromES | undefined => { + if (policies && policies.length > 0) { + return policies.find((policy: PolicyFromES) => policy.name === policyName); + } +}; + +export const initializeNewPolicy = (newPolicyName: string = ''): Policy => { + return { + name: newPolicyName, + phases: { + hot: { ...defaultNewHotPhase }, + warm: { ...defaultNewWarmPhase }, + cold: { ...defaultNewColdPhase }, + delete: { ...defaultNewDeletePhase }, + }, + }; +}; + +export const deserializePolicy = (policy: PolicyFromES): Policy => { + const { + name, + policy: { phases }, + } = policy; + + return { + name, + phases: { + hot: hotPhaseFromES(phases.hot), + warm: warmPhaseFromES(phases.warm), + cold: coldPhaseFromES(phases.cold), + delete: deletePhaseFromES(phases.delete), + }, + }; +}; + +export const serializePolicy = ( + policy: Policy, + originalEsPolicy: SerializedPolicy = { + name: policy.name, + phases: { hot: { ...serializedPhaseInitialization } }, + } +): SerializedPolicy => { + const serializedPolicy = { + name: policy.name, + phases: { hot: hotPhaseToES(policy.phases.hot, originalEsPolicy.phases.hot) }, + } as SerializedPolicy; + if (policy.phases.warm.phaseEnabled) { + serializedPolicy.phases.warm = warmPhaseToES(policy.phases.warm, originalEsPolicy.phases.warm); + } + + if (policy.phases.cold.phaseEnabled) { + serializedPolicy.phases.cold = coldPhaseToES(policy.phases.cold, originalEsPolicy.phases.cold); + } + + if (policy.phases.delete.phaseEnabled) { + serializedPolicy.phases.delete = deletePhaseToES( + policy.phases.delete, + originalEsPolicy.phases.delete + ); + } + return serializedPolicy; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts new file mode 100644 index 0000000000000..545488be2cd5e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { validateHotPhase } from './hot_phase'; +import { validateWarmPhase } from './warm_phase'; +import { validateColdPhase } from './cold_phase'; +import { validateDeletePhase } from './delete_phase'; +import { ColdPhase, DeletePhase, HotPhase, Phase, Policy, PolicyFromES, WarmPhase } from './types'; + +export const propertyof = (propertyName: keyof T & string) => propertyName; + +export const numberRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', + { + defaultMessage: 'A number is required.', + } +); + +// TODO validation includes 0 -> should be non-negative number? +export const positiveNumberRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', + { + defaultMessage: 'Only positive numbers are allowed.', + } +); + +export const maximumAgeRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError', + { + defaultMessage: 'A maximum age is required.', + } +); + +export const maximumSizeRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError', + { + defaultMessage: 'A maximum index size is required.', + } +); + +export const maximumDocumentsRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError', + { + defaultMessage: 'Maximum documents is required.', + } +); + +export const positiveNumbersAboveZeroErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', + { + defaultMessage: 'Only numbers above 0 are allowed.', + } +); + +export const policyNameRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', + { + defaultMessage: 'A policy name is required.', + } +); + +export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', + { + defaultMessage: 'A policy name cannot start with an underscore.', + } +); +export const policyNameContainsCommaErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', + { + defaultMessage: 'A policy name cannot include a comma.', + } +); +export const policyNameContainsSpaceErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', + { + defaultMessage: 'A policy name cannot include a space.', + } +); + +export const policyNameTooLongErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', + { + defaultMessage: 'A policy name cannot be longer than 255 bytes.', + } +); +export const policyNameMustBeDifferentErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', + { + defaultMessage: 'The policy name must be different.', + } +); +export const policyNameAlreadyUsedErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', + { + defaultMessage: 'That policy name is already used.', + } +); +export type PhaseValidationErrors = { + [P in keyof Partial]: string[]; +}; + +export interface ValidationErrors { + hot: PhaseValidationErrors; + warm: PhaseValidationErrors; + cold: PhaseValidationErrors; + delete: PhaseValidationErrors; + policyName: string[]; +} + +export const validatePolicy = ( + saveAsNew: boolean, + policy: Policy, + policies: PolicyFromES[], + originalPolicyName: string +): [boolean, ValidationErrors] => { + const policyNameErrors: string[] = []; + if (!policy.name) { + policyNameErrors.push(policyNameRequiredMessage); + } else { + if (policy.name.startsWith('_')) { + policyNameErrors.push(policyNameStartsWithUnderscoreErrorMessage); + } + if (policy.name.includes(',')) { + policyNameErrors.push(policyNameContainsCommaErrorMessage); + } + if (policy.name.includes(' ')) { + policyNameErrors.push(policyNameContainsSpaceErrorMessage); + } + if (window.TextEncoder && new window.TextEncoder().encode(policy.name).length > 255) { + policyNameErrors.push(policyNameTooLongErrorMessage); + } + + if (saveAsNew && policy.name === originalPolicyName) { + policyNameErrors.push(policyNameMustBeDifferentErrorMessage); + } else if (policy.name !== originalPolicyName) { + const policyNames = policies.map((existingPolicy) => existingPolicy.name); + if (policyNames.includes(policy.name)) { + policyNameErrors.push(policyNameAlreadyUsedErrorMessage); + } + } + } + + const hotPhaseErrors = validateHotPhase(policy.phases.hot); + const warmPhaseErrors = validateWarmPhase(policy.phases.warm); + const coldPhaseErrors = validateColdPhase(policy.phases.cold); + const deletePhaseErrors = validateDeletePhase(policy.phases.delete); + const isValid = + policyNameErrors.length === 0 && + Object.keys(hotPhaseErrors).length === 0 && + Object.keys(warmPhaseErrors).length === 0 && + Object.keys(coldPhaseErrors).length === 0 && + Object.keys(deletePhaseErrors).length === 0; + return [ + isValid, + { + policyName: [...policyNameErrors], + hot: hotPhaseErrors, + warm: warmPhaseErrors, + cold: coldPhaseErrors, + delete: deletePhaseErrors, + }, + ]; +}; + +export const findFirstError = (errors?: ValidationErrors): string | undefined => { + if (!errors) { + return; + } + + if (errors.policyName.length > 0) { + return propertyof('policyName'); + } + + if (Object.keys(errors.hot).length > 0) { + return `${propertyof('hot')}.${Object.keys(errors.hot)[0]}`; + } + if (Object.keys(errors.warm).length > 0) { + return `${propertyof('warm')}.${Object.keys(errors.warm)[0]}`; + } + if (Object.keys(errors.cold).length > 0) { + return `${propertyof('cold')}.${Object.keys(errors.cold)[0]}`; + } + if (Object.keys(errors.delete).length > 0) { + return `${propertyof('delete')}.${Object.keys(errors.delete)[0]}`; + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts new file mode 100644 index 0000000000000..2e2ed5b38bb87 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 SerializedPolicy { + name: string; + phases: Phases; +} + +export interface Phases { + hot?: SerializedHotPhase; + warm?: SerializedWarmPhase; + cold?: SerializedColdPhase; + delete?: SerializedDeletePhase; +} + +export interface PolicyFromES { + modified_date: string; + name: string; + policy: SerializedPolicy; + version: number; +} + +export interface SerializedPhase { + min_age: string; + actions: { + [action: string]: any; + }; +} + +export interface SerializedHotPhase extends SerializedPhase { + actions: { + rollover?: { + max_size?: string; + max_age?: string; + max_docs?: number; + }; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedWarmPhase extends SerializedPhase { + actions: { + allocate?: AllocateAction; + shrink?: { + number_of_shards: number; + }; + forcemerge?: { + max_num_segments: number; + }; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedColdPhase extends SerializedPhase { + actions: { + freeze?: {}; + allocate?: AllocateAction; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedDeletePhase extends SerializedPhase { + actions: { + wait_for_snapshot?: { + policy: string; + }; + delete?: { + delete_searchable_snapshot: boolean; + }; + }; +} + +export interface AllocateAction { + number_of_replicas: number; + include: {}; + exclude: {}; + require: { + [attribute: string]: string; + }; +} + +export interface Policy { + name: string; + phases: { + hot: HotPhase; + warm: WarmPhase; + cold: ColdPhase; + delete: DeletePhase; + }; +} + +export interface Phase { + phaseEnabled: boolean; +} +export interface HotPhase extends Phase { + rolloverEnabled: boolean; + selectedMaxSizeStored: string; + selectedMaxSizeStoredUnits: string; + selectedMaxDocuments: string; + selectedMaxAge: string; + selectedMaxAgeUnits: string; + phaseIndexPriority: string; +} + +export interface WarmPhase extends Phase { + warmPhaseOnRollover: boolean; + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + selectedNodeAttrs: string; + selectedReplicaCount: string; + shrinkEnabled: boolean; + selectedPrimaryShardCount: string; + forceMergeEnabled: boolean; + selectedForceMergeSegments: string; + phaseIndexPriority: string; +} + +export interface ColdPhase extends Phase { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + selectedNodeAttrs: string; + selectedReplicaCount: string; + freezeEnabled: boolean; + phaseIndexPriority: string; +} + +export interface DeletePhase extends Phase { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + waitForSnapshotPolicy: string; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts new file mode 100644 index 0000000000000..c331f4ccce38f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { isEmpty } from 'lodash'; +import { serializedPhaseInitialization } from '../../constants'; +import { AllocateAction, WarmPhase, SerializedWarmPhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; + +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, + positiveNumbersAboveZeroErrorMessage, +} from './policy_validation'; + +const warmPhaseInitialization: WarmPhase = { + phaseEnabled: false, + warmPhaseOnRollover: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + shrinkEnabled: false, + selectedPrimaryShardCount: '', + forceMergeEnabled: false, + selectedForceMergeSegments: '', + phaseIndexPriority: '', +}; + +export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhase => { + const phase: WarmPhase = { ...warmPhaseInitialization }; + + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + if (phaseSerialized.min_age === '0ms') { + phase.warmPhaseOnRollover = true; + } else { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + } + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.forcemerge) { + const forcemerge = actions.forcemerge; + phase.forceMergeEnabled = true; + phase.selectedForceMergeSegments = forcemerge.max_num_segments.toString(); + } + + if (actions.shrink) { + phase.shrinkEnabled = true; + phase.selectedPrimaryShardCount = actions.shrink.number_of_shards + ? actions.shrink.number_of_shards.toString() + : ''; + } + } + return phase; +}; + +export const warmPhaseToES = ( + phase: WarmPhase, + originalEsPhase?: SerializedWarmPhase +): SerializedWarmPhase => { + if (!originalEsPhase) { + originalEsPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalEsPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + // If warm phase on rollover is enabled, delete min age field + // An index lifecycle switches to warm phase when rollover occurs, so you cannot specify a warm phase time + // They are mutually exclusive + if (phase.warmPhaseOnRollover) { + // @ts-expect-error + delete esPhase.min_age; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.forceMergeEnabled) { + esPhase.actions.forcemerge = { + max_num_segments: parseInt(phase.selectedForceMergeSegments, 10), + }; + } else { + delete esPhase.actions.forcemerge; + } + + if (phase.shrinkEnabled && isNumber(phase.selectedPrimaryShardCount)) { + esPhase.actions.shrink = { + number_of_shards: parseInt(phase.selectedPrimaryShardCount, 10), + }; + } else { + delete esPhase.actions.shrink; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateWarmPhase = (phase: WarmPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // if warm phase on rollover is disabled, min age needs to be a positive number + if (!phase.warmPhaseOnRollover) { + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + } + + // if forcemerge is enabled, force merge segments needs to be a number above zero + if (phase.forceMergeEnabled) { + if (!isNumber(phase.selectedForceMergeSegments)) { + phaseErrors.selectedForceMergeSegments = [numberRequiredMessage]; + } else if (parseInt(phase.selectedForceMergeSegments, 10) < 1) { + phaseErrors.selectedForceMergeSegments = [positiveNumbersAboveZeroErrorMessage]; + } + } + + // if shrink is enabled, primary shard count needs to be a number above zero + if (phase.shrinkEnabled) { + if (!isNumber(phase.selectedPrimaryShardCount)) { + phaseErrors.selectedPrimaryShardCount = [numberRequiredMessage]; + } else if (parseInt(phase.selectedPrimaryShardCount, 10) < 1) { + phaseErrors.selectedPrimaryShardCount = [positiveNumbersAboveZeroErrorMessage]; + } + } + + // replica count is optional, but if it's set, it needs to be a positive number + if (phase.selectedReplicaCount) { + if (!isNumber(phase.selectedReplicaCount)) { + phaseErrors.selectedReplicaCount = [numberRequiredMessage]; + } else if (parseInt(phase.selectedReplicaCount, 10) < 0) { + phaseErrors.selectedReplicaCount = [numberRequiredMessage]; + } + } + + return { + ...phaseErrors, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts similarity index 75% rename from x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts index 99e6bfb99472c..7c7c0b70c0eed 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts @@ -5,14 +5,13 @@ */ import { - PHASE_INDEX_PRIORITY, UIM_CONFIG_COLD_PHASE, UIM_CONFIG_WARM_PHASE, UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_FREEZE_INDEX, -} from '../constants'; - -import { defaultColdPhase, defaultWarmPhase } from '../store/defaults'; + defaultNewWarmPhase, + defaultNewColdPhase, +} from '../constants/'; import { getUiMetricsForPhases } from './ui_metric'; jest.mock('ui/new_platform'); @@ -22,9 +21,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ cold: { + min_age: '0ms', actions: { set_priority: { - priority: defaultColdPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewColdPhase.phaseIndexPriority, 10), }, }, }, @@ -36,9 +36,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ warm: { + min_age: '0ms', actions: { set_priority: { - priority: defaultWarmPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewWarmPhase.phaseIndexPriority, 10), }, }, }, @@ -50,9 +51,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ warm: { + min_age: '0ms', actions: { set_priority: { - priority: defaultWarmPhase[PHASE_INDEX_PRIORITY] + 1, + priority: parseInt(defaultNewWarmPhase.phaseIndexPriority, 10) + 1, }, }, }, @@ -64,10 +66,11 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ cold: { + min_age: '0ms', actions: { freeze: {}, set_priority: { - priority: defaultColdPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewColdPhase.phaseIndexPriority, 10), }, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts index d71e38d0b31de..b38a734770546 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; - import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { UiStatsMetricType } from '@kbn/analytics'; import { UIM_APP_NAME, UIM_CONFIG_COLD_PHASE, - UIM_CONFIG_WARM_PHASE, - UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_FREEZE_INDEX, - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_INDEX_PRIORITY, + UIM_CONFIG_SET_PRIORITY, + UIM_CONFIG_WARM_PHASE, + defaultNewColdPhase, + defaultNewHotPhase, + defaultNewWarmPhase, } from '../constants'; -import { defaultColdPhase, defaultWarmPhase, defaultHotPhase } from '../store/defaults'; +import { Phases } from './policies/types'; export let trackUiMetric = (metricType: UiStatsMetricType, eventName: string) => {}; @@ -31,49 +28,54 @@ export function init(usageCollection?: UsageCollectionSetup): void { } } -export function getUiMetricsForPhases(phases: any): any { +export function getUiMetricsForPhases(phases: Phases): any { const phaseUiMetrics = [ { metric: UIM_CONFIG_COLD_PHASE, - isTracked: () => Boolean(phases[PHASE_COLD]), + isTracked: () => Boolean(phases.cold), }, { metric: UIM_CONFIG_WARM_PHASE, - isTracked: () => Boolean(phases[PHASE_WARM]), + isTracked: () => Boolean(phases.warm), }, { metric: UIM_CONFIG_SET_PRIORITY, isTracked: () => { - const phaseToDefaultIndexPriorityMap = { - [PHASE_HOT]: defaultHotPhase[PHASE_INDEX_PRIORITY], - [PHASE_WARM]: defaultWarmPhase[PHASE_INDEX_PRIORITY], - [PHASE_COLD]: defaultColdPhase[PHASE_INDEX_PRIORITY], - }; - // We only care about whether the user has interacted with the priority of *any* phase at all. - return [PHASE_HOT, PHASE_WARM, PHASE_COLD].some((phase) => { - // If the priority is different than the default, we'll consider it a user interaction, - // even if the user has set it to undefined. - return ( - phases[phase] && - get(phases[phase], 'actions.set_priority.priority') !== - phaseToDefaultIndexPriorityMap[phase] - ); - }); + const isHotPhasePriorityChanged = + phases.hot && + phases.hot.actions.set_priority && + phases.hot.actions.set_priority.priority !== + parseInt(defaultNewHotPhase.phaseIndexPriority, 10); + + const isWarmPhasePriorityChanged = + phases.warm && + phases.warm.actions.set_priority && + phases.warm.actions.set_priority.priority !== + parseInt(defaultNewWarmPhase.phaseIndexPriority, 10); + + const isColdPhasePriorityChanged = + phases.cold && + phases.cold.actions.set_priority && + phases.cold.actions.set_priority.priority !== + parseInt(defaultNewColdPhase.phaseIndexPriority, 10); + // If the priority is different than the default, we'll consider it a user interaction, + // even if the user has set it to undefined. + return ( + isHotPhasePriorityChanged || isWarmPhasePriorityChanged || isColdPhasePriorityChanged + ); }, }, { metric: UIM_CONFIG_FREEZE_INDEX, - isTracked: () => phases[PHASE_COLD] && get(phases[PHASE_COLD], 'actions.freeze'), + isTracked: () => phases.cold && phases.cold.actions.freeze, }, ]; - const trackedUiMetrics = phaseUiMetrics.reduce((tracked: any, { metric, isTracked }) => { + return phaseUiMetrics.reduce((tracked: any, { metric, isTracked }) => { if (isTracked()) { tracked.push(metric); } return tracked; }, []); - - return trackedUiMetrics; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js deleted file mode 100644 index 28719fde87b0c..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js +++ /dev/null @@ -1,11 +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 { createAction } from 'redux-actions'; - -export const setBootstrapEnabled = createAction('SET_BOOTSTRAP_ENABLED'); -export const setIndexName = createAction('SET_INDEX_NAME'); -export const setAliasName = createAction('SET_ALIAS_NAME'); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js index ea539578c885c..fef79c7782bb0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './nodes'; export * from './policies'; -export * from './lifecycle'; -export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js deleted file mode 100644 index 45a8e63f70e83..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js +++ /dev/null @@ -1,8 +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 { createAction } from 'redux-actions'; -export const setSelectedPrimaryShardCount = createAction('SET_SELECTED_PRIMARY_SHARED_COUNT'); -export const setSelectedReplicaCount = createAction('SET_SELECTED_REPLICA_COUNT'); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js index aa20c0eb1d326..d47136679604f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js @@ -9,7 +9,6 @@ import { createAction } from 'redux-actions'; import { showApiError } from '../../services/api_errors'; import { loadPolicies } from '../../services/api'; -import { SET_PHASE_DATA } from '../../constants'; export const fetchedPolicies = createAction('FETCHED_POLICIES'); export const setSelectedPolicy = createAction('SET_SELECTED_POLICY'); @@ -41,9 +40,3 @@ export const fetchPolicies = (withIndices, callback) => async (dispatch) => { callback && callback(); return policies; }; - -export const setPhaseData = createAction(SET_PHASE_DATA, (phase, key, value) => ({ - phase, - key, - value, -})); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js deleted file mode 100644 index a8f7fd3f4bdfa..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js +++ /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 { - PHASE_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_NODE_ATTRS, - PHASE_REPLICA_COUNT, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_FREEZE_ENABLED, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultColdPhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_NODE_ATTRS]: '', - [PHASE_REPLICA_COUNT]: '', - [PHASE_FREEZE_ENABLED]: false, - [PHASE_INDEX_PRIORITY]: 0, -}; -export const defaultEmptyColdPhase = { - ...defaultColdPhase, - [PHASE_INDEX_PRIORITY]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js deleted file mode 100644 index 8534893e7e3b3..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js +++ /dev/null @@ -1,23 +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 { - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_WAIT_FOR_SNAPSHOT_POLICY, -} from '../../constants'; - -export const defaultDeletePhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_WAIT_FOR_SNAPSHOT_POLICY]: '', -}; -export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js deleted file mode 100644 index 1f5b5c399a642..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultHotPhase = { - [PHASE_ENABLED]: true, - [PHASE_ROLLOVER_ENABLED]: true, - [PHASE_ROLLOVER_MAX_AGE]: 30, - [PHASE_ROLLOVER_MAX_AGE_UNITS]: 'd', - [PHASE_ROLLOVER_MAX_SIZE_STORED]: 50, - [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: 'gb', - [PHASE_INDEX_PRIORITY]: 100, - [PHASE_ROLLOVER_MAX_DOCUMENTS]: '', -}; -export const defaultEmptyHotPhase = { - ...defaultHotPhase, - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ENABLED]: false, - [PHASE_ROLLOVER_MAX_AGE]: '', - [PHASE_ROLLOVER_MAX_SIZE_STORED]: '', - [PHASE_INDEX_PRIORITY]: '', - [PHASE_ROLLOVER_MAX_DOCUMENTS]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js deleted file mode 100644 index f02ac2096675f..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js +++ /dev/null @@ -1,39 +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 { - PHASE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_FORCE_MERGE_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_NODE_ATTRS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_SHRINK_ENABLED, - WARM_PHASE_ON_ROLLOVER, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultWarmPhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_FORCE_MERGE_SEGMENTS]: '', - [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_NODE_ATTRS]: '', - [PHASE_SHRINK_ENABLED]: false, - [PHASE_PRIMARY_SHARD_COUNT]: '', - [PHASE_REPLICA_COUNT]: '', - [WARM_PHASE_ON_ROLLOVER]: true, - [PHASE_INDEX_PRIORITY]: 50, -}; -export const defaultEmptyWarmPhase = { - ...defaultWarmPhase, - [WARM_PHASE_ON_ROLLOVER]: false, - [PHASE_INDEX_PRIORITY]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js deleted file mode 100644 index fcba2fd1358b0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js +++ /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 { handleActions } from 'redux-actions'; -import { setIndexName, setAliasName, setBootstrapEnabled } from '../actions/general'; - -const defaultState = { - bootstrapEnabled: false, - indexName: '', - aliasName: '', -}; - -export const general = handleActions( - { - [setIndexName](state, { payload: indexName }) { - return { - ...state, - indexName, - }; - }, - [setAliasName](state, { payload: aliasName }) { - return { - ...state, - aliasName, - }; - }, - [setBootstrapEnabled](state, { payload: bootstrapEnabled }) { - return { - ...state, - bootstrapEnabled, - }; - }, - }, - defaultState -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js index 60126b85c313e..7fe7134f5f5db 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js @@ -5,12 +5,8 @@ */ import { combineReducers } from 'redux'; -import { nodes } from './nodes'; import { policies } from './policies'; -import { general } from './general'; export const indexLifecycleManagement = combineReducers({ - nodes, policies, - general, }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js deleted file mode 100644 index 383e61b5aacde..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { handleActions } from 'redux-actions'; -import { setSelectedPrimaryShardCount, setSelectedReplicaCount } from '../actions'; - -const defaultState = { - isLoading: false, - selectedNodeAttrs: '', - selectedPrimaryShardCount: 1, - selectedReplicaCount: 1, - nodes: undefined, - details: {}, -}; - -export const nodes = handleActions( - { - [setSelectedPrimaryShardCount](state, { payload }) { - let selectedPrimaryShardCount = parseInt(payload); - if (isNaN(selectedPrimaryShardCount)) { - selectedPrimaryShardCount = ''; - } - return { - ...state, - selectedPrimaryShardCount, - }; - }, - [setSelectedReplicaCount](state, { payload }) { - let selectedReplicaCount; - if (payload != null) { - selectedReplicaCount = parseInt(payload); - if (isNaN(selectedReplicaCount)) { - selectedReplicaCount = ''; - } - } else { - // default value for Elasticsearch - selectedReplicaCount = 1; - } - - return { - ...state, - selectedReplicaCount, - }; - }, - }, - defaultState -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js index a94e875a71845..ca9d59e295a29 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js @@ -7,49 +7,17 @@ import { handleActions } from 'redux-actions'; import { fetchedPolicies, - setSelectedPolicy, - unsetSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - setPhaseData, policyFilterChanged, policyPageChanged, policyPageSizeChanged, policySortChanged, } from '../actions'; -import { policyFromES } from '../selectors'; -import { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, -} from '../../constants'; - -import { - defaultColdPhase, - defaultDeletePhase, - defaultHotPhase, - defaultWarmPhase, -} from '../defaults'; -export const defaultPolicy = { - name: '', - saveAsNew: true, - isNew: true, - phases: { - [PHASE_HOT]: defaultHotPhase, - [PHASE_WARM]: defaultWarmPhase, - [PHASE_COLD]: defaultColdPhase, - [PHASE_DELETE]: defaultDeletePhase, - }, -}; const defaultState = { isLoading: false, isLoaded: false, originalPolicyName: undefined, selectedPolicySet: false, - selectedPolicy: defaultPolicy, policies: [], sort: { sortField: 'name', @@ -70,71 +38,6 @@ export const policies = handleActions( policies, }; }, - [setSelectedPolicy](state, { payload: selectedPolicy }) { - if (!selectedPolicy) { - return { - ...state, - selectedPolicy: defaultPolicy, - selectedPolicySet: true, - }; - } - - return { - ...state, - originalPolicyName: selectedPolicy.name, - selectedPolicySet: true, - selectedPolicy: { - ...defaultPolicy, - ...policyFromES(selectedPolicy), - }, - }; - }, - [unsetSelectedPolicy]() { - return defaultState; - }, - [setSelectedPolicyName](state, { payload: name }) { - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - name, - }, - }; - }, - [setSaveAsNewPolicy](state, { payload: saveAsNew }) { - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - saveAsNew, - }, - }; - }, - [setPhaseData](state, { payload }) { - const { phase, key } = payload; - - let value = payload.value; - if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { - value = parseInt(value); - if (isNaN(value)) { - value = ''; - } - } - - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - phases: { - ...state.selectedPolicy.phases, - [phase]: { - ...state.selectedPolicy.phases[phase], - [key]: value, - }, - }, - }, - }; - }, [policyFilterChanged](state, action) { const { filter } = action.payload; return { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js deleted file mode 100644 index 2d01749be3087..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js +++ /dev/null @@ -1,9 +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. - */ - -export const getBootstrapEnabled = (state) => state.general.bootstrapEnabled; -export const getIndexName = (state) => state.general.indexName; -export const getAliasName = (state) => state.general.aliasName; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js index ea539578c885c..fef79c7782bb0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './nodes'; export * from './policies'; -export * from './lifecycle'; -export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js deleted file mode 100644 index 03538fad9aa83..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js +++ /dev/null @@ -1,287 +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 { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MAX_SIZE_STORED, - STRUCTURE_POLICY_NAME, - ERROR_STRUCTURE, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_SHRINK_ENABLED, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_REPLICA_COUNT, - WARM_PHASE_ON_ROLLOVER, - PHASE_INDEX_PRIORITY, - PHASE_ROLLOVER_MAX_DOCUMENTS, -} from '../../constants'; - -import { - getPhase, - getPhases, - phaseToES, - getSelectedPolicyName, - isNumber, - getSaveAsNewPolicy, - getSelectedOriginalPolicyName, - getPolicies, -} from '.'; - -import { getPolicyByName } from './policies'; - -export const numberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', - { - defaultMessage: 'A number is required.', - } -); - -export const positiveNumberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', - { - defaultMessage: 'Only positive numbers are allowed.', - } -); - -export const maximumAgeRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError', - { - defaultMessage: 'A maximum age is required.', - } -); - -export const maximumSizeRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError', - { - defaultMessage: 'A maximum index size is required.', - } -); - -export const maximumDocumentsRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError', - { - defaultMessage: 'Maximum documents is required.', - } -); - -export const positiveNumbersAboveZeroErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', - { - defaultMessage: 'Only numbers above 0 are allowed.', - } -); - -export const validatePhase = (type, phase, errors) => { - const phaseErrors = {}; - - if (!phase[PHASE_ENABLED]) { - return; - } - - for (const numberedAttribute of PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE) { - if (phase.hasOwnProperty(numberedAttribute)) { - // If WARM_PHASE_ON_ROLLOVER or PHASE_HOT there is no need to validate this - if ( - numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE && - (phase[WARM_PHASE_ON_ROLLOVER] || type === PHASE_HOT) - ) { - continue; - } - // If shrink is disabled, there is no need to validate this - if (numberedAttribute === PHASE_PRIMARY_SHARD_COUNT && !phase[PHASE_SHRINK_ENABLED]) { - continue; - } - // If forcemerge is disabled, there is no need to validate this - if (numberedAttribute === PHASE_FORCE_MERGE_SEGMENTS && !phase[PHASE_FORCE_MERGE_ENABLED]) { - continue; - } - // PHASE_REPLICA_COUNT is optional and can be zero - if (numberedAttribute === PHASE_REPLICA_COUNT && !phase[numberedAttribute]) { - continue; - } - // PHASE_INDEX_PRIORITY is optional and can be zero - if (numberedAttribute === PHASE_INDEX_PRIORITY && !phase[numberedAttribute]) { - continue; - } - if (!isNumber(phase[numberedAttribute])) { - phaseErrors[numberedAttribute] = [numberRequiredMessage]; - } else if (phase[numberedAttribute] < 0) { - phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } - } - } - if (phase[PHASE_ROLLOVER_ENABLED]) { - if ( - !isNumber(phase[PHASE_ROLLOVER_MAX_AGE]) && - !isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED]) && - !isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS]) - ) { - phaseErrors[PHASE_ROLLOVER_MAX_AGE] = [maximumAgeRequiredMessage]; - phaseErrors[PHASE_ROLLOVER_MAX_SIZE_STORED] = [maximumSizeRequiredMessage]; - phaseErrors[PHASE_ROLLOVER_MAX_DOCUMENTS] = [maximumDocumentsRequiredMessage]; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_AGE]) && phase[PHASE_ROLLOVER_MAX_AGE] < 1) { - phaseErrors[PHASE_ROLLOVER_MAX_AGE] = [positiveNumbersAboveZeroErrorMessage]; - } - if ( - isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED]) && - phase[PHASE_ROLLOVER_MAX_SIZE_STORED] < 1 - ) { - phaseErrors[PHASE_ROLLOVER_MAX_SIZE_STORED] = [positiveNumbersAboveZeroErrorMessage]; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS]) && phase[PHASE_ROLLOVER_MAX_DOCUMENTS] < 1) { - phaseErrors[PHASE_ROLLOVER_MAX_DOCUMENTS] = [positiveNumbersAboveZeroErrorMessage]; - } - } - if (phase[PHASE_SHRINK_ENABLED]) { - if (!isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { - phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [numberRequiredMessage]; - } else if (phase[PHASE_PRIMARY_SHARD_COUNT] < 1) { - phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [positiveNumbersAboveZeroErrorMessage]; - } - } - - if (phase[PHASE_FORCE_MERGE_ENABLED]) { - if (!isNumber(phase[PHASE_FORCE_MERGE_SEGMENTS])) { - phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [numberRequiredMessage]; - } else if (phase[PHASE_FORCE_MERGE_SEGMENTS] < 1) { - phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [positiveNumbersAboveZeroErrorMessage]; - } - } - errors[type] = { - ...errors[type], - ...phaseErrors, - }; -}; - -export const policyNameRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', - { - defaultMessage: 'A policy name is required.', - } -); -export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', - { - defaultMessage: 'A policy name cannot start with an underscore.', - } -); -export const policyNameContainsCommaErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', - { - defaultMessage: 'A policy name cannot include a comma.', - } -); -export const policyNameContainsSpaceErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', - { - defaultMessage: 'A policy name cannot include a space.', - } -); -export const policyNameTooLongErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', - { - defaultMessage: 'A policy name cannot be longer than 255 bytes.', - } -); -export const policyNameMustBeDifferentErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', - { - defaultMessage: 'The policy name must be different.', - } -); -export const policyNameAlreadyUsedErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', - { - defaultMessage: 'That policy name is already used.', - } -); -export const validateLifecycle = (state) => { - // This method of deep copy does not always work but it should be fine here - const errors = JSON.parse(JSON.stringify(ERROR_STRUCTURE)); - const policyName = getSelectedPolicyName(state); - if (!policyName) { - errors[STRUCTURE_POLICY_NAME].push(policyNameRequiredMessage); - } else { - if (policyName.startsWith('_')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameStartsWithUnderscoreErrorMessage); - } - if (policyName.includes(',')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameContainsCommaErrorMessage); - } - if (policyName.includes(' ')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameContainsSpaceErrorMessage); - } - if (window.TextEncoder && new window.TextEncoder('utf-8').encode(policyName).length > 255) { - errors[STRUCTURE_POLICY_NAME].push(policyNameTooLongErrorMessage); - } - } - - if ( - getSaveAsNewPolicy(state) && - getSelectedOriginalPolicyName(state) === getSelectedPolicyName(state) - ) { - errors[STRUCTURE_POLICY_NAME].push(policyNameMustBeDifferentErrorMessage); - } else if (getSelectedOriginalPolicyName(state) !== getSelectedPolicyName(state)) { - const policyNames = getPolicies(state).map((policy) => policy.name); - if (policyNames.includes(getSelectedPolicyName(state))) { - errors[STRUCTURE_POLICY_NAME].push(policyNameAlreadyUsedErrorMessage); - } - } - - const hotPhase = getPhase(state, PHASE_HOT); - const warmPhase = getPhase(state, PHASE_WARM); - const coldPhase = getPhase(state, PHASE_COLD); - const deletePhase = getPhase(state, PHASE_DELETE); - - validatePhase(PHASE_HOT, hotPhase, errors); - validatePhase(PHASE_WARM, warmPhase, errors); - validatePhase(PHASE_COLD, coldPhase, errors); - validatePhase(PHASE_DELETE, deletePhase, errors); - return errors; -}; - -export const getLifecycle = (state) => { - const policyName = getSelectedPolicyName(state); - const phases = Object.entries(getPhases(state)).reduce((accum, [phaseName, phase]) => { - // Hot is ALWAYS enabled - if (phaseName === PHASE_HOT) { - phase[PHASE_ENABLED] = true; - } - const esPolicy = getPolicyByName(state, policyName).policy || {}; - const esPhase = esPolicy.phases ? esPolicy.phases[phaseName] : {}; - if (phase[PHASE_ENABLED]) { - accum[phaseName] = phaseToES(phase, esPhase); - - // These seem to be constants - if (phaseName === PHASE_DELETE) { - accum[phaseName].actions = { - ...accum[phaseName].actions, - delete: { - ...accum[phaseName].actions.delete, - }, - }; - } - } - return accum; - }, {}); - - return { - name: getSelectedPolicyName(state), - //type, TODO: figure this out (jsut store it and not let the user change it?) - phases, - }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js deleted file mode 100644 index 72bfd4b15a78a..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js +++ /dev/null @@ -1,12 +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. - */ - -export const getNodes = (state) => state.nodes.nodes; - -export const getSelectedPrimaryShardCount = (state) => state.nodes.selectedPrimaryShardCount; - -export const getSelectedReplicaCount = (state) => - state.nodes.selectedReplicaCount !== undefined ? state.nodes.selectedReplicaCount : 1; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js index 5bea22f0b3a76..e1c89314a2ec5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js @@ -7,49 +7,9 @@ import { createSelector } from 'reselect'; import { Pager } from '@elastic/eui'; -import { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_NODE_ATTRS, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_ENABLED, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, - WARM_PHASE_ON_ROLLOVER, - PHASE_SHRINK_ENABLED, - PHASE_FREEZE_ENABLED, - PHASE_INDEX_PRIORITY, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_WAIT_FOR_SNAPSHOT_POLICY, -} from '../../constants'; - import { filterItems, sortTable } from '../../services'; -import { - defaultEmptyDeletePhase, - defaultEmptyColdPhase, - defaultEmptyWarmPhase, - defaultEmptyHotPhase, -} from '../defaults'; - export const getPolicies = (state) => state.policies.policies; -export const getPolicyByName = (state, name) => - getPolicies(state).find((policy) => policy.name === name) || {}; -export const getIsNewPolicy = (state) => state.policies.selectedPolicy.isNew; -export const getSelectedPolicy = (state) => state.policies.selectedPolicy; -export const getIsSelectedPolicySet = (state) => state.policies.selectedPolicySet; -export const getSelectedOriginalPolicyName = (state) => state.policies.originalPolicyName; export const getPolicyFilter = (state) => state.policies.filter; export const getPolicySort = (state) => state.policies.sort; export const getPolicyCurrentPage = (state) => state.policies.currentPage; @@ -77,255 +37,6 @@ export const getPageOfPolicies = createSelector( (filteredPolicies, sort, pager) => { const sortedPolicies = sortTable(filteredPolicies, sort.sortField, sort.isSortAscending); const { firstItemIndex, lastItemIndex } = pager; - const pagedPolicies = sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); - return pagedPolicies; + return sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); } ); -export const getSaveAsNewPolicy = (state) => state.policies.selectedPolicy.saveAsNew; - -export const getSelectedPolicyName = (state) => { - if (!getSaveAsNewPolicy(state)) { - return getSelectedOriginalPolicyName(state); - } - return state.policies.selectedPolicy.name; -}; - -export const getPhases = (state) => state.policies.selectedPolicy.phases; - -export const getPhase = (state, phase) => getPhases(state)[phase]; - -export const getPhaseData = (state, phase, key) => { - if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { - return parseInt(getPhase(state, phase)[key]); - } - return getPhase(state, phase)[key]; -}; - -export const splitSizeAndUnits = (field) => { - let size; - let units; - - const result = /(\d+)(\w+)/.exec(field); - if (result) { - size = parseInt(result[1]) || 0; - units = result[2]; - } - - return { - size, - units, - }; -}; - -export const isNumber = (value) => typeof value === 'number'; -export const isEmptyObject = (obj) => { - return !obj || (Object.entries(obj).length === 0 && obj.constructor === Object); -}; - -const phaseFromES = (phase, phaseName, defaultEmptyPolicy) => { - const policy = { ...defaultEmptyPolicy }; - if (!phase) { - return policy; - } - - policy[PHASE_ENABLED] = true; - - if (phase.min_age) { - if (phaseName === PHASE_WARM && phase.min_age === '0ms') { - policy[WARM_PHASE_ON_ROLLOVER] = true; - } else { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phase.min_age); - policy[PHASE_ROLLOVER_MINIMUM_AGE] = minAge; - policy[PHASE_ROLLOVER_MINIMUM_AGE_UNITS] = minAgeUnits; - } - } - if (phaseName === PHASE_WARM) { - policy[PHASE_SHRINK_ENABLED] = false; - policy[PHASE_FORCE_MERGE_ENABLED] = false; - } - if (phase.actions) { - const actions = phase.actions; - - if (actions.rollover) { - const rollover = actions.rollover; - policy[PHASE_ROLLOVER_ENABLED] = true; - if (rollover.max_age) { - const { size: maxAge, units: maxAgeUnits } = splitSizeAndUnits(rollover.max_age); - policy[PHASE_ROLLOVER_MAX_AGE] = maxAge; - policy[PHASE_ROLLOVER_MAX_AGE_UNITS] = maxAgeUnits; - } - if (rollover.max_size) { - const { size: maxSize, units: maxSizeUnits } = splitSizeAndUnits(rollover.max_size); - policy[PHASE_ROLLOVER_MAX_SIZE_STORED] = maxSize; - policy[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] = maxSizeUnits; - } - if (rollover.max_docs) { - policy[PHASE_ROLLOVER_MAX_DOCUMENTS] = rollover.max_docs; - } - } - - if (actions.allocate) { - const allocate = actions.allocate; - if (allocate.require) { - Object.entries(allocate.require).forEach((entry) => { - policy[PHASE_NODE_ATTRS] = entry.join(':'); - }); - // checking for null or undefined here - if (allocate.number_of_replicas != null) { - policy[PHASE_REPLICA_COUNT] = allocate.number_of_replicas; - } - } - } - - if (actions.forcemerge) { - const forcemerge = actions.forcemerge; - policy[PHASE_FORCE_MERGE_ENABLED] = true; - policy[PHASE_FORCE_MERGE_SEGMENTS] = forcemerge.max_num_segments; - } - - if (actions.shrink) { - policy[PHASE_SHRINK_ENABLED] = true; - policy[PHASE_PRIMARY_SHARD_COUNT] = actions.shrink.number_of_shards; - } - - if (actions.freeze) { - policy[PHASE_FREEZE_ENABLED] = true; - } - - if (actions.set_priority) { - const { priority } = actions.set_priority; - - policy[PHASE_INDEX_PRIORITY] = priority ?? ''; - } - - if (actions.wait_for_snapshot) { - policy[PHASE_WAIT_FOR_SNAPSHOT_POLICY] = actions.wait_for_snapshot.policy; - } - } - return policy; -}; - -export const policyFromES = (policy) => { - const { - name, - policy: { phases }, - } = policy; - - return { - name, - phases: { - [PHASE_HOT]: phaseFromES(phases[PHASE_HOT], PHASE_HOT, defaultEmptyHotPhase), - [PHASE_WARM]: phaseFromES(phases[PHASE_WARM], PHASE_WARM, defaultEmptyWarmPhase), - [PHASE_COLD]: phaseFromES(phases[PHASE_COLD], PHASE_COLD, defaultEmptyColdPhase), - [PHASE_DELETE]: phaseFromES(phases[PHASE_DELETE], PHASE_DELETE, defaultEmptyDeletePhase), - }, - isNew: false, - saveAsNew: false, - }; -}; - -export const phaseToES = (phase, originalEsPhase) => { - const esPhase = { ...originalEsPhase }; - - if (!phase[PHASE_ENABLED]) { - return {}; - } - if (isNumber(phase[PHASE_ROLLOVER_MINIMUM_AGE])) { - esPhase.min_age = `${phase[PHASE_ROLLOVER_MINIMUM_AGE]}${phase[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]}`; - } - - // If warm phase on rollover is enabled, delete min age field - // An index lifecycle switches to warm phase when rollover occurs, so you cannot specify a warm phase time - // They are mutually exclusive - if (phase[WARM_PHASE_ON_ROLLOVER]) { - delete esPhase.min_age; - } - - esPhase.actions = esPhase.actions || {}; - - if (phase[PHASE_ROLLOVER_ENABLED]) { - esPhase.actions.rollover = {}; - - if (isNumber(phase[PHASE_ROLLOVER_MAX_AGE])) { - esPhase.actions.rollover.max_age = `${phase[PHASE_ROLLOVER_MAX_AGE]}${phase[PHASE_ROLLOVER_MAX_AGE_UNITS]}`; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED])) { - esPhase.actions.rollover.max_size = `${phase[PHASE_ROLLOVER_MAX_SIZE_STORED]}${phase[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]}`; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS])) { - esPhase.actions.rollover.max_docs = phase[PHASE_ROLLOVER_MAX_DOCUMENTS]; - } - } else { - delete esPhase.actions.rollover; - } - if (phase[PHASE_NODE_ATTRS]) { - const [name, value] = phase[PHASE_NODE_ATTRS].split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || {}; - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } - if (isNumber(phase[PHASE_REPLICA_COUNT])) { - esPhase.actions.allocate = esPhase.actions.allocate || {}; - esPhase.actions.allocate.number_of_replicas = phase[PHASE_REPLICA_COUNT]; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.number_of_replicas; - } - } - if ( - esPhase.actions.allocate && - !esPhase.actions.allocate.require && - !isNumber(esPhase.actions.allocate.number_of_replicas) && - isEmptyObject(esPhase.actions.allocate.include) && - isEmptyObject(esPhase.actions.allocate.exclude) - ) { - // remove allocate action if it does not define require or number of nodes - // and both include and exclude are empty objects (ES will fail to parse if we don't) - delete esPhase.actions.allocate; - } - - if (phase[PHASE_FORCE_MERGE_ENABLED]) { - esPhase.actions.forcemerge = { - max_num_segments: phase[PHASE_FORCE_MERGE_SEGMENTS], - }; - } else { - delete esPhase.actions.forcemerge; - } - - if (phase[PHASE_SHRINK_ENABLED] && isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { - esPhase.actions.shrink = { - number_of_shards: phase[PHASE_PRIMARY_SHARD_COUNT], - }; - } else { - delete esPhase.actions.shrink; - } - - if (phase[PHASE_FREEZE_ENABLED]) { - esPhase.actions.freeze = {}; - } else { - delete esPhase.actions.freeze; - } - if (isNumber(phase[PHASE_INDEX_PRIORITY])) { - esPhase.actions.set_priority = { - priority: phase[PHASE_INDEX_PRIORITY], - }; - } else if (phase[PHASE_INDEX_PRIORITY] === '') { - esPhase.actions.set_priority = { - priority: null, - }; - } - - if (phase[PHASE_WAIT_FOR_SNAPSHOT_POLICY]) { - esPhase.actions.wait_for_snapshot = { - policy: phase[PHASE_WAIT_FOR_SNAPSHOT_POLICY], - }; - } else { - delete esPhase.actions.wait_for_snapshot; - } - return esPhase; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index 0ca62c10f55f3..645a78bfc99b8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { CoreSetup, PluginInitializerContext } from 'src/core/public'; - +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { PLUGIN } from '../common/constants'; import { init as initHttp } from './application/services/http'; import { init as initDocumentation } from './application/services/documentation'; @@ -30,7 +31,7 @@ export class IndexLifecycleManagementPlugin { getStartServices, } = coreSetup; - const { usageCollection, management, indexManagement } = plugins; + const { usageCollection, management, indexManagement, home } = plugins; // Initialize services even if the app isn't mounted, because they're used by index management extensions. initHttp(http); @@ -74,6 +75,24 @@ export class IndexLifecycleManagementPlugin { }, }); + if (home) { + home.featureCatalogue.register({ + id: PLUGIN.ID, + title: i18n.translate('xpack.indexLifecycleMgmt.featureCatalogueTitle', { + defaultMessage: 'Manage index lifecycles', + }), + description: i18n.translate('xpack.indexLifecycleMgmt.featureCatalogueDescription', { + defaultMessage: + 'Define lifecycle policies to automatically perform operations as an index ages.', + }), + icon: 'indexSettings', + path: '/app/management/data/index_lifecycle_management', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + order: 640, + }); + } + if (indexManagement) { addAllExtensions(indexManagement.extensionsService); } diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 178884a7ee679..65db00f1e68c1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IndexManagementPluginSetup } from '../../index_management/public'; @@ -12,6 +13,7 @@ export interface PluginsDependencies { usageCollection?: UsageCollectionSetup; management: ManagementSetup; indexManagement?: IndexManagementPluginSetup; + home?: HomePublicPluginSetup; } export interface ClientConfigType { diff --git a/x-pack/plugins/infra/common/alerting/logs/types.ts b/x-pack/plugins/infra/common/alerting/logs/types.ts index 884a813d74c86..1b736f52aa7e2 100644 --- a/x-pack/plugins/infra/common/alerting/logs/types.ts +++ b/x-pack/plugins/infra/common/alerting/logs/types.ts @@ -96,40 +96,64 @@ const DocumentCountRT = rt.type({ export type DocumentCount = rt.TypeOf; -const CriterionRT = rt.type({ +export const CriterionRT = rt.type({ field: rt.string, comparator: ComparatorRT, value: rt.union([rt.string, rt.number]), }); export type Criterion = rt.TypeOf; +export const criteriaRT = rt.array(CriterionRT); -const TimeUnitRT = rt.union([rt.literal('s'), rt.literal('m'), rt.literal('h'), rt.literal('d')]); +export const TimeUnitRT = rt.union([ + rt.literal('s'), + rt.literal('m'), + rt.literal('h'), + rt.literal('d'), +]); export type TimeUnit = rt.TypeOf; +export const timeSizeRT = rt.number; +export const groupByRT = rt.array(rt.string); + export const LogDocumentCountAlertParamsRT = rt.intersection([ rt.type({ count: DocumentCountRT, - criteria: rt.array(CriterionRT), + criteria: criteriaRT, timeUnit: TimeUnitRT, - timeSize: rt.number, + timeSize: timeSizeRT, }), rt.partial({ - groupBy: rt.array(rt.string), + groupBy: groupByRT, }), ]); export type LogDocumentCountAlertParams = rt.TypeOf; +const chartPreviewHistogramBucket = rt.type({ + key: rt.number, + doc_count: rt.number, +}); + export const UngroupedSearchQueryResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ - hits: rt.type({ - total: rt.type({ - value: rt.number, + rt.intersection([ + rt.type({ + hits: rt.type({ + total: rt.type({ + value: rt.number, + }), }), }), - }), + // Chart preview buckets + rt.partial({ + aggregations: rt.type({ + histogramBuckets: rt.type({ + buckets: rt.array(chartPreviewHistogramBucket), + }), + }), + }), + ]), ]); export type UngroupedSearchQueryResponse = rt.TypeOf; @@ -144,9 +168,17 @@ export const GroupedSearchQueryResponseRT = rt.intersection([ rt.type({ key: rt.record(rt.string, rt.string), doc_count: rt.number, - filtered_results: rt.type({ - doc_count: rt.number, - }), + filtered_results: rt.intersection([ + rt.type({ + doc_count: rt.number, + }), + // Chart preview buckets + rt.partial({ + histogramBuckets: rt.type({ + buckets: rt.array(chartPreviewHistogramBucket), + }), + }), + ]), }) ), }), diff --git a/x-pack/plugins/infra/common/color_palette.test.ts b/x-pack/plugins/infra/common/color_palette.test.ts index ced45c39c710c..1e814d6f67fec 100644 --- a/x-pack/plugins/infra/common/color_palette.test.ts +++ b/x-pack/plugins/infra/common/color_palette.test.ts @@ -4,35 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sampleColor, MetricsExplorerColor, colorTransformer } from './color_palette'; +import { sampleColor, Color, colorTransformer } from './color_palette'; describe('Color Palette', () => { describe('sampleColor()', () => { it('should just work', () => { - const usedColors = [MetricsExplorerColor.color0]; + const usedColors = [Color.color0]; const color = sampleColor(usedColors); - expect(color).toBe(MetricsExplorerColor.color1); + expect(color).toBe(Color.color1); }); it('should return color0 when nothing is available', () => { const usedColors = [ - MetricsExplorerColor.color0, - MetricsExplorerColor.color1, - MetricsExplorerColor.color2, - MetricsExplorerColor.color3, - MetricsExplorerColor.color4, - MetricsExplorerColor.color5, - MetricsExplorerColor.color6, - MetricsExplorerColor.color7, - MetricsExplorerColor.color8, - MetricsExplorerColor.color9, + Color.color0, + Color.color1, + Color.color2, + Color.color3, + Color.color4, + Color.color5, + Color.color6, + Color.color7, + Color.color8, + Color.color9, ]; const color = sampleColor(usedColors); - expect(color).toBe(MetricsExplorerColor.color0); + expect(color).toBe(Color.color0); }); }); describe('colorTransformer()', () => { it('should just work', () => { - expect(colorTransformer(MetricsExplorerColor.color0)).toBe('#6092C0'); + expect(colorTransformer(Color.color0)).toBe('#6092C0'); }); }); }); diff --git a/x-pack/plugins/infra/common/color_palette.ts b/x-pack/plugins/infra/common/color_palette.ts index 51962150d8424..2b72b3f0c1dfa 100644 --- a/x-pack/plugins/infra/common/color_palette.ts +++ b/x-pack/plugins/infra/common/color_palette.ts @@ -6,7 +6,7 @@ import { difference, first, values } from 'lodash'; import { euiPaletteColorBlind } from '@elastic/eui'; -export enum MetricsExplorerColor { +export enum Color { color0 = 'color0', color1 = 'color1', color2 = 'color2', @@ -19,41 +19,30 @@ export enum MetricsExplorerColor { color9 = 'color9', } -export interface MetricsExplorerPalette { - [MetricsExplorerColor.color0]: string; - [MetricsExplorerColor.color1]: string; - [MetricsExplorerColor.color2]: string; - [MetricsExplorerColor.color3]: string; - [MetricsExplorerColor.color4]: string; - [MetricsExplorerColor.color5]: string; - [MetricsExplorerColor.color6]: string; - [MetricsExplorerColor.color7]: string; - [MetricsExplorerColor.color8]: string; - [MetricsExplorerColor.color9]: string; -} +export type Palette = { + [K in keyof typeof Color]: string; +}; const euiPalette = euiPaletteColorBlind(); -export const defaultPalette: MetricsExplorerPalette = { - [MetricsExplorerColor.color0]: euiPalette[1], // (blue) - [MetricsExplorerColor.color1]: euiPalette[2], // (pink) - [MetricsExplorerColor.color2]: euiPalette[0], // (green-ish) - [MetricsExplorerColor.color3]: euiPalette[3], // (purple) - [MetricsExplorerColor.color4]: euiPalette[4], // (light pink) - [MetricsExplorerColor.color5]: euiPalette[5], // (yellow) - [MetricsExplorerColor.color6]: euiPalette[6], // (tan) - [MetricsExplorerColor.color7]: euiPalette[7], // (orange) - [MetricsExplorerColor.color8]: euiPalette[8], // (brown) - [MetricsExplorerColor.color9]: euiPalette[9], // (red) +export const defaultPalette: Palette = { + [Color.color0]: euiPalette[1], // (blue) + [Color.color1]: euiPalette[2], // (pink) + [Color.color2]: euiPalette[0], // (green-ish) + [Color.color3]: euiPalette[3], // (purple) + [Color.color4]: euiPalette[4], // (light pink) + [Color.color5]: euiPalette[5], // (yellow) + [Color.color6]: euiPalette[6], // (tan) + [Color.color7]: euiPalette[7], // (orange) + [Color.color8]: euiPalette[8], // (brown) + [Color.color9]: euiPalette[9], // (red) }; -export const createPaletteTransformer = (palette: MetricsExplorerPalette) => ( - color: MetricsExplorerColor -) => palette[color]; +export const createPaletteTransformer = (palette: Palette) => (color: Color) => palette[color]; export const colorTransformer = createPaletteTransformer(defaultPalette); -export const sampleColor = (usedColors: MetricsExplorerColor[] = []): MetricsExplorerColor => { - const available = difference(values(MetricsExplorerColor) as MetricsExplorerColor[], usedColors); - return first(available) || MetricsExplorerColor.color0; +export const sampleColor = (usedColors: Color[] = []): Color => { + const available = difference(values(Color) as Color[], usedColors); + return first(available) || Color.color0; }; diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 9ec8bf5231066..818009417fb1c 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -9,3 +9,4 @@ export * from './metadata_api'; export * from './log_entries'; export * from './metrics_explorer'; export * from './metrics_api'; +export * from './log_alerts'; diff --git a/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.ts b/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.ts new file mode 100644 index 0000000000000..15914bd1b2209 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.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 * as rt from 'io-ts'; +import { criteriaRT, TimeUnitRT, timeSizeRT, groupByRT } from '../../alerting/logs/types'; + +export const LOG_ALERTS_CHART_PREVIEW_DATA_PATH = '/api/infra/log_alerts/chart_preview_data'; + +const pointRT = rt.type({ + timestamp: rt.number, + value: rt.number, +}); + +export type Point = rt.TypeOf; + +const serieRT = rt.type({ + id: rt.string, + points: rt.array(pointRT), +}); + +const seriesRT = rt.array(serieRT); + +export type Series = rt.TypeOf; + +export const getLogAlertsChartPreviewDataSuccessResponsePayloadRT = rt.type({ + data: rt.type({ + series: seriesRT, + }), +}); + +export type GetLogAlertsChartPreviewDataSuccessResponsePayload = rt.TypeOf< + typeof getLogAlertsChartPreviewDataSuccessResponsePayloadRT +>; + +export const getLogAlertsChartPreviewDataAlertParamsSubsetRT = rt.intersection([ + rt.type({ + criteria: criteriaRT, + timeUnit: TimeUnitRT, + timeSize: timeSizeRT, + }), + rt.partial({ + groupBy: groupByRT, + }), +]); + +export type GetLogAlertsChartPreviewDataAlertParamsSubset = rt.TypeOf< + typeof getLogAlertsChartPreviewDataAlertParamsSubsetRT +>; + +export const getLogAlertsChartPreviewDataRequestPayloadRT = rt.type({ + data: rt.type({ + sourceId: rt.string, + alertParams: getLogAlertsChartPreviewDataAlertParamsSubsetRT, + buckets: rt.number, + }), +}); + +export type GetLogAlertsChartPreviewDataRequestPayload = rt.TypeOf< + typeof getLogAlertsChartPreviewDataRequestPayloadRT +>; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts b/x-pack/plugins/infra/common/http_api/log_alerts/index.ts similarity index 86% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts rename to x-pack/plugins/infra/common/http_api/log_alerts/index.ts index 5f15d929a4916..5634fda043a52 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts +++ b/x-pack/plugins/infra/common/http_api/log_alerts/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export declare const EditPolicy: any; +export * from './chart_preview_data'; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index 06394c2aa916c..08ffe520e53d5 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -6,14 +6,14 @@ "features", "usageCollection", "spaces", - "home", + "data", "dataEnhanced", "visTypeTimeseries", "alerts", "triggers_actions_ui" ], - "optionalPlugins": ["ml", "observability"], + "optionalPlugins": ["ml", "observability", "home"], "server": true, "ui": true, "configPath": ["xpack", "infra"], @@ -22,6 +22,7 @@ "licenseManagement", "kibanaUtils", "kibanaReact", - "apm" + "apm", + "home" ] } diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index c90c534193fdc..94ad074b72e9c 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -28,7 +28,7 @@ import { Comparator, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../server/lib/alerting/metric_threshold/types'; -import { MetricsExplorerColor, colorTransformer } from '../../../../common/color_palette'; +import { Color, colorTransformer } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; import { MetricExpression, AlertContextMeta } from '../types'; @@ -80,7 +80,7 @@ export const ExpressionChart: React.FC = ({ const metric = { field: expression.metric, aggregation: expression.aggType as MetricsExplorerAggregation, - color: MetricsExplorerColor.color0, + color: Color.color0, }; const isDarkMode = context.uiSettings?.get('theme:darkMode') || false; const dateFormatter = useMemo(() => { @@ -176,7 +176,7 @@ export const ExpressionChart: React.FC = ({ style={{ line: { strokeWidth: 2, - stroke: colorTransformer(MetricsExplorerColor.color1), + stroke: colorTransformer(Color.color1), opacity: 1, }, }} @@ -186,7 +186,7 @@ export const ExpressionChart: React.FC = ({ = ({ = ({ = ({ = ({ ) => void; removeCriterion: (idx: number) => void; errors: IErrorObject; + alertParams: Partial; + context: AlertsContext; + sourceId: string; } export const Criteria: React.FC = ({ @@ -29,6 +34,9 @@ export const Criteria: React.FC = ({ updateCriterion, removeCriterion, errors, + alertParams, + context, + sourceId, }) => { if (!criteria) return null; return ( @@ -36,16 +44,23 @@ export const Criteria: React.FC = ({ {criteria.map((criterion, idx) => { return ( - 1} - errors={errors[idx.toString()] as IErrorObject} - /> + + 1} + errors={errors[idx.toString()] as IErrorObject} + /> + + ); })} diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx new file mode 100644 index 0000000000000..31f9a64015c07 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { useDebounce } from 'react-use'; +import { + ScaleType, + AnnotationDomainTypes, + Position, + Axis, + BarSeries, + Chart, + Settings, + RectAnnotation, + LineAnnotation, +} from '@elastic/charts'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ChartContainer, + LoadingState, + NoDataState, + ErrorState, + TIME_LABELS, + getDomain, + tooltipProps, + useDateFormatter, + getChartTheme, + yAxisFormatter, + NUM_BUCKETS, +} from '../../shared/criterion_preview_chart/criterion_preview_chart'; +import { + LogDocumentCountAlertParams, + Criterion, + Comparator, +} from '../../../../../common/alerting/logs/types'; +import { Color, colorTransformer } from '../../../../../common/color_palette'; +import { + GetLogAlertsChartPreviewDataAlertParamsSubset, + getLogAlertsChartPreviewDataAlertParamsSubsetRT, +} from '../../../../../common/http_api/log_alerts/'; +import { AlertsContext } from './editor'; +import { useChartPreviewData } from './hooks/use_chart_preview_data'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; + +const GROUP_LIMIT = 5; + +interface Props { + alertParams: Partial; + context: AlertsContext; + chartCriterion: Partial; + sourceId: string; +} + +export const CriterionPreview: React.FC = ({ + alertParams, + context, + chartCriterion, + sourceId, +}) => { + const chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset | null = useMemo(() => { + const { field, comparator, value } = chartCriterion; + const criteria = field && comparator && value ? [{ field, comparator, value }] : []; + const params = { + criteria, + timeSize: alertParams.timeSize, + timeUnit: alertParams.timeUnit, + groupBy: alertParams.groupBy, + }; + + try { + return decodeOrThrow(getLogAlertsChartPreviewDataAlertParamsSubsetRT)(params); + } catch (error) { + return null; + } + }, [alertParams.timeSize, alertParams.timeUnit, alertParams.groupBy, chartCriterion]); + + // Check for the existence of properties that are necessary for a meaningful chart. + if (chartAlertParams === null || chartAlertParams.criteria.length === 0) return null; + + return ( + + ); +}; + +interface ChartProps { + buckets: number; + context: AlertsContext; + sourceId: string; + threshold?: LogDocumentCountAlertParams['count']; + chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset; +} + +const CriterionPreviewChart: React.FC = ({ + buckets, + context, + sourceId, + threshold, + chartAlertParams, +}) => { + const isDarkMode = context.uiSettings?.get('theme:darkMode') || false; + + const { + getChartPreviewData, + isLoading, + hasError, + chartPreviewData: series, + } = useChartPreviewData({ + context, + sourceId, + alertParams: chartAlertParams, + buckets, + }); + + useDebounce( + () => { + getChartPreviewData(); + }, + 500, + [getChartPreviewData] + ); + + const isStacked = false; + + const { timeSize, timeUnit, groupBy } = chartAlertParams; + + const isGrouped = groupBy && groupBy.length > 0 ? true : false; + + const isAbove = + threshold && threshold.comparator + ? [Comparator.GT, Comparator.GT_OR_EQ].includes(threshold.comparator) + : false; + + const isBelow = + threshold && threshold.comparator + ? [Comparator.LT, Comparator.LT_OR_EQ].includes(threshold.comparator) + : false; + + // For grouped scenarios we want to limit the groups displayed, for "isAbove" thresholds we'll show + // groups with the highest doc counts. And for "isBelow" thresholds we'll show groups with the lowest doc counts. + const filteredSeries = useMemo(() => { + if (!isGrouped) { + return series; + } + + const sortedByMax = series.sort((a, b) => { + const aMax = Math.max(...a.points.map((point) => point.value)); + const bMax = Math.max(...b.points.map((point) => point.value)); + return bMax - aMax; + }); + const sortedSeries = (!isAbove && !isBelow) || isAbove ? sortedByMax : sortedByMax.reverse(); + return sortedSeries.slice(0, GROUP_LIMIT); + }, [series, isGrouped, isAbove, isBelow]); + + const barSeries = useMemo(() => { + return filteredSeries.reduce>( + (acc, serie) => { + const barPoints = serie.points.reduce< + Array<{ timestamp: number; value: number; groupBy: string }> + >((pointAcc, point) => { + return [...pointAcc, { ...point, groupBy: serie.id }]; + }, []); + return [...acc, ...barPoints]; + }, + [] + ); + }, [filteredSeries]); + + const lookback = timeSize * buckets; + const hasData = series.length > 0; + const { yMin, yMax, xMin, xMax } = getDomain(filteredSeries, isStacked); + const chartDomain = { + max: threshold && threshold.value ? Math.max(yMax, threshold.value) * 1.1 : yMax * 1.1, // Add 10% headroom. + min: threshold && threshold.value ? Math.min(yMin, threshold.value) : yMin, + }; + + if (threshold && threshold.value && chartDomain.min === threshold.value) { + chartDomain.min = chartDomain.min * 0.9; // Allow some padding so the threshold annotation has better visibility + } + + const THRESHOLD_OPACITY = 0.3; + const groupByLabel = groupBy && groupBy.length > 0 ? groupBy.join(', ') : null; + const dateFormatter = useDateFormatter(xMin, xMax); + const timeLabel = TIME_LABELS[timeUnit as keyof typeof TIME_LABELS]; + + if (isLoading) { + return ; + } else if (hasError) { + return ; + } else if (!hasData) { + return ; + } + + return ( + <> + + + + {threshold && threshold.value ? ( + + ) : null} + {threshold && threshold.value && isBelow ? ( + + ) : null} + {threshold && threshold.value && isAbove ? ( + + ) : null} + + + + + +
+ {groupByLabel != null ? ( + + + + ) : ( + + + + )} +
+ + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index 295e60552cce5..e063b880ab843 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -34,12 +34,14 @@ interface LogsContextMeta { isInternal?: boolean; } +export type AlertsContext = AlertsContextValue; interface Props { errors: IErrorObject; alertParams: Partial; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; - alertsContext: AlertsContextValue; + alertsContext: AlertsContext; + sourceId: string; } const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; @@ -62,12 +64,12 @@ export const ExpressionEditor: React.FC = (props) => { <> {isInternal ? ( - + ) : ( - + )} @@ -119,7 +121,7 @@ export const SourceStatusWrapper: React.FC = (props) => { }; export const Editor: React.FC = (props) => { - const { setAlertParams, alertParams, errors } = props; + const { setAlertParams, alertParams, errors, alertsContext, sourceId } = props; const [hasSetDefaults, setHasSetDefaults] = useState(false); const { sourceStatus } = useLogSourceContext(); useMount(() => { @@ -227,6 +229,9 @@ export const Editor: React.FC = (props) => { updateCriterion={updateCriterion} removeCriterion={removeCriterion} errors={errors.criteria as IErrorObject} + alertParams={alertParams} + context={alertsContext} + sourceId={sourceId} /> { + const [chartPreviewData, setChartPreviewData] = useState< + GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series'] + >([]); + const [hasError, setHasError] = useState(false); + const [getChartPreviewDataRequest, getChartPreviewData] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + setHasError(false); + return await callGetChartPreviewDataAPI(sourceId, context.http.fetch, alertParams, buckets); + }, + onResolve: ({ data: { series } }) => { + setHasError(false); + setChartPreviewData(series); + }, + onReject: (error) => { + setHasError(true); + }, + }, + [sourceId, context.http.fetch, alertParams, buckets] + ); + + const isLoading = useMemo(() => getChartPreviewDataRequest.state === 'pending', [ + getChartPreviewDataRequest.state, + ]); + + return { + chartPreviewData, + hasError, + isLoading, + getChartPreviewData, + }; +}; + +export const callGetChartPreviewDataAPI = async ( + sourceId: string, + fetch: AlertsContext['http']['fetch'], + alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset, + buckets: number +) => { + const response = await fetch(LOG_ALERTS_CHART_PREVIEW_DATA_PATH, { + method: 'POST', + body: JSON.stringify( + getLogAlertsChartPreviewDataRequestPayloadRT.encode({ + data: { + sourceId, + alertParams, + buckets, + }, + }) + ), + }); + + return decodeOrThrow(getLogAlertsChartPreviewDataSuccessResponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx new file mode 100644 index 0000000000000..239afd93a7a1f --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo } from 'react'; +import { niceTimeFormatter, TooltipValue } from '@elastic/charts'; +import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts'; +import { sum, min as getMin, max as getMax } from 'lodash'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { formatNumber } from '../../../../../common/formatters/number'; +import { GetLogAlertsChartPreviewDataSuccessResponsePayload } from '../../../../../common/http_api'; + +type Series = GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series']; + +export const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss'), +}; + +export const NUM_BUCKETS = 20; + +export const TIME_LABELS = { + s: i18n.translate('xpack.infra.alerts.timeLabels.seconds', { defaultMessage: 'seconds' }), + m: i18n.translate('xpack.infra.alerts.timeLabels.minutes', { defaultMessage: 'minutes' }), + h: i18n.translate('xpack.infra.alerts.timeLabels.hours', { defaultMessage: 'hours' }), + d: i18n.translate('xpack.infra.alerts.timeLabels.days', { defaultMessage: 'days' }), +}; + +export const useDateFormatter = (xMin?: number, xMax?: number) => { + const dateFormatter = useMemo(() => { + if (typeof xMin === 'number' && typeof xMax === 'number') { + return niceTimeFormatter([xMin, xMax]); + } else { + return (value: number) => `${value}`; + } + }, [xMin, xMax]); + return dateFormatter; +}; + +export const yAxisFormatter = formatNumber; + +export const getDomain = (series: Series, stacked: boolean = false) => { + let min: number | null = null; + let max: number | null = null; + const valuesByTimestamp = series.reduce<{ [timestamp: number]: number[] }>((acc, serie) => { + serie.points.forEach((point) => { + const valuesForTimestamp = acc[point.timestamp] || []; + acc[point.timestamp] = [...valuesForTimestamp, point.value]; + }); + return acc; + }, {}); + const pointValues = Object.values(valuesByTimestamp); + pointValues.forEach((results) => { + const maxResult = stacked ? sum(results) : getMax(results); + const minResult = getMin(results); + if (maxResult && (!max || maxResult > max)) { + max = maxResult; + } + if (minResult && (!min || minResult < min)) { + min = minResult; + } + }); + const timestampValues = Object.keys(valuesByTimestamp).map(Number); + const minTimestamp = getMin(timestampValues) || 0; + const maxTimestamp = getMax(timestampValues) || 0; + return { yMin: min || 0, yMax: max || 0, xMin: minTimestamp, xMax: maxTimestamp }; +}; + +export const getChartTheme = (isDarkMode: boolean): Theme => { + return isDarkMode ? DARK_THEME : LIGHT_THEME; +}; + +export const EmptyContainer: React.FC = ({ children }) => ( +
+ {children} +
+); + +export const ChartContainer: React.FC = ({ children }) => ( +
+ {children} +
+); + +export const NoDataState = () => { + return ( + + + + + + ); +}; + +export const LoadingState = () => { + return ( + + + + + + ); +}; + +export const ErrorState = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx index 521fbf209870c..f11d6cdb8d26d 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LogEntryFlyout } from './log_entry_flyout'; +export * from './log_entry_flyout'; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index 57f27ee76184b..76ffada510e51 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -26,12 +26,10 @@ import { InfraLoadingPanel } from '../../loading'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; import { LogEntriesItem, LogEntriesItemField } from '../../../../common/http_api'; -interface Props { +export interface LogEntryFlyoutProps { flyoutItem: LogEntriesItem | null; setFlyoutVisibility: (visible: boolean) => void; - setFilter: (filter: string) => void; - setTarget: (timeKey: TimeKey, flyoutItemId: string) => void; - + setFilter: (filter: string, flyoutItemId: string, timeKey?: TimeKey) => void; loading: boolean; } @@ -40,27 +38,27 @@ export const LogEntryFlyout = ({ loading, setFlyoutVisibility, setFilter, - setTarget, -}: Props) => { +}: LogEntryFlyoutProps) => { const createFilterHandler = useCallback( (field: LogEntriesItemField) => () => { + if (!flyoutItem) { + return; + } + const filter = `${field.field}:"${field.value}"`; - setFilter(filter); + const timestampMoment = moment(flyoutItem.key.time); + let target; - if (flyoutItem && flyoutItem.key) { - const timestampMoment = moment(flyoutItem.key.time); - if (timestampMoment.isValid()) { - setTarget( - { - time: timestampMoment.valueOf(), - tiebreaker: flyoutItem.key.tiebreaker, - }, - flyoutItem.id - ); - } + if (timestampMoment.isValid()) { + target = { + time: timestampMoment.valueOf(), + tiebreaker: flyoutItem.key.tiebreaker, + }; } + + setFilter(filter, flyoutItem.id, target); }, - [flyoutItem, setFilter, setTarget] + [flyoutItem, setFilter] ); const closeFlyout = useCallback(() => setFlyoutVisibility(false), [setFlyoutVisibility]); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index e986fa37c2b2c..4ad654614237d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -10,6 +10,7 @@ import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_a import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; +import { LogFlyout } from '../../../containers/logs/log_flyout'; export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); @@ -23,20 +24,22 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) } return ( - - + - {children} - - + + {children} + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 65cc4a6c4a704..de72ac5c5a574 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -7,7 +7,9 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { encode, RisonValue } from 'rison-node'; +import { stringify } from 'query-string'; +import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; @@ -29,6 +31,9 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; +import { LogEntryFlyout, LogEntryFlyoutProps } from '../../../components/logging/log_entry_flyout'; +import { LogFlyout } from '../../../containers/logs/log_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -42,6 +47,7 @@ export const PAGINATION_DEFAULTS = { export const LogEntryRateResultsContent: React.FunctionComponent = () => { useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results' }); useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results', delay: 15000 }); + const navigateToApp = useKibana().services.application?.navigateToApp; const { sourceId } = useLogSourceContext(); @@ -79,6 +85,30 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { lastChangedTime: Date.now(), })); + const linkToLogStream = useCallback( + (filter, id, timeKey) => { + const params = { + logPosition: encode({ + end: moment(queryTimeRange.value.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + position: timeKey as RisonValue, + start: moment(queryTimeRange.value.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + streamLive: false, + }), + flyoutOptions: encode({ + surroundingLogsId: id, + }), + logFilter: encode({ + expression: filter, + kind: 'kuery', + }), + }; + + // eslint-disable-next-line no-unused-expressions + navigateToApp?.('logs', { path: `/stream?${stringify(params)}` }); + }, + [queryTimeRange, navigateToApp] + ); + const bucketDuration = useMemo( () => getBucketDuration(queryTimeRange.value.startTime, queryTimeRange.value.endTime), [queryTimeRange.value.endTime, queryTimeRange.value.startTime] @@ -115,6 +145,10 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { filteredDatasets: selectedDatasets, }); + const { flyoutVisible, setFlyoutVisibility, flyoutItem, isLoading: isFlyoutLoading } = useContext( + LogFlyout.Context + ); + const handleQueryTimeRangeChange = useCallback( ({ start: startTime, end: endTime }: { start: string; end: string }) => { setQueryTimeRange({ @@ -198,75 +232,86 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { ); return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - +
+ + + + + +
+ + + {flyoutVisible ? ( + + ) : null} + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index fece2522de574..a543f95bf4ffb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useState } from 'react'; +import React, { useMemo, useCallback, useState, useContext } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; @@ -37,6 +37,7 @@ import { } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { LogFlyout } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -45,6 +46,13 @@ const MENU_LABEL = i18n.translate('xpack.infra.logAnomalies.logEntryExamplesMenu defaultMessage: 'View actions for log entry', }); +const VIEW_DETAILS_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewDetailsLabel', + { + defaultMessage: 'View details', + } +); + const VIEW_IN_STREAM_LABEL = i18n.translate( 'xpack.infra.logs.analysis.logEntryExamplesViewInStreamLabel', { @@ -80,6 +88,8 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ const setItemIsHovered = useCallback(() => setIsHovered(true), []); const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); + const { setFlyoutVisibility, setFlyoutId } = useContext(LogFlyout.Context); + // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -116,6 +126,13 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ } return [ + { + label: VIEW_DETAILS_LABEL, + onClick: () => { + setFlyoutId(id); + setFlyoutVisibility(true); + }, + }, { label: VIEW_IN_STREAM_LABEL, onClick: viewInStreamLinkProps.onClick, @@ -127,7 +144,13 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ href: viewAnomalyInMachineLearningLinkProps.href, }, ]; - }, [viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + }, [ + id, + setFlyoutId, + setFlyoutVisibility, + viewInStreamLinkProps, + viewAnomalyInMachineLearningLinkProps, + ]); return ( { const [, { setContextEntry }] = useContext(ViewLogInContext.Context); + const setFilter = useCallback( + (filter, flyoutItemId, timeKey) => { + applyLogFilterQuery(filter); + if (timeKey) { + jumpToTargetPosition(timeKey); + } + setSurroundingLogsId(flyoutItemId); + stopLiveStreaming(); + }, + [applyLogFilterQuery, jumpToTargetPosition, setSurroundingLogsId, stopLiveStreaming] + ); + return ( <> @@ -65,12 +77,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { {flyoutVisible ? ( { - jumpToTargetPosition(timeKey); - setSurroundingLogsId(flyoutItemId); - stopLiveStreaming(); - }} + setFilter={setFilter} setFlyoutVisibility={setFlyoutVisibility} flyoutItem={flyoutItem} loading={isLoading} diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts index f94c6b6156ae4..d706d598058bd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts @@ -7,7 +7,7 @@ import { calculateDomain } from './calculate_domain'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; -import { MetricsExplorerColor } from '../../../../../../common/color_palette'; +import { Color } from '../../../../../../common/color_palette'; describe('calculateDomain()', () => { const series: MetricsExplorerSeries = { id: 'test-01', @@ -29,12 +29,12 @@ describe('calculateDomain()', () => { { aggregation: 'avg', field: 'system.memory.free', - color: MetricsExplorerColor.color0, + color: Color.color0, }, { aggregation: 'avg', field: 'system.memory.used.bytes', - color: MetricsExplorerColor.color1, + color: Color.color1, }, ]; it('should return the min and max across 2 metrics', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index afddaf6621f10..15ed28c095199 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -7,7 +7,7 @@ import { encode } from 'rison-node'; import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../../common/color_palette'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -91,9 +91,7 @@ const mapMetricToSeries = (chartOptions: MetricsExplorerChartOptions) => ( label: createMetricLabel(metric), axis_position: 'right', chart_type: 'line', - color: - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0), + color: (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0), fill: chartOptions.type === MetricsExplorerChartType.area ? 0.5 : 0, formatter: format === InfraFormatterType.bits ? InfraFormatterType.bytes : format, value_template: 'rate' === metric.aggregation ? '{{value}}/s' : '{{value}}', diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx index 8be03a7096f08..b81a905b4aa87 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; import { IFieldType } from 'src/plugins/data/public'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../common/color_palette'; import { MetricsExplorerMetric } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options'; @@ -26,7 +26,7 @@ interface SelectedOption { } export const MetricsExplorerMetrics = ({ options, onChange, fields, autoFocus = false }: Props) => { - const colors = Object.keys(MetricsExplorerColor) as MetricsExplorerColor[]; + const colors = Object.keys(Color) as Array; const [shouldFocus, setShouldFocus] = useState(autoFocus); // the EuiCombobox forwards the ref to an input element @@ -59,7 +59,7 @@ export const MetricsExplorerMetrics = ({ options, onChange, fields, autoFocus = .map((metric) => ({ label: metric.field || '', value: metric.field || '', - color: colorTransformer(metric.color || MetricsExplorerColor.color0), + color: colorTransformer(metric.color || Color.color0), })); const placeholderText = i18n.translate('xpack.infra.metricsExplorer.metricComboBoxPlaceholder', { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx index 9b594ef5e630f..a621dca1e0c51 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx @@ -14,7 +14,7 @@ import { BarSeriesStyle, } from '@elastic/charts'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../common/color_palette'; import { createMetricLabel } from './helpers/create_metric_label'; import { MetricsExplorerOptionsMetric, @@ -41,9 +41,7 @@ export const MetricExplorerSeriesChart = (props: Props) => { }; export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opacity }: Props) => { - const color = - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0); + const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0); const yAccessors = Array.isArray(id) ? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length) @@ -84,9 +82,7 @@ export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opac }; export const MetricsExplorerBarChart = ({ metric, id, series, stack }: Props) => { - const color = - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0); + const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0); const yAccessors = Array.isArray(id) ? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length) diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 299231f1821f0..d54cb758188c6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -9,19 +9,14 @@ import { values } from 'lodash'; import createContainer from 'constate'; import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; -import { MetricsExplorerColor } from '../../../../../common/color_palette'; +import { Color } from '../../../../../common/color_palette'; import { metricsExplorerMetricRT } from '../../../../../common/http_api/metrics_explorer'; const metricsExplorerOptionsMetricRT = t.intersection([ metricsExplorerMetricRT, t.partial({ rate: t.boolean, - color: t.keyof( - Object.fromEntries(values(MetricsExplorerColor).map((c) => [c, null])) as Record< - MetricsExplorerColor, - null - > - ), + color: t.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record), label: t.string, }), ]); @@ -100,17 +95,17 @@ export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [ { aggregation: 'avg', field: 'system.cpu.user.pct', - color: MetricsExplorerColor.color0, + color: Color.color0, }, { aggregation: 'avg', field: 'kubernetes.pod.cpu.usage.node.pct', - color: MetricsExplorerColor.color1, + color: Color.color1, }, { aggregation: 'avg', field: 'docker.cpu.total.pct', - color: MetricsExplorerColor.color2, + color: Color.color2, }, ]; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 2dda664a7f675..8a1264d254e40 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -25,7 +25,9 @@ export class Plugin implements InfraClientPluginClass { constructor(_context: PluginInitializerContext) {} setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { - registerFeatures(pluginsSetup.home); + if (pluginsSetup.home) { + registerFeatures(pluginsSetup.home); + } pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createInventoryMetricAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); diff --git a/x-pack/plugins/infra/public/register_feature.ts b/x-pack/plugins/infra/public/register_feature.ts index 37217eaeaab2a..0e6e208df8a53 100644 --- a/x-pack/plugins/infra/public/register_feature.ts +++ b/x-pack/plugins/infra/public/register_feature.ts @@ -22,7 +22,7 @@ export const registerFeatures = (homePlugin: HomePublicPluginSetup) => { }), icon: 'metricsApp', path: `/app/metrics`, - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); @@ -37,7 +37,7 @@ export const registerFeatures = (homePlugin: HomePublicPluginSetup) => { }), icon: 'logsApp', path: `/app/logs`, - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); }; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index a1494a023201f..20a276dbb4f41 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -25,7 +25,7 @@ export type InfraClientStartExports = void; export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; observability: ObservabilityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index 3e32cebf19ac2..b75e831ac875c 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -18,7 +18,7 @@ export const METRICS_FEATURE = { icon: 'metricsApp', navLinkId: 'metrics', app: ['infra', 'metrics', 'kibana'], - catalogue: ['infraops'], + catalogue: ['infraops', 'metrics'], management: { insightsAndAlerting: ['triggersActions'], }, @@ -26,7 +26,7 @@ export const METRICS_FEATURE = { privileges: { all: { app: ['infra', 'metrics', 'kibana'], - catalogue: ['infraops'], + catalogue: ['infraops', 'metrics'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -42,7 +42,7 @@ export const METRICS_FEATURE = { }, read: { app: ['infra', 'metrics', 'kibana'], - catalogue: ['infraops'], + catalogue: ['infraops', 'metrics'], api: ['infra'], savedObject: { all: [], @@ -68,12 +68,12 @@ export const LOGS_FEATURE = { icon: 'logsApp', navLinkId: 'logs', app: ['infra', 'logs', 'kibana'], - catalogue: ['infralogging'], + catalogue: ['infralogging', 'logs'], alerting: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], privileges: { all: { app: ['infra', 'logs', 'kibana'], - catalogue: ['infralogging'], + catalogue: ['infralogging', 'logs'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -86,7 +86,7 @@ export const LOGS_FEATURE = { }, read: { app: ['infra', 'logs', 'kibana'], - catalogue: ['infralogging'], + catalogue: ['infralogging', 'logs'], api: ['infra'], alerting: { all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index c080618f2a563..a72e40e25b479 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -36,6 +36,7 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; import { initSourceRoute } from './routes/source'; import { initAlertPreviewRoute } from './routes/alerting'; +import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ @@ -72,4 +73,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLogSourceConfigurationRoutes(libs); initLogSourceStatusRoutes(libs); initAlertPreviewRoute(libs); + initGetLogAlertsChartPreviewDataRoute(libs); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts new file mode 100644 index 0000000000000..026f003463ef2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RequestHandlerContext } from 'src/core/server'; +import { InfraSource } from '../../sources'; +import { KibanaFramework } from '../../adapters/framework/kibana_framework_adapter'; +import { + GetLogAlertsChartPreviewDataAlertParamsSubset, + Series, + Point, +} from '../../../../common/http_api/log_alerts'; +import { + getGroupedESQuery, + getUngroupedESQuery, + buildFiltersFromCriteria, +} from './log_threshold_executor'; +import { + UngroupedSearchQueryResponseRT, + UngroupedSearchQueryResponse, + GroupedSearchQueryResponse, + GroupedSearchQueryResponseRT, +} from '../../../../common/alerting/logs/types'; +import { decodeOrThrow } from '../../../../common/runtime_types'; + +const COMPOSITE_GROUP_SIZE = 40; + +export async function getChartPreviewData( + requestContext: RequestHandlerContext, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'], + alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset, + buckets: number +) { + const indexPattern = sourceConfiguration.configuration.logAlias; + const timestampField = sourceConfiguration.configuration.fields.timestamp; + + const { groupBy, timeSize, timeUnit } = alertParams; + const isGrouped = groupBy && groupBy.length > 0 ? true : false; + + // Charts will use an expanded time range + const expandedAlertParams = { + ...alertParams, + timeSize: timeSize * buckets, + }; + + const { rangeFilter } = buildFiltersFromCriteria(expandedAlertParams, timestampField); + + const query = isGrouped + ? getGroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern) + : getUngroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern); + + if (!query) { + throw new Error('ES query could not be built from the provided alert params'); + } + + const expandedQuery = addHistogramAggregationToQuery( + query, + rangeFilter, + `${timeSize}${timeUnit}`, + timestampField, + isGrouped + ); + + const series = isGrouped + ? processGroupedResults(await getGroupedResults(expandedQuery, requestContext, callWithRequest)) + : processUngroupedResults( + await getUngroupedResults(expandedQuery, requestContext, callWithRequest) + ); + + return { series }; +} + +// Expand the same query that powers the executor with a date histogram aggregation +const addHistogramAggregationToQuery = ( + query: any, + rangeFilter: any, + interval: string, + timestampField: string, + isGrouped: boolean +) => { + const histogramAggregation = { + histogramBuckets: { + date_histogram: { + field: timestampField, + fixed_interval: interval, + // Utilise extended bounds to make sure we get a full set of buckets even if there are empty buckets + // at the start and / or end of the range. + extended_bounds: { + min: rangeFilter.range[timestampField].gte, + max: rangeFilter.range[timestampField].lte, + }, + }, + }, + }; + + if (isGrouped) { + query.body.aggregations.groups.aggregations.filtered_results = { + ...query.body.aggregations.groups.aggregations.filtered_results, + aggregations: histogramAggregation, + }; + } else { + query.body = { + ...query.body, + aggregations: histogramAggregation, + }; + } + + return query; +}; + +const getUngroupedResults = async ( + query: object, + requestContext: RequestHandlerContext, + callWithRequest: KibanaFramework['callWithRequest'] +) => { + return decodeOrThrow(UngroupedSearchQueryResponseRT)( + await callWithRequest(requestContext, 'search', query) + ); +}; + +const getGroupedResults = async ( + query: object, + requestContext: RequestHandlerContext, + callWithRequest: KibanaFramework['callWithRequest'] +) => { + let compositeGroupBuckets: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] = []; + let lastAfterKey: GroupedSearchQueryResponse['aggregations']['groups']['after_key'] | undefined; + + while (true) { + const queryWithAfterKey: any = { ...query }; + queryWithAfterKey.body.aggregations.groups.composite.after = lastAfterKey; + const groupResponse: GroupedSearchQueryResponse = decodeOrThrow(GroupedSearchQueryResponseRT)( + await callWithRequest(requestContext, 'search', queryWithAfterKey) + ); + compositeGroupBuckets = [ + ...compositeGroupBuckets, + ...groupResponse.aggregations.groups.buckets, + ]; + lastAfterKey = groupResponse.aggregations.groups.after_key; + if (groupResponse.aggregations.groups.buckets.length < COMPOSITE_GROUP_SIZE) { + break; + } + } + + return compositeGroupBuckets; +}; + +const processGroupedResults = ( + results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] +): Series => { + return results.reduce((series, group) => { + if (!group.filtered_results.histogramBuckets) return series; + const groupName = Object.values(group.key).join(', '); + const points = group.filtered_results.histogramBuckets.buckets.reduce( + (pointsAcc, bucket) => { + const { key, doc_count: count } = bucket; + return [...pointsAcc, { timestamp: key, value: count }]; + }, + [] + ); + return [...series, { id: groupName, points }]; + }, []); +}; + +const processUngroupedResults = (results: UngroupedSearchQueryResponse): Series => { + if (!results.aggregations?.histogramBuckets) return []; + const points = results.aggregations.histogramBuckets.buckets.reduce( + (pointsAcc, bucket) => { + const { key, doc_count: count } = bucket; + return [...pointsAcc, { timestamp: key, value: count }]; + }, + [] + ); + return [{ id: everythingSeriesName, points }]; +}; + +const everythingSeriesName = i18n.translate( + 'xpack.infra.logs.alerting.threshold.everythingSeriesName', + { + defaultMessage: 'Log entries', + } +); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index 85bb18e199192..db76e955f0073 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -145,7 +145,10 @@ const processGroupByResults = ( }); }; -const buildFiltersFromCriteria = (params: LogDocumentCountAlertParams, timestampField: string) => { +export const buildFiltersFromCriteria = ( + params: Omit, + timestampField: string +) => { const { timeSize, timeUnit, criteria } = params; const interval = `${timeSize}${timeUnit}`; const intervalAsSeconds = getIntervalInSeconds(interval); @@ -193,8 +196,8 @@ const buildFiltersFromCriteria = (params: LogDocumentCountAlertParams, timestamp return { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters }; }; -const getGroupedESQuery = ( - params: LogDocumentCountAlertParams, +export const getGroupedESQuery = ( + params: Omit, sourceConfiguration: InfraSource['configuration'], index: string ): object | undefined => { @@ -253,8 +256,8 @@ const getGroupedESQuery = ( }; }; -const getUngroupedESQuery = ( - params: LogDocumentCountAlertParams, +export const getUngroupedESQuery = ( + params: Omit, sourceConfiguration: InfraSource['configuration'], index: string ): object => { diff --git a/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts b/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts new file mode 100644 index 0000000000000..95389e14acdb8 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { InfraBackendLibs } from '../../lib/infra_types'; +import { + LOG_ALERTS_CHART_PREVIEW_DATA_PATH, + getLogAlertsChartPreviewDataSuccessResponsePayloadRT, + getLogAlertsChartPreviewDataRequestPayloadRT, +} from '../../../common/http_api/log_alerts/chart_preview_data'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { getChartPreviewData } from '../../lib/alerting/log_threshold/log_threshold_chart_preview'; + +export const initGetLogAlertsChartPreviewDataRoute = ({ framework, sources }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ALERTS_CHART_PREVIEW_DATA_PATH, + validate: { + body: createValidationFunction(getLogAlertsChartPreviewDataRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { sourceId, buckets, alertParams }, + } = request.body; + + const sourceConfiguration = await sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); + + try { + const { series } = await getChartPreviewData( + requestContext, + sourceConfiguration, + framework.callWithRequest, + alertParams, + buckets + ); + + return response.ok({ + body: getLogAlertsChartPreviewDataSuccessResponsePayloadRT.encode({ + data: { series }, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts b/x-pack/plugins/infra/server/routes/log_alerts/index.ts similarity index 78% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts rename to x-pack/plugins/infra/server/routes/log_alerts/index.ts index 2d37f037e21e5..5634fda043a52 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts +++ b/x-pack/plugins/infra/server/routes/log_alerts/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EngineOverviewHeader } from './engine_overview_header'; +export * from './chart_preview_data'; diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index ab0a2ba24ba66..3a47da9fee01f 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -7,5 +7,5 @@ "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], "extraPublicDirs": ["common"], - "requiredBundles": ["kibanaReact", "esUiShared"] + "requiredBundles": ["kibanaReact", "esUiShared", "home"] } diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index a8cc7b72eb17f..9f4aa2e18000d 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -13,9 +13,13 @@ import { import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { + HomePublicPluginSetup, + FeatureCatalogueCategory, +} from '../../../../src/plugins/home/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { PLUGIN_ID, CheckPermissionsResponse, PostIngestSetupResponse } from '../common'; +import { BASE_PATH } from './applications/ingest_manager/constants'; import { IngestManagerConfigType } from '../common/types'; import { setupRouteService, appRoutesService } from '../common'; @@ -95,6 +99,21 @@ export class IngestManagerPlugin deps.home.tutorials.registerDirectoryNotice(PLUGIN_ID, TutorialDirectoryNotice); deps.home.tutorials.registerDirectoryHeaderLink(PLUGIN_ID, TutorialDirectoryHeaderLink); deps.home.tutorials.registerModuleNotice(PLUGIN_ID, TutorialModuleNotice); + + deps.home.featureCatalogue.register({ + id: 'ingestManager', + title: i18n.translate('xpack.ingestManager.featureCatalogueTitle', { + defaultMessage: 'Add Elastic Agent', + }), + description: i18n.translate('xpack.ingestManager.featureCatalogueDescription', { + defaultMessage: 'Add and manage your fleet of Elastic Agents and integrations.', + }), + icon: 'indexManagementApp', + showOnHomePage: true, + path: BASE_PATH, + category: FeatureCatalogueCategory.DATA, + order: 510, + }); } return {}; diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index ce1659176faaf..4321dca7102a1 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -179,10 +179,12 @@ export class IngestManagerPlugin icon: 'savedObjectsApp', navLinkId: PLUGIN_ID, app: [PLUGIN_ID, 'kibana'], + catalogue: ['ingestManager'], privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], app: [PLUGIN_ID, 'kibana'], + catalogue: ['ingestManager'], savedObject: { all: allSavedObjectTypes, read: [], @@ -192,6 +194,7 @@ export class IngestManagerPlugin read: { api: [`${PLUGIN_ID}-read`], app: [PLUGIN_ID, 'kibana'], + catalogue: ['ingestManager'], // TODO: check if this is actually available to read user savedObject: { all: [], read: allSavedObjectTypes, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts index 78e6a11fa78a4..19a5c2dc08762 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import deepEqual from 'fast-deep-equal'; import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'src/core/server'; import { Agent, @@ -29,16 +30,19 @@ export async function agentCheckin( ) { const updateData: Partial = {}; const { updatedErrorEvents } = await processEventsForCheckin(soClient, agent, data.events); - if (updatedErrorEvents) { + if ( + updatedErrorEvents && + !(updatedErrorEvents.length === 0 && agent.current_error_events.length === 0) + ) { updateData.current_error_events = JSON.stringify(updatedErrorEvents); } - if (data.localMetadata) { + if (data.localMetadata && !deepEqual(data.localMetadata, agent.local_metadata)) { updateData.local_metadata = data.localMetadata; } - if (data.status !== agent.last_checkin_status) { updateData.last_checkin_status = data.status; } + // Update agent only if something changed if (Object.keys(updateData).length > 0) { await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, updateData); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts index ff1a91b00d84f..201003629e5ea 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts @@ -14,7 +14,7 @@ import * as Registry from '../../registry'; import { AssetType, KibanaAssetType, AssetReference } from '../../../../types'; import { savedObjectTypes } from '../../packages'; -type SavedObjectToBe = Required> & { +type SavedObjectToBe = Required> & { type: AssetType; }; export type ArchiveAsset = Pick< diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index b4af231024370..1e5632719fb72 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -15,7 +15,7 @@ let cachedAdminUser: null | { username: string; password: string } = null; class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { - return await soClient.find({ + return await soClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, searchFields: ['is_default'], search: 'true', @@ -42,6 +42,7 @@ class OutputService { } return { + id: outputs.saved_objects[0].id, ...outputs.saved_objects[0].attributes, }; } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx index 35dd462d88425..63ebb47dfc573 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx @@ -32,7 +32,7 @@ export const fieldsConfig: FieldsConfig = { helpText: ( {'field'}, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx index 2a278a251c30f..8cbc064c1c90c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx @@ -133,7 +133,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'ENGLISH'} }} /> ), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx index 31eac38222afb..5986374b338cf 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx @@ -157,7 +157,7 @@ export const Enrich: FunctionComponent = () => { helpText: ( @@ -182,7 +182,7 @@ export const Enrich: FunctionComponent = () => { helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.enrichForm.targetFieldHelpText', { - defaultMessage: 'Field used to contain enrich data', + defaultMessage: 'Field used to contain enrich data.', } )} validations={[targetFieldValidator]} @@ -202,7 +202,7 @@ export const Enrich: FunctionComponent = () => { helpText: ( { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx index ef2aa62c4a7de..9bb1d679938ed 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx @@ -80,7 +80,7 @@ export const GeoIP: FunctionComponent = () => { @@ -88,7 +88,7 @@ export const GeoIP: FunctionComponent = () => { helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.geoIPForm.targetFieldHelpText', { - defaultMessage: 'Field used to contain geo data properties', + defaultMessage: 'Field used to contain geo data properties.', } )} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx index 1ed9898149a67..d021038fda94f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx @@ -87,7 +87,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.grokForm.traceMatchFieldHelpText', { - defaultMessage: 'Add metadata about the matching expression to the document', + defaultMessage: 'Add metadata about the matching expression to the document.', } ), }, @@ -99,7 +99,7 @@ export const Grok: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx index 4d3445d469da2..a0bda245d667b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx @@ -29,7 +29,7 @@ const fieldsConfig: FieldsConfig = { }), deserializer: String, helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldHelpText', { - defaultMessage: 'Regular expression used to match substrings in the field', + defaultMessage: 'Regular expression used to match substrings in the field.', }), validations: [ { @@ -49,7 +49,7 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.gsubForm.replacementFieldHelpText', - { defaultMessage: 'Replacement text for matches' } + { defaultMessage: 'Replacement text for matches.' } ), validations: [ { @@ -69,7 +69,7 @@ export const Gsub: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/html_strip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/html_strip.tsx index c6ca7df4cc3e7..fb1a2d97672b0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/html_strip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/html_strip.tsx @@ -19,7 +19,7 @@ export const HtmlStrip: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx index ee8d7cc55a9f1..68281fc11f340 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx @@ -82,7 +82,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.inferenceForm.modelIDFieldHelpText', { - defaultMessage: 'ID of the model to infer against', + defaultMessage: 'ID of the model to infer against.', } ), validations: [ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx index 712d0106459b1..c35a5b463f573 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx @@ -28,7 +28,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.joinForm.separatorFieldHelpText', { - defaultMessage: 'Separator character', + defaultMessage: 'Separator character.', } ), validations: [ @@ -49,7 +49,7 @@ export const Join: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx index 9d62c67460136..5c4c53b65b6dc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx @@ -61,7 +61,7 @@ export const Json: FunctionComponent = () => { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index ac43593213687..b9bdea5522f32 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -144,6 +144,58 @@ describe('datatable_expression', () => { }); }); + test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a', meta: { type: 'date_range', aggConfigParams: { field: 'a' } } }, + { id: 'b', name: 'b', meta: { type: 'count' } }, + ], + rows: [{ a: 1588024800000, b: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: '', + columns: { columnIds: ['a', 'b'], type: 'lens_datatable_columns' }, + }; + + const wrapper = mountWithIntl( + x as IFieldFormat} + onClickValue={onClickValue} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + test('it shows emptyPlaceholder for undefined bucketed data', () => { const { args, data } = sampleArgs(); const emptyData: LensMultiTable = { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 02186ecf09b4b..87ac2d1710b19 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -164,9 +164,8 @@ export function DatatableComponent(props: DatatableRenderProps) { const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; - const isDateHistogram = col.meta?.type === 'date_histogram'; - const timeFieldName = - negate && isDateHistogram ? undefined : col?.meta?.aggConfigParams?.field; + const isDate = col.meta?.type === 'date_histogram' || col.meta?.type === 'date_range'; + const timeFieldName = negate && isDate ? undefined : col?.meta?.aggConfigParams?.field; const rowIndex = firstTable.rows.findIndex((row) => row[field] === value); const data: LensFilterEvent['data'] = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 8291b673cd17a..f17bf172b0fb1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -84,6 +84,7 @@ const initialState: IndexPatternPrivateState = { id: '1', title: 'idx1', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -134,6 +135,7 @@ const initialState: IndexPatternPrivateState = { id: '2', title: 'idx2', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', @@ -191,6 +193,7 @@ const initialState: IndexPatternPrivateState = { id: '3', title: 'idx3', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -322,8 +325,20 @@ describe('IndexPattern Data Panel', () => { isFirstExistenceFetch: false, currentIndexPatternId: 'a', indexPatterns: { - a: { id: 'a', title: 'aaa', timeFieldName: 'atime', fields: [] }, - b: { id: 'b', title: 'bbb', timeFieldName: 'btime', fields: [] }, + a: { + id: 'a', + title: 'aaa', + timeFieldName: 'atime', + fields: [], + hasRestrictions: false, + }, + b: { + id: 'b', + title: 'bbb', + timeFieldName: 'btime', + fields: [], + hasRestrictions: false, + }, }, layers: { 1: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 0777b9b9d8e57..f7adf91e307da 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -126,6 +126,7 @@ export function IndexPatternDataPanel({ title: indexPatterns[id].title, timeFieldName: indexPatterns[id].timeFieldName, fields: indexPatterns[id].fields, + hasRestrictions: indexPatterns[id].hasRestrictions, })); const dslQuery = buildSafeEsQuery( @@ -422,6 +423,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ] ); + const fieldInfoUnavailable = existenceFetchFailed || currentIndexPattern.hasRestrictions; + return ( - {!existenceFetchFailed && ( + {!fieldInfoUnavailable && ( { foo: { id: 'foo', title: 'Foo pattern', + hasRestrictions: false, fields: [ { aggregatable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index b2a59788b50f9..e4dfa69813743 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -181,7 +181,7 @@ export function FieldSelect({ }} renderOption={(option, searchValue) => { return ( - + ( - + ), - [fieldProps, exists] + [fieldProps, exists, hideDetails] ); return ( 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 0ba7b7df97853..900cd02622aaf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -21,6 +21,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -70,6 +71,7 @@ const expectedIndexPatterns = { id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', 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 5489dcffc52c4..663d7c18bb370 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 @@ -20,6 +20,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -68,6 +69,7 @@ const expectedIndexPatterns = { 2: { id: '2', title: 'my-fake-restricted-pattern', + hasRestrictions: true, timeFieldName: 'timestamp', fields: [ { @@ -322,6 +324,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'no timefield', + hasRestrictions: false, fields: [ { name: 'bytes', @@ -532,6 +535,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'no timefield', + hasRestrictions: false, fields: [ { name: 'bytes', @@ -1350,6 +1354,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', @@ -1493,6 +1498,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', @@ -1555,6 +1561,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 738cdd611a7ba..92e35b257f24a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -62,6 +62,7 @@ const initialState: IndexPatternPrivateState = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -103,6 +104,7 @@ const initialState: IndexPatternPrivateState = { '2': { id: '2', title: 'my-fake-restricted-pattern', + hasRestrictions: true, timeFieldName: 'timestamp', fields: [ { @@ -160,6 +162,7 @@ const initialState: IndexPatternPrivateState = { id: '3', title: 'my-compatible-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d80bf779a5d17..660be9514a92f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -40,6 +40,7 @@ const indexPattern1 = ({ id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -105,6 +106,7 @@ const indexPattern2 = ({ id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', @@ -733,9 +735,9 @@ describe('loader', () => { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, indexPatterns: [ - { id: '1', title: '1', fields: [] }, - { id: '2', title: '1', fields: [] }, - { id: '3', title: '1', fields: [] }, + { id: '1', title: '1', fields: [], hasRestrictions: false }, + { id: '2', title: '1', fields: [], hasRestrictions: false }, + { id: '3', title: '1', fields: [], hasRestrictions: false }, ], setState, dslQuery, @@ -783,9 +785,9 @@ describe('loader', () => { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, indexPatterns: [ - { id: '1', title: '1', fields: [] }, - { id: '2', title: '1', fields: [] }, - { id: 'c', title: '1', fields: [] }, + { id: '1', title: '1', fields: [], hasRestrictions: false }, + { id: '2', title: '1', fields: [], hasRestrictions: false }, + { id: 'c', title: '1', fields: [], hasRestrictions: false }, ], setState, dslQuery, @@ -817,6 +819,7 @@ describe('loader', () => { { id: '1', title: '1', + hasRestrictions: false, fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 24906790a9fc9..585a1281cbf51 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -91,6 +91,7 @@ export async function loadIndexPatterns({ timeFieldName, fieldFormatMap, fields: newFields, + hasRestrictions: !!typeMeta?.aggs, }; return { @@ -334,6 +335,7 @@ export async function syncExistingFields({ title: string; fields: IndexPatternField[]; timeFieldName?: string | null; + hasRestrictions: boolean; }>; fetchJson: HttpSetup['post']; setState: SetState; @@ -343,6 +345,12 @@ export async function syncExistingFields({ showNoDataPopover: () => void; }) { const existenceRequests = indexPatterns.map((pattern) => { + if (pattern.hasRestrictions) { + return { + indexPatternTitle: pattern.title, + existingFieldNames: pattern.fields.map((field) => field.name), + }; + } const body: Record = { dslQuery, fromDate: dateRange.fromDate, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 869eee67d381d..31e6240993d36 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -11,6 +11,7 @@ export const createMockedIndexPattern = (): IndexPattern => ({ id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -70,6 +71,7 @@ export const createMockedRestrictedIndexPattern = () => ({ id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index 48a6079c58ac0..ac6bf63c37110 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -55,6 +55,7 @@ describe('date_histogram', () => { id: '1', title: 'Mock Indexpattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -69,6 +70,7 @@ describe('date_histogram', () => { 2: { id: '2', title: 'Mock Indexpattern 2', + hasRestrictions: false, fields: [ { name: 'other_timestamp', @@ -229,13 +231,50 @@ describe('date_histogram', () => { it('should reflect params correctly', () => { const esAggsConfig = dateHistogramOperation.toEsAggsConfig( state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, - 'col1' + 'col1', + state.indexPatterns['1'] ); expect(esAggsConfig).toEqual( expect.objectContaining({ params: expect.objectContaining({ interval: '42w', field: 'timestamp', + useNormalizedEsInterval: true, + }), + }) + ); + }); + + it('should not use normalized es interval for rollups', () => { + const esAggsConfig = dateHistogramOperation.toEsAggsConfig( + state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, + 'col1', + { + ...state.indexPatterns['1'], + fields: [ + { + name: 'timestamp', + displayName: 'timestamp', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'UTC', + calendar_interval: '42w', + }, + }, + }, + ], + } + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + interval: '42w', + field: 'timestamp', + useNormalizedEsInterval: false, }), }) ); @@ -300,6 +339,7 @@ describe('date_histogram', () => { { title: '', id: '', + hasRestrictions: true, fields: [ { name: 'dateField', @@ -343,6 +383,7 @@ describe('date_histogram', () => { { title: '', id: '', + hasRestrictions: false, fields: [ { name: 'dateField', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 2236bc576e2b6..57454291d43c5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -119,21 +119,24 @@ export const dateHistogramOperation: OperationDefinition ({ - id: columnId, - enabled: true, - type: 'date_histogram', - schema: 'segment', - params: { - field: column.sourceField, - time_zone: column.params.timeZone, - useNormalizedEsInterval: true, - interval: column.params.interval, - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, - }), + toEsAggsConfig: (column, columnId, indexPattern) => { + const usedField = indexPattern.fields.find((field) => field.name === column.sourceField); + return { + id: columnId, + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: column.sourceField, + time_zone: column.params.timeZone, + useNormalizedEsInterval: !usedField || !usedField.aggregationRestrictions?.date_histogram, + interval: column.params.interval, + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }; + }, paramEditor: ({ state, setState, currentColumn: currentColumn, layerId, dateRange, data }) => { const field = currentColumn && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index ef12fca690f0c..8ef53c1e0b425 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -84,7 +84,7 @@ interface BaseOperationDefinitionProps { * Function turning a column into an agg config passed to the `esaggs` function * together with the agg configs returned from other columns. */ - toEsAggsConfig: (column: C, columnId: string) => unknown; + toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; /** * Returns true if the `column` can also be used on `newIndexPattern`. * If this function returns false, the column is removed when switching index pattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index e6c8a5f6ac852..4c37d95f6b050 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -68,7 +68,7 @@ function buildMetricOperation>({ sourceField: field.name, }; }, - toEsAggsConfig: (column, columnId) => ({ + toEsAggsConfig: (column, columnId, _indexPattern) => ({ id: columnId, enabled: true, type: column.operationType, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx index 05bb2ef673888..2972ed2d0231b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx @@ -13,7 +13,7 @@ import { dataPluginMock } from '../../../../../../../src/plugins/data/public/moc import { createMockedIndexPattern } from '../../mocks'; import { TermsIndexPatternColumn } from './terms'; import { termsOperation } from './index'; -import { IndexPatternPrivateState } from '../../types'; +import { IndexPatternPrivateState, IndexPattern } from '../../types'; const defaultProps = { storage: {} as IStorageWrapper, @@ -69,7 +69,8 @@ describe('terms', () => { it('should reflect params correctly', () => { const esAggsConfig = termsOperation.toEsAggsConfig( state.layers.first.columns.col1 as TermsIndexPatternColumn, - 'col1' + 'col1', + {} as IndexPattern ); expect(esAggsConfig).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx index ac1ff9da2fea0..c1b19fd5549e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx @@ -95,7 +95,7 @@ export const termsOperation: OperationDefinition = { }, }; }, - toEsAggsConfig: (column, columnId) => ({ + toEsAggsConfig: (column, columnId, _indexPattern) => ({ id: columnId, enabled: true, type: 'terms', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 3fce2562f528e..4ac3fc89500f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -16,6 +16,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index d7fd0d3661c86..7b6eb11efc494 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -570,6 +570,7 @@ describe('state_helpers', () => { const indexPattern: IndexPattern = { id: 'test', title: '', + hasRestrictions: true, fields: [ { name: 'fieldA', 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 9473a1523b8ca..1b87c48dc7193 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -21,7 +21,11 @@ function getExpressionForLayer( } function getEsAggsConfig(column: C, columnId: string) { - return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); + return operationDefinitionMap[column.operationType].toEsAggsConfig( + column, + columnId, + indexPattern + ); } const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 95cc47e68f8a1..c101f1354b703 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -19,6 +19,7 @@ export interface IndexPattern { params: unknown; } >; + hasRestrictions: boolean; } export interface IndexPatternField { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 20b267caa9074..b8b43c3ed248b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -90,7 +90,41 @@ describe('suggestions', () => { columns: [ { columnId: 'b', - operation: { label: 'Days', dataType: 'date' as DataType, isBucketed: true }, + operation: { + label: 'Days', + dataType: 'date' as DataType, + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject any histogram operations', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'b', + operation: { + label: 'Durations', + dataType: 'number' as DataType, + isBucketed: true, + scale: 'interval', + }, }, { columnId: 'c', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 5d85ac3bbd56a..067b0bb4906df 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -15,7 +15,7 @@ function shouldReject({ table, keptLayerIds }: SuggestionRequest 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.changeType === 'reorder' || - table.columns.some((col) => col.operation.dataType === 'date') + table.columns.some((col) => col.operation.scale === 'interval') // Histograms are not good for pie ); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 20f2ce6c56774..729daed7223fe 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -259,6 +259,11 @@ export interface OperationMetadata { // A bucketed operation is grouped by duplicate values, otherwise each row is // treated as unique isBucketed: boolean; + /** + * ordinal: Each name is a unique value, but the names are in sorted order, like "Top values" + * interval: Histogram data, like date or number histograms + * ratio: Most number data is rendered as a ratio that includes 0 + */ scale?: 'ordinal' | 'interval' | 'ratio'; // Extra meta-information like cardinality, color // TODO currently it's not possible to differentiate between a field from a raw 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 632f6fc8861a4..79e4ed6958193 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 @@ -54,6 +54,18 @@ describe('xy_suggestions', () => { }; } + function histogramCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + isBucketed: true, + label: `${columnId} histogram`, + scale: 'interval', + }, + }; + } + // Helper that plucks out the important part of a suggestion for // most test assertions function suggestionSubset(suggestion: VisualizationSuggestion) { @@ -274,6 +286,33 @@ describe('xy_suggestions', () => { `); }); + test('suggests all basic x y chart with histogram on x', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), histogramCol('duration')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + + expect(rest).toHaveLength(visualizationTypes.length - 1); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": undefined, + "x": "duration", + "y": Array [ + "bytes", + ], + }, + ] + `); + }); + test('does not suggest multiple splits', () => { const suggestions = getSuggestions({ table: { 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 387d56c03e31a..75dd5a7a579b8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -112,13 +112,13 @@ function getBucketMappings(table: TableSuggestion, currentState?: State) { const currentXColumnIndex = prioritizedBuckets.findIndex( ({ columnId }) => columnId === currentLayer.xAccessor ); - const currentXDataType = - currentXColumnIndex > -1 && prioritizedBuckets[currentXColumnIndex].operation.dataType; + const currentXScaleType = + currentXColumnIndex > -1 && prioritizedBuckets[currentXColumnIndex].operation.scale; if ( - currentXDataType && - // make sure time gets mapped to x dimension even when changing current bucket/dimension mapping - (currentXDataType === 'date' || prioritizedBuckets[0].operation.dataType !== 'date') + currentXScaleType && + // make sure histograms get mapped to x dimension even when changing current bucket/dimension mapping + (currentXScaleType === 'interval' || prioritizedBuckets[0].operation.scale !== 'interval') ) { const [x] = prioritizedBuckets.splice(currentXColumnIndex, 1); prioritizedBuckets.unshift(x); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts index e2f0a7c06b400..6df051e83b97c 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts @@ -19,6 +19,11 @@ import { _VERSION, } from '../../constants.mock'; import { ENDPOINT_LIST_ID } from '../..'; +import { + ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION, + ENDPOINT_TRUSTED_APPS_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_NAME, +} from '../../constants'; import { ExceptionListSchema } from './exception_list_schema'; @@ -42,6 +47,15 @@ export const getExceptionListSchemaMock = (): ExceptionListSchema => ({ version: VERSION, }); +export const getTrustedAppsListSchemaMock = (): ExceptionListSchema => { + return { + ...getExceptionListSchemaMock(), + description: ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION, + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + name: ENDPOINT_TRUSTED_APPS_LIST_NAME, + }; +}; + /** * This is useful for end to end tests where we remove the auto generated parts for comparisons * such as created_at, updated_at, and id. diff --git a/x-pack/plugins/lists/public/common/mocks/kibana_core.ts b/x-pack/plugins/lists/public/common/mocks/kibana_core.ts deleted file mode 100644 index c078e8ccd5ea1..0000000000000 --- a/x-pack/plugins/lists/public/common/mocks/kibana_core.ts +++ /dev/null @@ -1,12 +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 { coreMock } from '../../../../../../src/core/public/mocks'; -import { CoreStart } from '../../../../../../src/core/public'; - -export type GlobalServices = Pick; - -export const createKibanaCoreStartMock = (): GlobalServices => coreMock.createStart(); diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index ba9c1db5af0a5..7e79b212f69cb 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.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 { createKibanaCoreStartMock } from '../common/mocks/kibana_core'; +import { coreMock } from '../../../../../src/core/public/mocks'; import { getExceptionListSchemaMock } from '../../common/schemas/response/exception_list_schema.mock'; import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListSchemaMock } from '../../common/schemas/request/create_exception_list_schema.mock'; @@ -34,39 +34,28 @@ import { ApiCallByIdProps, ApiCallByListIdProps } from './types'; const abortCtrl = new AbortController(); -jest.mock('../common/mocks/kibana_core', () => ({ - createKibanaCoreStartMock: (): jest.Mock => jest.fn(), -})); -const fetchMock = jest.fn(); +describe('Exceptions Lists API', () => { + let httpMock: ReturnType['http']; -/* - This is a little funky, in order for typescript to not - yell at us for converting 'Pick' to type 'Mock' - have to first convert to type 'unknown' - */ -const mockKibanaHttpService = ((createKibanaCoreStartMock() as unknown) as jest.Mock).mockReturnValue( - { - fetch: fetchMock, - } -); + beforeEach(() => { + httpMock = coreMock.createStart().http; + }); -describe('Exceptions Lists API', () => { describe('#addExceptionList', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); }); test('it invokes "addExceptionList" with expected url and body values', async () => { const payload = getCreateExceptionListSchemaMock(); await addExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists', { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, @@ -76,7 +65,7 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const payload = getCreateExceptionListSchemaMock(); const exceptionResponse = await addExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }); @@ -90,7 +79,7 @@ describe('Exceptions Lists API', () => { await expect( addExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: (payload as unknown) as ExceptionListSchema, signal: abortCtrl.signal, }) @@ -102,11 +91,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( addExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }) @@ -116,20 +105,19 @@ describe('Exceptions Lists API', () => { describe('#addExceptionListItem', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListItemSchemaMock()); }); test('it invokes "addExceptionListItem" with expected url and body values', async () => { const payload = getCreateExceptionListItemSchemaMock(); await addExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items', { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, @@ -139,7 +127,7 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const payload = getCreateExceptionListItemSchemaMock(); const exceptionResponse = await addExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }); @@ -153,7 +141,7 @@ describe('Exceptions Lists API', () => { await expect( addExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: (payload as unknown) as ExceptionListItemSchema, signal: abortCtrl.signal, }) @@ -165,11 +153,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListItemSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( addExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }) @@ -179,20 +167,19 @@ describe('Exceptions Lists API', () => { describe('#updateExceptionList', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); }); test('it invokes "updateExceptionList" with expected url and body values', async () => { const payload = getUpdateExceptionListSchemaMock(); await updateExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists', { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, @@ -202,7 +189,7 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const payload = getUpdateExceptionListSchemaMock(); const exceptionResponse = await updateExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }); @@ -216,7 +203,7 @@ describe('Exceptions Lists API', () => { await expect( updateExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }) @@ -228,11 +215,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( updateExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, list: payload, signal: abortCtrl.signal, }) @@ -242,20 +229,19 @@ describe('Exceptions Lists API', () => { describe('#updateExceptionListItem', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListItemSchemaMock()); }); test('it invokes "updateExceptionListItem" with expected url and body values', async () => { const payload = getUpdateExceptionListItemSchemaMock(); await updateExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items', { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, @@ -265,7 +251,7 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const payload = getUpdateExceptionListItemSchemaMock(); const exceptionResponse = await updateExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }); @@ -279,7 +265,7 @@ describe('Exceptions Lists API', () => { await expect( updateExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }) @@ -291,11 +277,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListItemSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( updateExceptionListItem({ - http: mockKibanaHttpService(), + http: httpMock, listItem: payload, signal: abortCtrl.signal, }) @@ -305,18 +291,17 @@ describe('Exceptions Lists API', () => { describe('#fetchExceptionListById', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); }); test('it invokes "fetchExceptionListById" with expected url and body values', async () => { await fetchExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists', { method: 'GET', query: { id: '1', @@ -328,7 +313,7 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const exceptionResponse = await fetchExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -338,7 +323,7 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ - http: mockKibanaHttpService(), + http: httpMock, id: 1, namespaceType: 'single', signal: abortCtrl.signal, @@ -352,11 +337,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( fetchExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -367,14 +352,13 @@ describe('Exceptions Lists API', () => { describe('#fetchExceptionListsItemsByListIds', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getFoundExceptionListItemSchemaMock()); + httpMock.fetch.mockResolvedValue(getFoundExceptionListItemSchemaMock()); }); test('it invokes "fetchExceptionListsItemsByListIds" with expected url and body values', async () => { await fetchExceptionListsItemsByListIds({ filterOptions: [], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList', 'myOtherListId'], namespaceTypes: ['single', 'single'], pagination: { @@ -384,7 +368,7 @@ describe('Exceptions Lists API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { list_id: 'myList,myOtherListId', @@ -404,7 +388,7 @@ describe('Exceptions Lists API', () => { tags: [], }, ], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['single'], pagination: { @@ -414,7 +398,7 @@ describe('Exceptions Lists API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { filter: 'exception-list.attributes.entries.field:hello world*', @@ -435,7 +419,7 @@ describe('Exceptions Lists API', () => { tags: [], }, ], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['agnostic'], pagination: { @@ -445,7 +429,7 @@ describe('Exceptions Lists API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { filter: 'exception-list-agnostic.attributes.entries.field:hello world*', @@ -466,7 +450,7 @@ describe('Exceptions Lists API', () => { tags: ['malware'], }, ], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['agnostic'], pagination: { @@ -476,7 +460,7 @@ describe('Exceptions Lists API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { filter: 'exception-list-agnostic.attributes.tags:malware', @@ -497,7 +481,7 @@ describe('Exceptions Lists API', () => { tags: ['malware'], }, ], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['agnostic'], pagination: { @@ -507,7 +491,7 @@ describe('Exceptions Lists API', () => { signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { filter: @@ -524,7 +508,7 @@ describe('Exceptions Lists API', () => { test('it returns expected format when call succeeds', async () => { const exceptionResponse = await fetchExceptionListsItemsByListIds({ filterOptions: [], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['endpoint_list_id'], namespaceTypes: ['single'], pagination: { @@ -539,7 +523,7 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ filterOptions: [], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['not a namespace type'], pagination: { @@ -557,12 +541,12 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListItemSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( fetchExceptionListsItemsByListIds({ filterOptions: [], - http: mockKibanaHttpService(), + http: httpMock, listIds: ['myList'], namespaceTypes: ['single'], pagination: { @@ -579,18 +563,17 @@ describe('Exceptions Lists API', () => { describe('#fetchExceptionListItemById', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListItemSchemaMock()); }); test('it invokes "fetchExceptionListItemById" with expected url and body values', async () => { await fetchExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items', { method: 'GET', query: { id: '1', @@ -602,7 +585,7 @@ describe('Exceptions Lists API', () => { test('it returns expected format when call succeeds', async () => { const exceptionResponse = await fetchExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -612,7 +595,7 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'not a namespace type', signal: abortCtrl.signal, @@ -626,11 +609,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListItemSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( fetchExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -641,18 +624,17 @@ describe('Exceptions Lists API', () => { describe('#deleteExceptionListById', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); }); test('check parameter url, body when deleting exception item', async () => { await deleteExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists', { method: 'DELETE', query: { id: '1', @@ -664,7 +646,7 @@ describe('Exceptions Lists API', () => { test('it returns expected format when call succeeds', async () => { const exceptionResponse = await deleteExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -674,7 +656,7 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ - http: mockKibanaHttpService(), + http: httpMock, id: 1, namespaceType: 'single', signal: abortCtrl.signal, @@ -688,11 +670,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( deleteExceptionListById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -703,18 +685,17 @@ describe('Exceptions Lists API', () => { describe('#deleteExceptionListItemById', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListItemSchemaMock()); }); test('check parameter url, body when deleting exception item', async () => { await deleteExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/items', { method: 'DELETE', query: { id: '1', @@ -726,7 +707,7 @@ describe('Exceptions Lists API', () => { test('it returns expected format when call succeeds', async () => { const exceptionResponse = await deleteExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -736,7 +717,7 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ - http: mockKibanaHttpService(), + http: httpMock, id: 1, namespaceType: 'single', signal: abortCtrl.signal, @@ -750,11 +731,11 @@ describe('Exceptions Lists API', () => { const badPayload = getExceptionListItemSchemaMock(); // @ts-expect-error delete badPayload.id; - fetchMock.mockResolvedValue(badPayload); + httpMock.fetch.mockResolvedValue(badPayload); await expect( deleteExceptionListItemById({ - http: mockKibanaHttpService(), + http: httpMock, id: '1', namespaceType: 'single', signal: abortCtrl.signal, @@ -765,16 +746,15 @@ describe('Exceptions Lists API', () => { describe('#addEndpointExceptionList', () => { beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); }); test('it invokes "addEndpointExceptionList" with expected url and body values', async () => { await addEndpointExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, signal: abortCtrl.signal, }); - expect(fetchMock).toHaveBeenCalledWith('/api/endpoint_list', { + expect(httpMock.fetch).toHaveBeenCalledWith('/api/endpoint_list', { method: 'POST', signal: abortCtrl.signal, }); @@ -782,16 +762,16 @@ describe('Exceptions Lists API', () => { test('it returns expected exception list on success', async () => { const exceptionResponse = await addEndpointExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, signal: abortCtrl.signal, }); expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); }); test('it returns an empty object when list already exists', async () => { - fetchMock.mockResolvedValue({}); + httpMock.fetch.mockResolvedValue({}); const exceptionResponse = await addEndpointExceptionList({ - http: mockKibanaHttpService(), + http: httpMock, signal: abortCtrl.signal, }); expect(exceptionResponse).toEqual({}); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts index ebee2cbace9cc..9460432cbc9c9 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts @@ -6,16 +6,16 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getCreateExceptionListItemSchemaMock } from '../../../common/schemas/request/create_exception_list_item_schema.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../common/schemas/request/update_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; -import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { PersistHookProps } from '../types'; import { ReturnPersistExceptionItem, usePersistExceptionItem } from './persist_exception_item'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; describe('usePersistExceptionItem', () => { const onError = jest.fn(); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts index 0541f893e2797..d5dfe1174d009 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts @@ -6,16 +6,16 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getCreateExceptionListSchemaMock } from '../../../common/schemas/request/create_exception_list_schema.mock'; import { getUpdateExceptionListSchemaMock } from '../../../common/schemas/request/update_exception_list_schema.mock'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; -import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { PersistHookProps } from '../types'; import { ReturnPersistExceptionList, usePersistExceptionList } from './persist_exception_list'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; describe('usePersistExceptionList', () => { const onError = jest.fn(); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts index c93155274937e..6469dc49c460f 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts @@ -6,8 +6,8 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; -import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; @@ -16,7 +16,7 @@ import { ApiCallByIdProps, ApiCallByListIdProps } from '../types'; import { ExceptionsApi, useApi } from './use_api'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; describe('useApi', () => { const onErrorMock = jest.fn(); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts index 3a8b1713b901b..5c544c7e96e33 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts @@ -6,15 +6,15 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; -import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { UseExceptionListProps, UseExceptionListSuccess } from '../types'; import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; describe('useExceptionList', () => { const onErrorMock = jest.fn(); diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts index d0e238f8c5c40..4354c735747be 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts @@ -9,7 +9,10 @@ import { savedObjectsClientMock } from 'src/core/server/mocks'; import { getFoundExceptionListSchemaMock } from '../../../common/schemas/response/found_exception_list_schema.mock'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; -import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; +import { + getExceptionListSchemaMock, + getTrustedAppsListSchemaMock, +} from '../../../common/schemas/response/exception_list_schema.mock'; import { ExceptionListClient } from './exception_list_client'; @@ -24,6 +27,7 @@ export class ExceptionListClientMock extends ExceptionListClient { public deleteExceptionListItem = jest.fn().mockResolvedValue(getExceptionListItemSchemaMock()); public findExceptionListItem = jest.fn().mockResolvedValue(getFoundExceptionListItemSchemaMock()); public findExceptionList = jest.fn().mockResolvedValue(getFoundExceptionListSchemaMock()); + public createTrustedAppsList = jest.fn().mockResolvedValue(getTrustedAppsListSchemaMock()); } export const getExceptionListClientMock = (): ExceptionListClient => { diff --git a/x-pack/plugins/logstash/public/plugin.ts b/x-pack/plugins/logstash/public/plugin.ts index 59f92ee0a7ffc..3cd73d90bdd16 100644 --- a/x-pack/plugins/logstash/public/plugin.ts +++ b/x-pack/plugins/logstash/public/plugin.ts @@ -70,7 +70,7 @@ export class LogstashPlugin implements Plugin { }), icon: 'pipelineApp', path: '/app/management/ingest/pipelines', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); }); diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index d554d159a196f..b30c155d43cbe 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -7,7 +7,6 @@ "licensing", "features", "inspector", - "home", "data", "fileUpload", "uiActions", @@ -18,12 +17,14 @@ "usageCollection", "share" ], + "optionalPlugins": ["home"], "ui": true, "server": true, "extraPublicDirs": ["common/constants"], "requiredBundles": [ "kibanaReact", "kibanaUtils", - "savedObjects" + "savedObjects", + "home" ] } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts index ad4479d3a324b..fa614ae87b290 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts @@ -32,7 +32,6 @@ export interface IVectorLayer extends ILayer { getJoins(): IJoin[]; getValidJoins(): IJoin[]; getSource(): IVectorSource; - getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; hasJoins(): boolean; @@ -79,7 +78,6 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { _setMbPointsProperties(mbMap: unknown, mvtSourceLayer?: string): void; _setMbLinePolygonProperties(mbMap: unknown, mvtSourceLayer?: string): void; getSource(): IVectorSource; - getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; hasJoins(): boolean; diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index e64d20138cfb8..52dc89a6bba58 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -36,7 +36,8 @@ export const sourceTitle = i18n.translate( } ); -export class MVTSingleLayerVectorSource extends AbstractSource +export class MVTSingleLayerVectorSource + extends AbstractSource implements ITiledSingleLayerVectorSource { static createDescriptor({ urlTemplate, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts index 3f6edc81e30ef..a2dfdc94d8058 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts @@ -5,7 +5,7 @@ */ // eslint-disable-next-line max-classes-per-file -import { FIELD_ORIGIN } from '../../../../../../common/constants'; +import { FIELD_ORIGIN, LAYER_STYLE_TYPE } from '../../../../../../common/constants'; import { StyleMeta } from '../../style_meta'; import { CategoryFieldMeta, @@ -44,7 +44,7 @@ export class MockStyle implements IStyle { } getType() { - return 'mockStyle'; + return LAYER_STYLE_TYPE.VECTOR; } getStyleMeta(): StyleMeta { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 47659e055936e..b16755e69f92d 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -27,6 +27,7 @@ import { import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../layers/vector_layer/vector_layer'; import { IJoin } from '../../../joins/join'; +import { IVectorStyle } from '../vector_style'; export interface IDynamicStyleProperty extends IStyleProperty { getFieldMetaOptions(): FieldMetaOptions; @@ -49,7 +50,8 @@ export interface IDynamicStyleProperty extends IStyleProperty { export type FieldFormatter = (value: string | number | undefined) => string | number; -export class DynamicStyleProperty extends AbstractStyleProperty +export class DynamicStyleProperty + extends AbstractStyleProperty implements IDynamicStyleProperty { static type = STYLE_TYPE.DYNAMIC; @@ -88,7 +90,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty } getRangeFieldMeta() { - const style = this._layer.getStyle(); + const style = this._layer.getStyle() as IVectorStyle; const styleMeta = style.getStyleMeta(); const fieldName = this.getFieldName(); const rangeFieldMetaFromLocalFeatures = styleMeta.getRangeFieldMetaDescriptor(fieldName); @@ -113,7 +115,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty } getCategoryFieldMeta() { - const style = this._layer.getStyle(); + const style = this._layer.getStyle() as IVectorStyle; const styleMeta = style.getStyleMeta(); const fieldName = this.getFieldName(); const categoryFieldMetaFromLocalFeatures = styleMeta.getCategoryFieldMetaDescriptor(fieldName); diff --git a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx index 84316a1b9105d..9bab590d1f5ea 100644 --- a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx +++ b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx @@ -13,8 +13,10 @@ import { EuiDroppable, EuiText, EuiTextAlign, + EuiTextColor, EuiSpacer, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { AddTooltipFieldPopover, FieldProps } from './add_tooltip_field_popover'; import { IField } from '../../classes/fields/field'; @@ -156,7 +158,18 @@ export class TooltipSelector extends Component { _renderProperties() { if (!this.state.selectedFieldProps.length) { - return null; + return ( + +

+ + + +

+
+ ); } return ( diff --git a/x-pack/plugins/maps/public/feature_catalogue_entry.ts b/x-pack/plugins/maps/public/feature_catalogue_entry.ts index 6c2579bd3e4e2..ca71101864fda 100644 --- a/x-pack/plugins/maps/public/feature_catalogue_entry.ts +++ b/x-pack/plugins/maps/public/feature_catalogue_entry.ts @@ -12,10 +12,10 @@ export const featureCatalogueEntry = { id: APP_ID, title: getAppTitle(), description: i18n.translate('xpack.maps.feature.appDescription', { - defaultMessage: 'Explore geospatial data from Elasticsearch and the Elastic Maps Service', + defaultMessage: 'Explore geospatial data from Elasticsearch and the Elastic Maps Service.', }), icon: APP_ICON, path: '/app/maps', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 9bb79f2937c68..e03a085e9bc88 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -51,7 +51,7 @@ import { StartContract as FileUploadStartContract } from '../../file_upload/publ export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; visualizations: VisualizationsSetup; embeddable: EmbeddableSetup; mapsLegacy: { config: MapsLegacyConfigType }; @@ -108,7 +108,9 @@ export class MapsPlugin ); plugins.inspector.registerView(MapView); - plugins.home.featureCatalogue.register(featureCatalogueEntry); + if (plugins.home) { + plugins.home.featureCatalogue.register(featureCatalogueEntry); + } plugins.visualizations.registerAlias( getMapsVisTypeAlias(plugins.visualizations, config.showMapVisualizationTypes) ); diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx new file mode 100644 index 0000000000000..e8e0e583a7c6d --- /dev/null +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getBreadcrumbs } from './get_breadcrumbs'; + +jest.mock('../../../kibana_services', () => {}); +jest.mock('../../maps_router', () => {}); + +const getHasUnsavedChanges = () => { + return false; +}; + +test('should get breadcrumbs "Maps / mymap"', () => { + const breadcrumbs = getBreadcrumbs({ title: 'mymap', getHasUnsavedChanges }); + expect(breadcrumbs.length).toBe(2); + expect(breadcrumbs[0].text).toBe('Maps'); + expect(breadcrumbs[1].text).toBe('mymap'); +}); + +test('should get breadcrumbs "Dashboard / Maps / mymap" with originatingApp', () => { + const breadcrumbs = getBreadcrumbs({ + title: 'mymap', + getHasUnsavedChanges, + originatingApp: 'dashboardId', + getAppNameFromId: (appId) => { + return 'Dashboard'; + }, + }); + expect(breadcrumbs.length).toBe(3); + expect(breadcrumbs[0].text).toBe('Dashboard'); + expect(breadcrumbs[1].text).toBe('Maps'); + expect(breadcrumbs[2].text).toBe('mymap'); +}); diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx new file mode 100644 index 0000000000000..1ccf890597edc --- /dev/null +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { getNavigateToApp } from '../../../kibana_services'; +// @ts-expect-error +import { goToSpecifiedPath } from '../../maps_router'; + +export const unsavedChangesWarning = i18n.translate( + 'xpack.maps.breadCrumbs.unsavedChangesWarning', + { + defaultMessage: 'Your map has unsaved changes. Are you sure you want to leave?', + } +); + +export function getBreadcrumbs({ + title, + getHasUnsavedChanges, + originatingApp, + getAppNameFromId, +}: { + title: string; + getHasUnsavedChanges: () => boolean; + originatingApp?: string; + getAppNameFromId?: (id: string) => string; +}) { + const breadcrumbs = []; + if (originatingApp && getAppNameFromId) { + breadcrumbs.push({ + onClick: () => { + getNavigateToApp()(originatingApp); + }, + text: getAppNameFromId(originatingApp), + }); + } + + breadcrumbs.push({ + text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { + defaultMessage: 'Maps', + }), + onClick: () => { + if (getHasUnsavedChanges()) { + const navigateAway = window.confirm(unsavedChangesWarning); + if (navigateAway) { + goToSpecifiedPath('/'); + } + } else { + goToSpecifiedPath('/'); + } + }, + }); + + breadcrumbs.push({ text: title }); + + return breadcrumbs; +} diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index 58f0bf16e93f2..485b0ed7682fa 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -5,7 +5,6 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import 'mapbox-gl/dist/mapbox-gl.css'; import _ from 'lodash'; import { DEFAULT_IS_LAYER_TOC_OPEN } from '../../../reducers/ui'; @@ -29,13 +28,9 @@ import { AppStateManager } from '../../state_syncing/app_state_manager'; import { startAppStateSyncing } from '../../state_syncing/app_sync'; import { esFilters } from '../../../../../../../src/plugins/data/public'; import { MapContainer } from '../../../connected_components/map_container'; -import { goToSpecifiedPath } from '../../maps_router'; import { getIndexPatternsFromIds } from '../../../index_pattern_util'; import { getTopNavConfig } from './top_nav_config'; - -const unsavedChangesWarning = i18n.translate('xpack.maps.breadCrumbs.unsavedChangesWarning', { - defaultMessage: 'Your map has unsaved changes. Are you sure you want to leave?', -}); +import { getBreadcrumbs, unsavedChangesWarning } from './get_breadcrumbs'; export class MapsAppView extends React.Component { _globalSyncUnsubscribe = null; @@ -104,7 +99,7 @@ export class MapsAppView extends React.Component { getCoreChrome().setBreadcrumbs([]); } - _hasUnsavedChanges() { + _hasUnsavedChanges = () => { const savedLayerList = this.props.savedMap.getLayerList(); return !savedLayerList ? !_.isEqual(this.props.layerListConfigOnly, this.state.initialLayerListConfig) @@ -114,27 +109,16 @@ export class MapsAppView extends React.Component { // Need to perform the same process for layerListConfigOnly to compare apples to apples // and avoid undefined properties in layerListConfigOnly triggering unsaved changes. !_.isEqual(JSON.parse(JSON.stringify(this.props.layerListConfigOnly)), savedLayerList); - } + }; _setBreadcrumbs = () => { - getCoreChrome().setBreadcrumbs([ - { - text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { - defaultMessage: 'Maps', - }), - onClick: () => { - if (this._hasUnsavedChanges()) { - const navigateAway = window.confirm(unsavedChangesWarning); - if (navigateAway) { - goToSpecifiedPath('/'); - } - } else { - goToSpecifiedPath('/'); - } - }, - }, - { text: this.props.savedMap.title }, - ]); + const breadcrumbs = getBreadcrumbs({ + title: this.props.savedMap.title, + getHasUnsavedChanges: this._hasUnsavedChanges, + originatingApp: this.state.originatingApp, + getAppNameFromId: this.props.stateTransfer.getAppNameFromId, + }); + getCoreChrome().setBreadcrumbs(breadcrumbs); }; _updateFromGlobalState = ({ changes, state: globalState }) => { diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index 58a2043502d27..8f29365fdca1b 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -91,6 +91,7 @@ export function getPluginPrivileges() { admin: { ...privilege, api: allMlCapabilitiesKeys.map((k) => `ml:${k}`), + catalogue: [PLUGIN_ID, `${PLUGIN_ID}_file_data_visualizer`], ui: allMlCapabilitiesKeys, savedObject: { all: savedObjects, @@ -100,6 +101,7 @@ export function getPluginPrivileges() { user: { ...privilege, api: userMlCapabilitiesKeys.map((k) => `ml:${k}`), + catalogue: [PLUGIN_ID], ui: userMlCapabilitiesKeys, savedObject: { all: [], diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 7b4ea5458f4a6..fc673397ef177 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -10,7 +10,6 @@ "data", "cloud", "features", - "home", "licensing", "usageCollection", "share", @@ -20,6 +19,7 @@ "indexPatternManagement" ], "optionalPlugins": [ + "home", "security", "spaces", "management", @@ -32,6 +32,7 @@ "kibanaUtils", "kibanaReact", "dashboard", - "savedObjects" + "savedObjects", + "home" ] } diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 07e33a43d3ff9..0d94c5ccdfe08 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -72,6 +72,11 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) = // eslint-disable-next-line @typescript-eslint/naming-convention echTooltip__rowHighlighted: isHighlighted, }); + + const renderValue = Array.isArray(value) + ? value.map((v) =>
{v}
) + : value; + return (
= React.memo(({ service }) = {label} - {value} + {renderValue}
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 4d53e747d4855..0a76211f2e330 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -37,7 +37,6 @@ import { } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; import { getTimeBucketsFromCache } from '../../util/time_buckets'; -import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; const CONTENT_WRAPPER_HEIGHT = 215; @@ -486,7 +485,7 @@ export class ExplorerChartSingleMetric extends React.Component { label: i18n.translate('xpack.ml.explorer.singleMetricChart.scheduledEventsLabel', { defaultMessage: 'Scheduled events', }), - value: marker.scheduledEvents.map(mlEscape).join('
'), + value: marker.scheduledEvents, seriesIdentifier: { key: seriesKey, }, diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index ff59d46de758d..4f8ceb8effe98 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -50,7 +50,7 @@ export interface MlSetupDependencies { management?: ManagementSetup; usageCollection: UsageCollectionSetup; licenseManagement?: LicenseManagementUIPluginSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; embeddable: EmbeddableSetup; uiActions: UiActionsSetup; kibanaVersion: string; @@ -111,7 +111,9 @@ export class MlPlugin implements Plugin { const [coreStart] = await core.getStartServices(); if (isMlEnabled(license)) { // add ML to home page - registerFeature(pluginsSetup.home); + if (pluginsSetup.home) { + registerFeature(pluginsSetup.home); + } // register ML for the index pattern management no data screen. pluginsSetup.indexPatternManagement.environment.update({ diff --git a/x-pack/plugins/ml/public/register_feature.ts b/x-pack/plugins/ml/public/register_feature.ts index ca60de612c3d5..942e9b7f33a40 100644 --- a/x-pack/plugins/ml/public/register_feature.ts +++ b/x-pack/plugins/ml/public/register_feature.ts @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; import { - HomePublicPluginSetup, FeatureCatalogueCategory, + HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; import { PLUGIN_ID } from '../common/constants/app'; @@ -28,7 +28,22 @@ export const registerFeature = (home: HomePublicPluginSetup) => { }), icon: 'machineLearningApp', path: '/app/ml', + showOnHomePage: false, + category: FeatureCatalogueCategory.DATA, + }); + + home.featureCatalogue.register({ + id: `${PLUGIN_ID}_file_data_visualizer`, + title: i18n.translate('xpack.ml.fileDataVisualizerTitle', { + defaultMessage: 'Upload a file', + }), + description: i18n.translate('xpack.ml.fileDataVisualizerDescription', { + defaultMessage: 'Import your own CSV, NDJSON, or log file.', + }), + icon: 'document', + path: '/app/ml#/filedatavisualizer', showOnHomePage: true, category: FeatureCatalogueCategory.DATA, + order: 520, }); }; diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index f6d47639ef7c5..76128341e6ddc 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -87,7 +87,7 @@ export class MlServerPlugin implements Plugin { serverBasePath: '', }, }, - uuid: { - getInstanceUuid: jest.fn(), - }, elasticsearch: { legacy: { client: {}, diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 043435c48a211..501c96b12fde8 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -92,7 +92,7 @@ export class Plugin { const router = core.http.createRouter(); this.legacyShimDependencies = { router, - instanceUuid: core.uuid.getInstanceUuid(), + instanceUuid: this.initializerContext.env.instanceUuid, esDataClient: core.elasticsearch.legacy.client, kibanaStatsCollector: plugins.usageCollection?.getCollectorByType( KIBANA_STATS_TYPE_MONITORING @@ -159,7 +159,7 @@ export class Plugin { config, log: kibanaMonitoringLog, kibanaStats: { - uuid: core.uuid.getInstanceUuid(), + uuid: this.initializerContext.env.instanceUuid, name: serverInfo.name, index: get(legacyConfig, 'kibana.index'), host: serverInfo.hostname, diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 2a04a35830a47..834982009b9d0 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -7,7 +7,8 @@ "observability" ], "optionalPlugins": [ - "licensing" + "licensing", + "home" ], "ui": true, "server": true, diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 335ce897dce7b..aead711f2d73e 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -3,6 +3,8 @@ * 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 { AppMountParameters, CoreSetup, @@ -11,6 +13,7 @@ import { PluginInitializerContext, CoreStart, } from '../../../../src/core/public'; +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { registerDataHandler } from './data_handler'; import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; @@ -18,12 +21,16 @@ export interface ObservabilityPluginSetup { dashboard: { register: typeof registerDataHandler }; } +interface SetupPlugins { + home?: HomePublicPluginSetup; +} + export type ObservabilityPluginStart = void; export class Plugin implements PluginClass { constructor(context: PluginInitializerContext) {} - public setup(core: CoreSetup) { + public setup(core: CoreSetup, plugins: SetupPlugins) { core.application.register({ id: 'observability-overview', title: 'Overview', @@ -41,6 +48,32 @@ export class Plugin implements PluginClass = (state, action) => { indexName: value.indexName, operation: Object.freeze(restOfOperation), // prettier-ignore - shardName: `[${/* shard id */value.shard.id[0]}][${/* shard number */value.shard.id[2] }]` + shardName: `[${/* shard id */value.shard.id[0]}][${/* shard number */value.shard.id[2] }]`, }; } else { nextState.highlightDetails = null; diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index c9afb9d4c1fb7..e3905dc2acf45 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -116,16 +116,16 @@ export class SecurityPlugin home.featureCatalogue.register({ id: 'security', title: i18n.translate('xpack.security.registerFeature.securitySettingsTitle', { - defaultMessage: 'Security Settings', + defaultMessage: 'Manage permissions', }), description: i18n.translate('xpack.security.registerFeature.securitySettingsDescription', { - defaultMessage: - 'Protect your data and easily manage who has access to what with users and roles.', + defaultMessage: 'Control who has access and what tasks they can perform.', }), icon: 'securityApp', - path: '/app/management/security/users', + path: '/app/management/security/roles', showOnHomePage: true, category: FeatureCatalogueCategory.ADMIN, + order: 600, }); } diff --git a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts index 40cbd00858b5f..0a795ad767898 100644 --- a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts +++ b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts @@ -7,7 +7,7 @@ // @ts-ignore import fetchMock from 'fetch-mock/es5/client'; import { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor'; -import { setup } from '../../../../../src/test_utils/public/http_test_setup'; +import { setup } from '../../../../../src/core/test_helpers/http_test_setup'; import { createSessionTimeoutMock } from './session_timeout.mock'; const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts index 78c82cbc3a9a6..71ef6496ef6ca 100644 --- a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts @@ -7,7 +7,7 @@ // @ts-ignore import fetchMock from 'fetch-mock/es5/client'; import { SessionExpired } from './session_expired'; -import { setup } from '../../../../../src/test_utils/public/http_test_setup'; +import { setup } from '../../../../../src/core/test_helpers/http_test_setup'; import { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor'; jest.mock('./session_expired'); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index ca191602dcf44..7f7f969e8b480 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -62,7 +62,7 @@ const expectGeneralError = async (fn: Function, args: Record) => { * Requires that function args are passed in as key/value pairs * The argument properties must be in the correct order to be spread properly */ -const expectForbiddenError = async (fn: Function, args: Record) => { +const expectForbiddenError = async (fn: Function, args: Record, action?: string) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure ); @@ -87,7 +87,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, missing, @@ -96,7 +96,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }; -const expectSuccess = async (fn: Function, args: Record) => { +const expectSuccess = async (fn: Function, args: Record, action?: string) => { const result = await fn.bind(client)(...Object.values(args)); const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< SavedObjectActions['get'] @@ -109,7 +109,7 @@ const expectSuccess = async (fn: Function, args: Record) => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, args @@ -492,6 +492,40 @@ describe('#bulkUpdate', () => { }); }); +describe('#checkConflicts', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const objects = [obj1, obj2]; + await expectGeneralError(client.checkConflicts, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess( + client.checkConflicts, + { objects, options }, + 'checkConflicts' + ); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.checkConflicts, { objects, options }); + }); +}); + describe('#create', () => { const type = 'foo'; const attributes = { some_attr: 's' }; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 9fd8a732c4eab..68fe65d204d6d 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -77,6 +78,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject); } + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + const types = this.getUniqueObjectTypes(objects); + const args = { objects, options }; + await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts'); + + const response = await this.baseClient.checkConflicts(objects, options); + return response; + } + public async bulkCreate( objects: Array>, options: SavedObjectsBaseOptions = {} diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 2a0d1ef8b9dfd..64f2f223a3073 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -7,11 +7,14 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + import { RiskScore } from '../types/risk_score'; import { UUID } from '../types/uuid'; import { IsoDateString } from '../types/iso_date_string'; import { PositiveIntegerGreaterThanZero } from '../types/positive_integer_greater_than_zero'; import { PositiveInteger } from '../types/positive_integer'; +import { parseScheduleDates } from '../../utils'; export const author = t.array(t.string); export type Author = t.TypeOf; @@ -76,8 +79,18 @@ export const action = t.exact( export const actions = t.array(action); export type Actions = t.TypeOf; -// TODO: Create a regular expression type or custom date math part type here -export const from = t.string; +const stringValidator = (input: unknown): input is string => typeof input === 'string'; +export const from = new t.Type( + 'From', + t.string.is, + (input, context): Either => { + if (stringValidator(input) && parseScheduleDates(input) == null) { + return t.failure(input, context, 'Failed to parse "from" on rule param'); + } + return t.string.validate(input, context); + }, + t.identity +); export type From = t.TypeOf; export const fromOrUndefined = t.union([from, t.undefined]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts index a85ea58b26478..5b1c837db9f74 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts @@ -6,7 +6,7 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; - +import { from } from '../common/schemas'; /** * Types the DefaultFromString as: * - If null or undefined, then a default of the string "now-6m" will be used @@ -14,7 +14,11 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultFromString = new t.Type( 'DefaultFromString', t.string.is, - (input, context): Either => - input == null ? t.success('now-6m') : t.string.validate(input, context), + (input, context): Either => { + if (input == null) { + return t.success('now-6m'); + } + return from.validate(input, context); + }, t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 153130fc16d60..a70258c2684b6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; +import dateMath from '@elastic/datemath'; + import { EntriesArray } from '../shared_imports'; import { RuleType } from './types'; @@ -18,3 +21,15 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => { }; export const isThresholdRule = (ruleType: RuleType) => ruleType === 'threshold'; + +export const parseScheduleDates = (time: string): moment.Moment | null => { + const isValidDateString = !isNaN(Date.parse(time)); + const isValidInput = isValidDateString || time.trim().startsWith('now'); + const formattedDate = isValidDateString + ? moment(time) + : isValidInput + ? dateMath.parse(time) + : null; + + return formattedDate ?? null; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 6ea0c36328eed..507ce63c7b815 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -11,3 +11,5 @@ export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; + +export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 46fc002e76e7f..be3a1e82356c8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -169,6 +169,7 @@ describe('data generator', () => { const childrenPerNode = 3; const generations = 3; const relatedAlerts = 4; + beforeEach(() => { tree = generator.generateTree({ alwaysGenMaxChildrenPerNode: true, @@ -182,6 +183,7 @@ describe('data generator', () => { { category: RelatedEventCategory.File, count: 2 }, { category: RelatedEventCategory.Network, count: 1 }, ], + relatedEventsOrdered: true, relatedAlerts, ancestryArraySize: ANCESTRY_LIMIT, }); @@ -212,6 +214,14 @@ describe('data generator', () => { } }; + it('creates related events in ascending order', () => { + // the order should not change since it should already be in ascending order + const relatedEventsAsc = _.cloneDeep(tree.origin.relatedEvents).sort( + (event1, event2) => event1['@timestamp'] - event2['@timestamp'] + ); + expect(tree.origin.relatedEvents).toStrictEqual(relatedEventsAsc); + }); + it('has ancestry array defined', () => { expect(tree.origin.lifecycle[0].process.Ext!.ancestry!.length).toBe(ANCESTRY_LIMIT); for (const event of tree.allEvents) { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 7340b1c021eba..0955f196df176 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -302,6 +302,12 @@ export interface TreeOptions { generations?: number; children?: number; relatedEvents?: RelatedEventInfo[] | number; + /** + * If true then the related events will be created with timestamps that preserve the + * generation order, meaning the first event will always have a timestamp number less + * than the next related event + */ + relatedEventsOrdered?: boolean; relatedAlerts?: number; percentWithRelated?: number; percentTerminated?: number; @@ -322,6 +328,7 @@ export function getTreeOptionsWithDef(options?: TreeOptions): TreeOptionDefaults generations: options?.generations ?? 2, children: options?.children ?? 2, relatedEvents: options?.relatedEvents ?? 5, + relatedEventsOrdered: options?.relatedEventsOrdered ?? false, relatedAlerts: options?.relatedAlerts ?? 3, percentWithRelated: options?.percentWithRelated ?? 30, percentTerminated: options?.percentTerminated ?? 100, @@ -809,7 +816,8 @@ export class EndpointDocGenerator { for (const relatedEvent of this.relatedEventsGenerator( node, opts.relatedEvents, - secBeforeEvent + secBeforeEvent, + opts.relatedEventsOrdered )) { eventList.push(relatedEvent); } @@ -877,6 +885,8 @@ export class EndpointDocGenerator { addRelatedAlerts(ancestor, numAlertsPerNode, processDuration, events); } } + timestamp = timestamp + 1000; + events.push( this.generateAlert( timestamp, @@ -961,7 +971,12 @@ export class EndpointDocGenerator { }); } if (this.randomN(100) < opts.percentWithRelated) { - yield* this.relatedEventsGenerator(child, opts.relatedEvents, processDuration); + yield* this.relatedEventsGenerator( + child, + opts.relatedEvents, + processDuration, + opts.relatedEventsOrdered + ); yield* this.relatedAlertsGenerator(child, opts.relatedAlerts, processDuration); } } @@ -973,13 +988,17 @@ export class EndpointDocGenerator { * @param relatedEvents - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node * or a number which defines the number of related events and will default to random categories * @param processDuration - maximum number of seconds after process event that related event timestamp can be + * @param ordered - if true the events will have an increasing timestamp, otherwise their timestamp will be random but + * guaranteed to be greater than or equal to the originating event */ public *relatedEventsGenerator( node: Event, relatedEvents: RelatedEventInfo[] | number = 10, - processDuration: number = 6 * 3600 + processDuration: number = 6 * 3600, + ordered: boolean = false ) { let relatedEventsInfo: RelatedEventInfo[]; + let ts = node['@timestamp'] + 1; if (typeof relatedEvents === 'number') { relatedEventsInfo = [{ category: RelatedEventCategory.Random, count: relatedEvents }]; } else { @@ -995,7 +1014,12 @@ export class EndpointDocGenerator { eventInfo = OTHER_EVENT_CATEGORIES[event.category]; } - const ts = node['@timestamp'] + this.randomN(processDuration) * 1000; + if (ordered) { + ts += this.randomN(processDuration) * 1000; + } else { + ts = node['@timestamp'] + this.randomN(processDuration) * 1000; + } + yield this.generateEvent({ timestamp: ts, entityID: node.process.entity_id, diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index f3e67f84b2880..311aa0c04c9ab 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -33,6 +33,11 @@ export const validateEvents = { afterEvent: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), + body: schema.nullable( + schema.object({ + filter: schema.maybe(schema.string()), + }) + ), }; /** @@ -45,6 +50,11 @@ export const validateAlerts = { afterAlert: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), + body: schema.nullable( + schema.object({ + filter: schema.maybe(schema.string()), + }) + ), }; /** diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts new file mode 100644 index 0000000000000..7aec8e15c317c --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.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 { GetTrustedAppsRequestSchema } from './trusted_apps'; + +describe('When invoking Trusted Apps Schema', () => { + describe('for GET List', () => { + const getListQueryParams = (page: unknown = 1, perPage: unknown = 20) => ({ + page, + per_page: perPage, + }); + const query = GetTrustedAppsRequestSchema.query; + + describe('query param validation', () => { + it('should return query params if valid', () => { + expect(query.validate(getListQueryParams())).toEqual({ + page: 1, + per_page: 20, + }); + }); + + it('should use default values', () => { + expect(query.validate(getListQueryParams(undefined, undefined))).toEqual({ + page: 1, + per_page: 20, + }); + expect(query.validate(getListQueryParams(undefined, 100))).toEqual({ + page: 1, + per_page: 100, + }); + expect(query.validate(getListQueryParams(10, undefined))).toEqual({ + page: 10, + per_page: 20, + }); + }); + + it('should throw if `page` param is not a number', () => { + expect(() => { + query.validate(getListQueryParams('one')); + }).toThrowError(); + }); + + it('should throw if `page` param is less than 1', () => { + expect(() => { + query.validate(getListQueryParams(0)); + }).toThrowError(); + expect(() => { + query.validate(getListQueryParams(-1)); + }).toThrowError(); + }); + + it('should throw if `per_page` param is not a number', () => { + expect(() => { + query.validate(getListQueryParams(1, 'twenty')); + }).toThrowError(); + }); + + it('should throw if `per_page` param is less than 1', () => { + expect(() => { + query.validate(getListQueryParams(1, 0)); + }).toThrowError(); + expect(() => { + query.validate(getListQueryParams(1, -1)); + }).toThrowError(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts new file mode 100644 index 0000000000000..20fab93aaf304 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.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 { schema } from '@kbn/config-schema'; + +export const GetTrustedAppsRequestSchema = { + query: schema.object({ + page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), + per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts similarity index 99% rename from x-pack/plugins/security_solution/common/endpoint/types.ts rename to x-pack/plugins/security_solution/common/endpoint/types/index.ts index 2b8de7ed16b08..8e507cbc921a2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -5,8 +5,10 @@ */ import { ApplicationStart } from 'kibana/public'; -import { NewPackagePolicy, PackagePolicy } from '../../../ingest_manager/common'; -import { ManifestSchema } from './schema/manifest'; +import { NewPackagePolicy, PackagePolicy } from '../../../../ingest_manager/common'; +import { ManifestSchema } from '../schema/manifest'; + +export * from './trusted_apps'; /** * Supported React-Router state for the Policy Details page diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts new file mode 100644 index 0000000000000..2905274bef1cb --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { GetTrustedAppsRequestSchema } from '../schema/trusted_apps'; + +/** API request params for retrieving a list of Trusted Apps */ +export type GetTrustedAppsListRequest = TypeOf; +export interface GetTrustedListAppsResponse { + per_page: number; + page: number; + total: number; + data: TrustedApp[]; +} + +interface MacosLinuxConditionEntry { + field: 'hash' | 'path'; + type: 'match'; + operator: 'included'; + value: string; +} + +type WindowsConditionEntry = + | MacosLinuxConditionEntry + | (Omit & { + field: 'signer'; + }); + +/** Type for a new Trusted App Entry */ +export type NewTrustedApp = { + name: string; + description?: string; +} & ( + | { + os: 'linux' | 'macos'; + entries: MacosLinuxConditionEntry[]; + } + | { + os: 'windows'; + entries: WindowsConditionEntry[]; + } +); + +/** A trusted app entry */ +export type TrustedApp = NewTrustedApp & { + id: string; + created_at: string; + created_by: string; +}; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index edb5dda2ca6da..a188eb7619e6b 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -40,7 +40,7 @@ export enum Direction { } export interface SortField { - field: string; + field: 'lastSeen' | 'hostName'; direction: Direction; } diff --git a/x-pack/plugins/security_solution/common/utility_types.ts b/x-pack/plugins/security_solution/common/utility_types.ts index 43271dc40ba12..4a7bd02d0442b 100644 --- a/x-pack/plugins/security_solution/common/utility_types.ts +++ b/x-pack/plugins/security_solution/common/utility_types.ts @@ -26,3 +26,21 @@ export const stringEnum = (enumObj: T, enumName = 'enum') => : runtimeTypes.failure(u, c), (a) => (a as unknown) as string ); + +/** + * Unreachable Assertion helper for scenarios like exhaustive switches. + * For references see: https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript + * This "x" should _always_ be a type of "never" and not change to "unknown" or any other type. See above link or the generic + * concept of exhaustive checks in switch blocks. + * + * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints + * but there are situations and times where this function might still be needed. + * @param x Unreachable field + * @param message Message of error thrown + */ +export const assertUnreachable = ( + x: never, // This should always be a type of "never" + message = 'Unknown Field in switch statement' +): never => { + throw new Error(`${message}: ${x}`); +}; diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts index 383ebe2220585..c2ff2c58687f3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts @@ -13,7 +13,8 @@ import { TABLE_COLUMN_EVENTS_MESSAGE } from '../screens/hosts/external_events'; import { waitsForEventsToBeLoaded, openEventsViewerFieldsBrowser } from '../tasks/hosts/events'; import { removeColumn, resetFields } from '../tasks/timeline'; -describe('persistent timeline', () => { +// FLAKY: https://github.com/elastic/kibana/issues/75794 +describe.skip('persistent timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); openEvents(); diff --git a/x-pack/plugins/security_solution/cypress/integration/url_compatibility.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_compatibility.spec.ts index 7146cf70dc8c8..d55a8faae021d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_compatibility.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_compatibility.spec.ts @@ -18,6 +18,7 @@ const ABSOLUTE_DATE = { startTime: '2019-08-01T20:03:29.186Z', }; +// FLAKY: https://github.com/elastic/kibana/issues/75697 describe.skip('URL compatibility', () => { it('Redirects to Detection alerts from old Detections URL', () => { loginAndWaitForPage(DETECTIONS); diff --git a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts index 68352c6e584cc..6127d22110e78 100644 --- a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts +++ b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts @@ -5,19 +5,22 @@ */ export const DETECTIONS_PAGE = - '[data-test-subj="collapsibleNavGroup-security"] [title="Detections"]'; + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Detections"]'; -export const CASES_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Cases"]'; +export const CASES_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Cases"]'; -export const HOSTS_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Hosts"]'; +export const HOSTS_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Hosts"]'; export const KIBANA_NAVIGATION_TOGGLE = '[data-test-subj="toggleNavButton"]'; export const ADMINISTRATION_PAGE = - '[data-test-subj="collapsibleNavGroup-security"] [title="Administration"]'; + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Administration"]'; -export const NETWORK_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Network"]'; +export const NETWORK_PAGE = + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Network"]'; -export const OVERVIEW_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Overview"]'; +export const OVERVIEW_PAGE = + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Overview"]'; -export const TIMELINES_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Timelines"]'; +export const TIMELINES_PAGE = + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index f46d2c65c565f..40bea852cd59c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -126,7 +126,7 @@ const loginViaConfig = () => { /** * Authenticates with Kibana, visits the specified `url`, and waits for the - * Kibana logo to be displayed before continuing + * Kibana global nav to be displayed before continuing */ export const loginAndWaitForPage = (url: string) => { login(); @@ -134,12 +134,12 @@ export const loginAndWaitForPage = (url: string) => { cy.visit( `${url}?timerange=(global:(linkTo:!(timeline),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)))` ); - cy.contains('a', 'Security'); + cy.get('#headerGlobalNav'); }; export const loginAndWaitForPageWithoutDateRange = (url: string) => { login(); cy.viewport('macbook-15'); cy.visit(url); - cy.contains('a', 'Security', { timeout: 120000 }); + cy.get('#headerGlobalNav', { timeout: 120000 }); }; diff --git a/x-pack/plugins/security_solution/cypress/tsconfig.json b/x-pack/plugins/security_solution/cypress/tsconfig.json index 929a3fb39babb..d6e5cc4aed9f7 100644 --- a/x-pack/plugins/security_solution/cypress/tsconfig.json +++ b/x-pack/plugins/security_solution/cypress/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "exclude": [], "include": [ "./**/*" diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 92fc93453b9f1..7b5c3b5337c02 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -11,7 +11,6 @@ "dataEnhanced", "embeddable", "features", - "home", "taskManager", "inspector", "licensing", @@ -27,7 +26,8 @@ "security", "spaces", "usageCollection", - "lists" + "lists", + "home" ], "server": true, "ui": true, diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 703ef6584f164..687099541b3d2 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -13,9 +13,7 @@ "test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts" }, "devDependencies": { - "@types/md5": "^2.2.0" - }, - "dependencies": { + "@types/md5": "^2.2.0", "@types/rbush": "^3.0.0", "@types/seedrandom": ">=2.0.0 <4.0.0", "querystring": "^0.2.0", diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index f5ed151ebac3c..e6e0823214195 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -15,7 +15,6 @@ import { useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; import { useKibana } from '../../../common/lib/kibana'; -import { createUseKibanaMock } from '../../../common/mock/kibana_react'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; @@ -28,7 +27,7 @@ jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); -const useKibanaMock = useKibana as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; @@ -97,23 +96,16 @@ describe('AllCases', () => { }); /* eslint-enable no-console */ beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); navigateToApp = jest.fn(); - const kibanaMock = createUseKibanaMock()(); - useKibanaMock.mockReturnValue({ - ...kibanaMock, - services: { - application: { - navigateToApp, - }, - }, - }); + useKibanaMock().services.application.navigateToApp = navigateToApp; useUpdateCasesMock.mockReturnValue(defaultUpdateCases); useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); moment.tz.setDefault('UTC'); }); + it('should render AllCases', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index 23c76953a6a0f..08303ddc9397e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -8,10 +8,7 @@ import { Connector } from '../../../containers/configure/types'; import { ReturnConnectors } from '../../../containers/configure/use_connectors'; import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; -import { createUseKibanaMock } from '../../../../common/mock/kibana_react'; export { mapping } from '../../../containers/configure/mock'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { actionTypeRegistryMock } from '../../../../../../triggers_actions_ui/public/application/action_type_registry.mock'; export const connectors: Connector[] = connectorsMock; @@ -46,10 +43,3 @@ export const useConnectorsResponse: ReturnConnectors = { connectors, refetchConnectors: jest.fn(), }; - -export const kibanaMockImplementationArgs = { - services: { - ...createUseKibanaMock()().services, - triggers_actions_ui: { actionTypeRegistry: actionTypeRegistryMock.create() }, - }, -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 7974116f4dc43..3c17a9191d20c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -15,38 +15,39 @@ import { ActionsConnectorsContextProvider, ConnectorAddFlyout, ConnectorEditFlyout, + TriggersAndActionsUIPublicPluginStart, } from '../../../../../triggers_actions_ui/public'; +import { actionTypeRegistryMock } from '../../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { - connectors, - searchURL, - useCaseConfigureResponse, - useConnectorsResponse, - kibanaMockImplementationArgs, -} from './__mock__'; +import { connectors, searchURL, useCaseConfigureResponse, useConnectorsResponse } from './__mock__'; jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); jest.mock('../../../common/components/navigation/use_get_url_search'); -const useKibanaMock = useKibana as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; + describe('ConfigureCases', () => { + beforeEach(() => { + useKibanaMock().services.triggers_actions_ui = ({ + actionTypeRegistry: actionTypeRegistryMock.create(), + } as unknown) as TriggersAndActionsUIPublicPluginStart; + }); + describe('rendering', () => { let wrapper: ReactWrapper; beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); @@ -84,8 +85,8 @@ describe('ConfigureCases', () => { describe('Unhappy path', () => { let wrapper: ReactWrapper; + beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, closureType: 'close-by-user', @@ -98,7 +99,6 @@ describe('ConfigureCases', () => { }, })); useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -122,7 +122,6 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, @@ -136,7 +135,6 @@ describe('ConfigureCases', () => { }, })); useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); @@ -211,9 +209,6 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, mapping: connectors[1].config.incidentConfiguration.mapping, @@ -230,7 +225,6 @@ describe('ConfigureCases', () => { ...useConnectorsResponse, loading: true, })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -262,7 +256,6 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, connectorId: 'servicenow-1', @@ -270,7 +263,6 @@ describe('ConfigureCases', () => { })); useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -305,7 +297,6 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, loading: true, @@ -313,7 +304,6 @@ describe('ConfigureCases', () => { useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -329,10 +319,10 @@ describe('ConfigureCases', () => { describe('connectors', () => { let wrapper: ReactWrapper; - const persistCaseConfigure = jest.fn(); + let persistCaseConfigure: jest.Mock; beforeEach(() => { - jest.resetAllMocks(); + persistCaseConfigure = jest.fn(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, @@ -347,7 +337,6 @@ describe('ConfigureCases', () => { persistCaseConfigure, })); useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); @@ -396,10 +385,10 @@ describe('ConfigureCases', () => { describe('closure options', () => { let wrapper: ReactWrapper; - const persistCaseConfigure = jest.fn(); + let persistCaseConfigure: jest.Mock; beforeEach(() => { - jest.resetAllMocks(); + persistCaseConfigure = jest.fn(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, @@ -414,7 +403,6 @@ describe('closure options', () => { persistCaseConfigure, })); useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); @@ -435,7 +423,6 @@ describe('closure options', () => { describe('user interactions', () => { beforeEach(() => { - jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, mapping: connectors[1].config.incidentConfiguration.mapping, @@ -449,7 +436,6 @@ describe('user interactions', () => { }, })); useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); useGetUrlSearchMock.mockImplementation(() => searchURL); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx index b5bf68cbf6dc8..3b203e81cd074 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -14,26 +14,17 @@ import '../../../common/mock/match_media'; import { TimelineId } from '../../../../common/types/timeline'; import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.'; import { TestProviders } from '../../../common/mock'; -import { createUseKibanaMock } from '../../../common/mock/kibana_react'; jest.mock('../../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; describe('useAllCasesModal', () => { - const navigateToApp = jest.fn(() => Promise.resolve()); + let navigateToApp: jest.Mock; beforeEach(() => { - jest.clearAllMocks(); - const kibanaMock = createUseKibanaMock()(); - useKibanaMock.mockImplementation(() => ({ - ...kibanaMock, - services: { - application: { - navigateToApp, - }, - }, - })); + navigateToApp = jest.fn(); + useKibanaMock().services.application.navigateToApp = navigateToApp; }); it('init', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index 8e76a88572e42..b53da42da55f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -7,12 +7,12 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { useWithSource } from '../../containers/source'; import { mockBrowserFields } from '../../containers/source/mock'; import '../../mock/match_media'; import { useKibana } from '../../lib/kibana'; import { TestProviders } from '../../mock'; -import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { useAddToTimeline } from '../../hooks/use_add_to_timeline'; @@ -60,7 +60,7 @@ jest.mock('../../../timelines/components/manage_timeline', () => { }; }); -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; const timelineId = TimelineId.active; const field = 'process.name'; const value = 'nice'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx index 2b713636862bb..cef92ce2a7817 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx @@ -11,14 +11,13 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { act } from 'react-dom/test-utils'; import { AddExceptionModal } from './'; -import { useKibana, useCurrentUser } from '../../../../common/lib/kibana'; +import { useCurrentUser } from '../../../../common/lib/kibana'; import { getExceptionListSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_schema.mock'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; import { stubIndexPattern } from 'src/plugins/data/common/index_patterns/index_pattern.stub'; import { useAddOrUpdateException } from '../use_add_exception'; import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { createUseKibanaMock } from '../../../mock/kibana_react'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; import * as builder from '../builder'; import * as helpers from '../helpers'; @@ -33,8 +32,6 @@ jest.mock('../use_add_exception'); jest.mock('../use_fetch_or_create_rule_exception_list'); jest.mock('../builder'); -const useKibanaMock = useKibana as jest.Mock; - describe('When the add exception modal is opened', () => { const ruleName = 'test rule'; let defaultEndpointItems: jest.SpyInstance { .spyOn(builder, 'ExceptionBuilderComponent') .mockReturnValue(<>); - const kibanaMock = createUseKibanaMock()(); - useKibanaMock.mockImplementation(() => ({ - ...kibanaMock, - })); (useAddOrUpdateException as jest.Mock).mockImplementation(() => [ { isLoading: false }, jest.fn(), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 03051ead357c9..21f82c6ab4c98 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -18,7 +18,6 @@ import { EuiCheckbox, EuiSpacer, EuiFormRow, - EuiCallOut, EuiText, } from '@elastic/eui'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -28,6 +27,7 @@ import { ExceptionListType, } from '../../../../../public/lists_plugin_deps'; import * as i18n from './translations'; +import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; @@ -35,6 +35,7 @@ import { ExceptionBuilderComponent } from '../builder'; import { Loader } from '../../loader'; import { useAddOrUpdateException } from '../use_add_exception'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; import { AddExceptionComments } from '../add_exception_comments'; import { @@ -46,6 +47,7 @@ import { entryHasNonEcsType, getMappedNonEcsValue, } from '../helpers'; +import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; export interface AddExceptionModalBaseProps { @@ -107,13 +109,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }: AddExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); const [shouldCloseAlert, setShouldCloseAlert] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< Array >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); + const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ @@ -164,17 +167,41 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [onRuleChange] ); - const onFetchOrCreateExceptionListError = useCallback( - (error: Error) => { - setFetchOrCreateListError(true); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + handleRuleChange(true); + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + onCancel(); + }, + [handleRuleChange, addSuccess, onCancel] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, + [addError, onCancel] + ); + + const handleFetchOrCreateExceptionListError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { + setFetchOrCreateListError({ + reason: error.message, + code: statusCode, + details: message, + listListId: null, + }); }, [setFetchOrCreateListError] ); + const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ http, ruleId, exceptionListType, - onError: onFetchOrCreateExceptionListError, + onError: handleFetchOrCreateExceptionListError, onSuccess: handleRuleChange, }); @@ -279,7 +306,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), + () => + fetchOrCreateListError != null || + exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -295,19 +324,27 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {fetchOrCreateListError === true && ( - -

{i18n.ADD_EXCEPTION_FETCH_ERROR}

-
+ {fetchOrCreateListError != null && ( + + + )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && (isLoadingExceptionList || isIndexPatternLoading || isSignalIndexLoading || isSignalIndexPatternLoading) && ( )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && !isSignalIndexLoading && !isSignalIndexPatternLoading && !isLoadingExceptionList && @@ -377,20 +414,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} + {fetchOrCreateListError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.ADD_EXCEPTION} - - + + {i18n.ADD_EXCEPTION} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts index 3916284416707..2e9bced21fe71 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts @@ -13,7 +13,7 @@ export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addExcep export const ADD_EXCEPTION = i18n.translate( 'xpack.securitySolution.exceptions.addException.addException', { - defaultMessage: 'Add Exception', + defaultMessage: 'Add Rule Exception', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx index 8ad80eba569c7..c724e6a2c711f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx @@ -11,7 +11,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { act } from 'react-dom/test-utils'; import { EditExceptionModal } from './'; -import { useKibana, useCurrentUser } from '../../../../common/lib/kibana'; +import { useCurrentUser } from '../../../../common/lib/kibana'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; import { stubIndexPattern, @@ -19,7 +19,6 @@ import { } from 'src/plugins/data/common/index_patterns/index_pattern.stub'; import { useAddOrUpdateException } from '../use_add_exception'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { createUseKibanaMock } from '../../../mock/kibana_react'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { EntriesArray } from '../../../../../../lists/common/schemas/types'; import * as builder from '../builder'; @@ -31,8 +30,6 @@ jest.mock('../use_fetch_or_create_rule_exception_list'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../builder'); -const useKibanaMock = useKibana as jest.Mock; - describe('When the edit exception modal is opened', () => { const ruleName = 'test rule'; @@ -45,10 +42,6 @@ describe('When the edit exception modal is opened', () => { .spyOn(builder, 'ExceptionBuilderComponent') .mockReturnValue(<>); - const kibanaMock = createUseKibanaMock()(); - useKibanaMock.mockImplementation(() => ({ - ...kibanaMock, - })); (useSignalIndex as jest.Mock).mockReturnValue({ loading: false, signalIndexName: 'test-signal', @@ -84,6 +77,7 @@ describe('When the edit exception modal is opened', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> void; onConfirm: () => void; + onRuleChange?: () => void; } const Modal = styled(EuiModal)` @@ -83,14 +88,18 @@ const ModalBodySection = styled.section` export const EditExceptionModal = memo(function EditExceptionModal({ ruleName, + ruleId, ruleIndices, exceptionItem, exceptionListType, onCancel, onConfirm, + onRuleChange, }: EditExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); + const [updateError, setUpdateError] = useState(null); const [hasVersionConflict, setHasVersionConflict] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); @@ -108,18 +117,44 @@ export const EditExceptionModal = memo(function EditExceptionModal({ 'rules' ); - const onError = useCallback( - (error) => { + const handleExceptionUpdateError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { if (error.message.includes('Conflict')) { setHasVersionConflict(true); } else { - addError(error, { title: i18n.EDIT_EXCEPTION_ERROR }); - onCancel(); + setUpdateError({ + reason: error.message, + code: statusCode, + details: message, + listListId: exceptionItem.list_id, + }); } }, + [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + ); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + + if (onRuleChange) { + onRuleChange(); + } + + onCancel(); + }, + [addSuccess, onCancel, onRuleChange] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, [addError, onCancel] ); - const onSuccess = useCallback(() => { + + const handleExceptionUpdateSuccess = useCallback((): void => { addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); onConfirm(); }, [addSuccess, onConfirm]); @@ -127,8 +162,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { http, - onSuccess, - onError, + onSuccess: handleExceptionUpdateSuccess, + onError: handleExceptionUpdateError, } ); @@ -222,11 +257,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {ruleName} - {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( )} - {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> @@ -280,7 +313,18 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} - + {updateError != null && ( + + + + )} {hasVersionConflict && ( @@ -288,20 +332,21 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} + {updateError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts index 09e0a75d21573..1452003d8f8b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -20,7 +20,7 @@ export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( export const EDIT_EXCEPTION_TITLE = i18n.translate( 'xpack.securitySolution.exceptions.editException.editExceptionTitle', { - defaultMessage: 'Edit Exception', + defaultMessage: 'Edit Rule Exception', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx new file mode 100644 index 0000000000000..9c86c502a7648 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx @@ -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 React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getListMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; +import { ErrorCallout } from './error_callout'; +import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'); + +const mockKibanaHttpService = coreMock.createStart().http; + +describe('ErrorCallout', () => { + const mockDissasociate = jest.fn(); + + beforeEach(() => { + (useDissasociateExceptionList as jest.Mock).mockReturnValue([false, mockDissasociate]); + }); + + it('it renders error details', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: error reason (500)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + }); + + it('it invokes "onCancel" when cancel button clicked', () => { + const mockOnCancel = jest.fn(); + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('it does not render status code if not available', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeFalsy(); + }); + + it('it renders specific missing exceptions list error', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found (404)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'The associated exception list (some_uuid) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeTruthy(); + }); + + it('it dissasociates list from rule when remove exception list clicked ', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); + + expect(mockDissasociate).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx new file mode 100644 index 0000000000000..a2419ef16df3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useState, useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiAccordion, + EuiCodeBlock, + EuiButton, + EuiCallOut, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { HttpSetup } from '../../../../../../../src/core/public'; +import { List } from '../../../../common/detection_engine/schemas/types/lists'; +import { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; + +export interface ErrorInfo { + reason: string | null; + code: number | null; + details: string | null; + listListId: string | null; +} + +export interface ErrorCalloutProps { + http: HttpSetup; + rule: Rule | null; + errorInfo: ErrorInfo; + onCancel: () => void; + onSuccess: (listId: string) => void; + onError: (arg: Error) => void; +} + +const ErrorCalloutComponent = ({ + http, + rule, + errorInfo, + onCancel, + onError, + onSuccess, +}: ErrorCalloutProps): JSX.Element => { + const [listToDelete, setListToDelete] = useState(null); + const [errorTitle, setErrorTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(i18n.ADD_EXCEPTION_FETCH_ERROR); + + const handleOnSuccess = useCallback((): void => { + onSuccess(listToDelete != null ? listToDelete.id : ''); + }, [onSuccess, listToDelete]); + + const [isDissasociatingList, handleDissasociateExceptionList] = useDissasociateExceptionList({ + http, + ruleRuleId: rule != null ? rule.rule_id : '', + onSuccess: handleOnSuccess, + onError, + }); + + const canDisplay404Actions = useMemo( + (): boolean => + errorInfo.code === 404 && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null, + [errorInfo.code, listToDelete, handleDissasociateExceptionList, rule] + ); + + useEffect((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `listToDelete` is checked in canDisplay404Actions + if (canDisplay404Actions && listToDelete != null) { + setErrorMessage(i18n.ADD_EXCEPTION_FETCH_404_ERROR(listToDelete.id)); + } + + setErrorTitle(`${errorInfo.reason}${errorInfo.code != null ? ` (${errorInfo.code})` : ''}`); + }, [errorInfo.reason, errorInfo.code, listToDelete, canDisplay404Actions]); + + const handleDissasociateList = useCallback((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `handleDissasociateExceptionList` and `list` are checked in + // canDisplay404Actions + if ( + canDisplay404Actions && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null + ) { + const exceptionLists = (rule.exceptions_list ?? []).filter( + ({ id }) => id !== listToDelete.id + ); + + handleDissasociateExceptionList(exceptionLists); + } + }, [handleDissasociateExceptionList, listToDelete, canDisplay404Actions, rule]); + + useEffect((): void => { + if (errorInfo.code === 404 && rule != null && rule.exceptions_list != null) { + const [listFound] = rule.exceptions_list.filter( + ({ id, list_id: listId }) => + (errorInfo.details != null && errorInfo.details.includes(id)) || + errorInfo.listListId === listId + ); + setListToDelete(listFound); + } + }, [rule, errorInfo.details, errorInfo.code, errorInfo.listListId]); + + return ( + + +

{errorMessage}

+
+ + {listToDelete != null && ( + +

{i18n.MODAL_ERROR_ACCORDION_TEXT}

+ + } + > + + {JSON.stringify(listToDelete)} + +
+ )} + + + {i18n.CANCEL} + + {canDisplay404Actions && ( + + {i18n.CLEAR_EXCEPTIONS_LABEL} + + )} +
+ ); +}; + +ErrorCalloutComponent.displayName = 'ErrorCalloutComponent'; + +export const ErrorCallout = React.memo(ErrorCalloutComponent); + +ErrorCallout.displayName = 'ErrorCallout'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 13e9d0df549f8..484a3d593026e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -190,3 +190,52 @@ export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate( defaultMessage: 'Error getting exception item totals', } ); + +export const CLEAR_EXCEPTIONS_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.clearExceptionsLabel', + { + defaultMessage: 'Remove Exception List', + } +); + +export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) => + i18n.translate('xpack.securitySolution.exceptions.fetch404Error', { + values: { listId }, + defaultMessage: + 'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.', + }); + +export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.fetchError', + { + defaultMessage: 'Error fetching exception list', + } +); + +export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', { + defaultMessage: 'Error', +}); + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', { + defaultMessage: 'Cancel', +}); + +export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.modalErrorAccordionText', + { + defaultMessage: 'Show rule reference information:', + } +); + +export const DISSASOCIATE_LIST_SUCCESS = (id: string) => + i18n.translate('xpack.securitySolution.exceptions.dissasociateListSuccessText', { + values: { id }, + defaultMessage: 'Exception list ({id}) has successfully been removed', + }); + +export const DISSASOCIATE_EXCEPTION_LIST_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.dissasociateExceptionListError', + { + defaultMessage: 'Failed to remove exception list', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index cb1a80abedb27..46923e07d225a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -5,6 +5,7 @@ */ import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { KibanaServices } from '../../../common/lib/kibana'; import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; @@ -14,7 +15,6 @@ import * as buildAlertStatusFilterHelper from '../../../detections/components/al import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock'; -import { createKibanaCoreStartMock } from '../../../common/mock/kibana_core'; import { ExceptionListItemSchema, CreateExceptionListItemSchema, @@ -27,7 +27,7 @@ import { AddOrUpdateExceptionItemsFunc, } from './use_add_exception'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; const mockKibanaServices = KibanaServices.get as jest.Mock; jest.mock('../../../common/lib/kibana'); @@ -148,6 +148,50 @@ describe('useAddOrUpdateException', () => { }); }); + it('invokes "onError" if call to add exception item fails', async () => { + const mockError = new Error('error adding item'); + + addExceptionListItem = jest + .spyOn(listsApi, 'addExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + + it('invokes "onError" if call to update exception item fails', async () => { + const mockError = new Error('error updating item'); + + updateExceptionListItem = jest + .spyOn(listsApi, 'updateExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + describe('when alertIdToClose is not passed in', () => { it('should not update the alert status', async () => { await act(async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 9d45a411b5130..be289b0e85e66 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -42,7 +42,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess: () => void; } @@ -157,7 +157,11 @@ export const useAddOrUpdateException = ({ } catch (error) { if (isSubscribed) { setIsLoading(false); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index 6dbf5922e0a97..f20a58b9ffa36 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -6,11 +6,11 @@ import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import * as rulesApi from '../../../detections/containers/detection_engine/rules/api'; import * as listsApi from '../../../../../lists/public/exceptions/api'; import { getExceptionListSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_schema.mock'; import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock'; -import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { ExceptionListType } from '../../../lists_plugin_deps'; import { ListArray } from '../../../../common/detection_engine/schemas/types'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; @@ -20,7 +20,7 @@ import { ReturnUseFetchOrCreateRuleExceptionList, } from './use_fetch_or_create_rule_exception_list'; -const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaHttpService = coreMock.createStart().http; jest.mock('../../../detections/containers/detection_engine/rules/api'); describe('useFetchOrCreateRuleExceptionList', () => { @@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { await waitForNextUpdate(); await waitForNextUpdate(); expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error); + expect(onError).toHaveBeenCalledWith(error, null, null); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 0d367e03a799f..944631d4e9fb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -30,7 +30,7 @@ export interface UseFetchOrCreateRuleExceptionListProps { http: HttpStart; ruleId: Rule['id']; exceptionListType: ExceptionListSchema['type']; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess?: (ruleWasChanged: boolean) => void; } @@ -179,7 +179,11 @@ export const useFetchOrCreateRuleExceptionList = ({ if (isSubscribed) { setIsLoading(false); setExceptionList(null); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 7482068454a97..c97895cdfe236 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -322,11 +322,13 @@ const ExceptionsViewerComponent = ({ exceptionListTypeToEdit != null && ( )} diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index f49001bd5d7af..df655bf179f5b 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -19,11 +19,11 @@ interface HeaderProps { const Header = styled.header.attrs(() => ({ className: 'siemHeaderSection', }))` -${({ height }) => - height && - css` - height: ${height}px; - `} + ${({ height }) => + height && + css` + height: ${height}px; + `} margin-bottom: ${({ height, theme }) => (height ? 0 : theme.eui.euiSizeL)}; user-select: text; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts index a9a728f81cc6c..dde5eebe624bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts @@ -29,6 +29,9 @@ export interface UseInstalledSecurityJobsReturn { * Use the corresponding helper functions to filter the job list as * necessary (running jobs, etc). * + * NOTE: If you need to include jobs that are not currently installed, try the + * {@link useInstalledSecurityJobs} hook. + * */ export const useInstalledSecurityJobs = (): UseInstalledSecurityJobsReturn => { const [jobs, setJobs] = useState([]); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts index e8809e8366eed..2ba5cb84d272d 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts @@ -32,6 +32,7 @@ export interface UseSecurityJobsReturn { * list as necessary. E.g. installed jobs, running jobs, etc. * * NOTE: If the user is not an ml admin, jobs will be empty and isMlAdmin will be false. + * If you only need installed jobs, try the {@link useInstalledSecurityJobs} hook. * * @param refetchData */ @@ -39,7 +40,7 @@ export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const mlCapabilities = useMlCapabilities(); - const [siemDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); + const [securitySolutionDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const http = useHttp(); const { addError } = useAppToasts(); @@ -54,12 +55,12 @@ export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => async function fetchSecurityJobIdsFromGroupsData() { if (isMlAdmin && isLicensed) { try { - // Batch fetch all installed jobs, ML modules, and check which modules are compatible with siemDefaultIndex + // Batch fetch all installed jobs, ML modules, and check which modules are compatible with securitySolutionDefaultIndex const [jobSummaryData, modulesData, compatibleModules] = await Promise.all([ getJobsSummary({ http, signal: abortCtrl.signal }), getModules({ signal: abortCtrl.signal }), checkRecognizer({ - indexPatternName: siemDefaultIndex, + indexPatternName: securitySolutionDefaultIndex, signal: abortCtrl.signal, }), ]); @@ -89,7 +90,7 @@ export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => isSubscribed = false; abortCtrl.abort(); }; - }, [refetchData, isMlAdmin, isLicensed, siemDefaultIndex, addError, http]); + }, [refetchData, isMlAdmin, isLicensed, securitySolutionDefaultIndex, addError, http]); return { isLicensed, isMlAdmin, jobs, loading }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts index c839f5110fe7f..7120fcf4a9e55 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts @@ -111,7 +111,7 @@ export interface CustomURL { } /** - * Representation of an ML Job as used by the SIEM App -- a composition of ModuleJob and MlSummaryJob + * Representation of an ML Job as used by the Security Solution App -- a composition of ModuleJob and MlSummaryJob * that includes necessary metadata like moduleName, defaultIndexPattern, etc. */ export interface SecurityJob extends MlSummaryJob { diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx index aac83ce650d86..aa61688f1f986 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx @@ -7,14 +7,13 @@ import { mount } from 'enzyme'; import React from 'react'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { DEFAULT_FROM, DEFAULT_TO } from '../../../../common/constants'; import { TestProviders, mockIndexPattern } from '../../mock'; -import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager, SearchBar } from '../../../../../../../src/plugins/data/public'; import { QueryBar, QueryBarComponentProps } from '.'; -import { createKibanaContextProviderMock } from '../../mock/kibana_react'; -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; describe('QueryBar ', () => { // We are doing that because we need to wrapped this component with redux @@ -187,13 +186,9 @@ describe('QueryBar ', () => { describe('state', () => { test('clears draftQuery when filterQueryDraft has been cleared', () => { - const KibanaWithStorageProvider = createKibanaContextProviderMock(); - const Proxy = (props: QueryBarComponentProps) => ( - - - + ); @@ -231,13 +226,9 @@ describe('QueryBar ', () => { describe('#onQueryChange', () => { test(' is the only reference that changed when filterQueryDraft props get updated', () => { - const KibanaWithStorageProvider = createKibanaContextProviderMock(); - const Proxy = (props: QueryBarComponentProps) => ( - - - + ); @@ -382,24 +373,9 @@ describe('QueryBar ', () => { describe('SavedQueryManagementComponent state', () => { test('popover should hidden when "Save current query" button was clicked', () => { - const KibanaWithStorageProvider = createKibanaContextProviderMock(); - const Proxy = (props: QueryBarComponentProps) => ( - - - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx index 0795e46c9e45f..956ee4b05f9d6 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx @@ -17,7 +17,7 @@ import { kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; -import { createUseUiSetting$Mock } from '../../mock/kibana_react'; +import { createUseUiSetting$Mock } from '../../lib/kibana/kibana_react.mock'; import { createStore, State } from '../../store'; import { SuperDatePicker, makeMapStateToProps } from '.'; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 1e93fdb936728..31318122eb564 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -18,7 +18,6 @@ import { createSecuritySolutionStorageMock, mockIndexPattern, } from '../../mock'; -import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { createStore, State } from '../../store'; @@ -29,6 +28,7 @@ import { getTimelineDefaults, } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -45,7 +45,7 @@ jest.mock('../link_to'); jest.mock('../../lib/kibana'); jest.mock('../../../timelines/store/timeline/actions'); -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; const field = 'process.name'; const value = 'nice'; diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx index dd6b66350052e..0efd27c6ecbc6 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx @@ -26,13 +26,11 @@ export const Bar = styled.aside.attrs({ className: 'siemUtilityBar', })` ${({ border, theme }) => css` - ${ - border && - css` - border-bottom: ${theme.eui.euiBorderThin}; - padding-bottom: ${theme.eui.paddingSizes.s}; - ` - } + ${border && + css` + border-bottom: ${theme.eui.euiBorderThin}; + padding-bottom: ${theme.eui.paddingSizes.s}; + `} @media only screen and (min-width: ${theme.eui.euiBreakpoints.l}) { display: flex; diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx index 7085894e4a51c..58f5c1a9beb2e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx @@ -6,17 +6,13 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useKibana } from '../../lib/kibana'; -import { createUseKibanaMock } from '../../mock/kibana_react'; import { useMessagesStorage, UseMessagesStorage } from './use_messages_storage'; jest.mock('../../lib/kibana'); -const useKibanaMock = useKibana as jest.Mock; describe('useLocalStorage', () => { beforeEach(() => { - const services = { ...createUseKibanaMock()().services }; - useKibanaMock.mockImplementation(() => ({ services })); - services.storage.store.clear(); + useKibana().services.storage.clear(); }); it('should return an empty array when there is no messages', async () => { diff --git a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx index 96b0343efdf72..35f51b3c65f95 100644 --- a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx @@ -24,19 +24,6 @@ export const asArrayIfExists: WrapArrayIfExitts = (value) => */ export type ValueOf = T[keyof T]; -/** - * Unreachable Assertion helper for scenarios like exhaustive switches - * - * @param x Unreachable field - * @param message Message of error thrown - */ -export const assertUnreachable = ( - x: never, - message = 'Unknown Field in switch statement' -): never => { - throw new Error(`${message}: ${x}`); -}; - /** * Global variables */ diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index 5f4285f2747ae..573ef92f7e069 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -9,19 +9,21 @@ import { createKibanaContextProviderMock, createUseUiSettingMock, createUseUiSetting$Mock, - createUseKibanaMock, + createStartServicesMock, createWithKibanaMock, -} from '../../../mock/kibana_react'; +} from '../kibana_react.mock'; export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; -export const useKibana = jest.fn(createUseKibanaMock()); +export const useKibana = jest.fn().mockReturnValue({ services: createStartServicesMock() }); export const useUiSetting = jest.fn(createUseUiSettingMock()); export const useUiSetting$ = jest.fn(createUseUiSetting$Mock()); -export const useHttp = jest.fn(() => useKibana().services.http); +export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); export const useTimeZone = jest.fn(); export const useDateFormat = jest.fn(); export const useBasePath = jest.fn(() => '/test/base/path'); -export const useToasts = jest.fn(() => notificationServiceMock.createStartContract().toasts); +export const useToasts = jest + .fn() + .mockReturnValue(notificationServiceMock.createStartContract().toasts); export const useCurrentUser = jest.fn(); export const withKibana = jest.fn(createWithKibanaMock()); export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts new file mode 100644 index 0000000000000..c026b65853a4c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ + +import React from 'react'; + +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { securityMock } from '../../../../../../plugins/security/public/mocks'; +import { + DEFAULT_APP_TIME_RANGE, + DEFAULT_APP_REFRESH_INTERVAL, + DEFAULT_INDEX_KEY, + DEFAULT_DATE_FORMAT, + DEFAULT_DATE_FORMAT_TZ, + DEFAULT_DARK_MODE, + DEFAULT_TIME_RANGE, + DEFAULT_REFRESH_RATE_INTERVAL, + DEFAULT_FROM, + DEFAULT_TO, + DEFAULT_INTERVAL_PAUSE, + DEFAULT_INTERVAL_VALUE, + DEFAULT_BYTES_FORMAT, + DEFAULT_INDEX_PATTERN, +} from '../../../../common/constants'; +import { StartServices } from '../../../types'; +import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage'; + +const mockUiSettings: Record = { + [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, + [DEFAULT_REFRESH_RATE_INTERVAL]: { pause: false, value: 0 }, + [DEFAULT_APP_TIME_RANGE]: { + from: DEFAULT_FROM, + to: DEFAULT_TO, + }, + [DEFAULT_APP_REFRESH_INTERVAL]: { + pause: DEFAULT_INTERVAL_PAUSE, + value: DEFAULT_INTERVAL_VALUE, + }, + [DEFAULT_INDEX_KEY]: DEFAULT_INDEX_PATTERN, + [DEFAULT_BYTES_FORMAT]: '0,0.[0]b', + [DEFAULT_DATE_FORMAT_TZ]: 'UTC', + [DEFAULT_DATE_FORMAT]: 'MMM D, YYYY @ HH:mm:ss.SSS', + [DEFAULT_DARK_MODE]: false, +}; + +export const createUseUiSettingMock = () => (key: string, defaultValue?: unknown): unknown => { + const result = mockUiSettings[key]; + + if (typeof result != null) return result; + + if (defaultValue != null) { + return defaultValue; + } + + throw new TypeError(`Unexpected config key: ${key}`); +}; + +export const createUseUiSetting$Mock = () => { + const useUiSettingMock = createUseUiSettingMock(); + + return (key: string, defaultValue?: unknown): [unknown, () => void] | undefined => [ + useUiSettingMock(key, defaultValue), + jest.fn(), + ]; +}; + +export const createStartServicesMock = (): StartServices => { + const core = coreMock.createStart(); + core.uiSettings.get.mockImplementation(createUseUiSettingMock()); + const { storage } = createSecuritySolutionStorageMock(); + const data = dataPluginMock.createStartContract(); + const security = securityMock.createSetup(); + + const services = ({ + ...core, + data, + security, + storage, + } as unknown) as StartServices; + + return services; +}; + +export const createWithKibanaMock = () => { + const services = createStartServicesMock(); + + return (Component: unknown) => (props: unknown) => { + return React.createElement(Component as string, { ...(props as object), kibana: { services } }); + }; +}; + +export const createKibanaContextProviderMock = () => { + const services = createStartServicesMock(); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(KibanaContextProvider, { services }, children); +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 1ed459521cc79..1b9e95f7d0737 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -17,7 +17,7 @@ import { apolloClientObservable, kibanaObservable } from '../test_providers'; import { createStore, State } from '../../store'; import { AppRootProvider } from './app_root_provider'; import { managementMiddlewareFactory } from '../../../management/store/middleware'; -import { createKibanaContextProviderMock } from '../kibana_react'; +import { createKibanaContextProviderMock } from '../../lib/kibana/kibana_react.mock'; import { SUB_PLUGINS_REDUCER, mockGlobalState, createSecuritySolutionStorageMock } from '..'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; diff --git a/x-pack/plugins/security_solution/public/common/mock/index.ts b/x-pack/plugins/security_solution/public/common/mock/index.ts index 678ad4d84b586..7e076772c42fb 100644 --- a/x-pack/plugins/security_solution/public/common/mock/index.ts +++ b/x-pack/plugins/security_solution/public/common/mock/index.ts @@ -16,4 +16,3 @@ export * from './test_providers'; export * from './utils'; export * from './mock_ecs'; export * from './timeline_results'; -export * from './kibana_react'; diff --git a/x-pack/plugins/security_solution/public/common/mock/kibana_core.ts b/x-pack/plugins/security_solution/public/common/mock/kibana_core.ts deleted file mode 100644 index f8eed75cf9bf1..0000000000000 --- a/x-pack/plugins/security_solution/public/common/mock/kibana_core.ts +++ /dev/null @@ -1,15 +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 { coreMock } from '../../../../../../src/core/public/mocks'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; -import { securityMock } from '../../../../../plugins/security/public/mocks'; - -export const createKibanaCoreStartMock = () => coreMock.createStart(); -export const createKibanaPluginsStartMock = () => ({ - data: dataPluginMock.createStartContract(), - security: securityMock.createSetup(), -}); diff --git a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts deleted file mode 100644 index bdb8ca85b0d77..0000000000000 --- a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable react/display-name */ - -import React from 'react'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; - -import { - DEFAULT_APP_TIME_RANGE, - DEFAULT_APP_REFRESH_INTERVAL, - DEFAULT_INDEX_KEY, - DEFAULT_DATE_FORMAT, - DEFAULT_DATE_FORMAT_TZ, - DEFAULT_DARK_MODE, - DEFAULT_TIME_RANGE, - DEFAULT_REFRESH_RATE_INTERVAL, - DEFAULT_FROM, - DEFAULT_TO, - DEFAULT_INTERVAL_PAUSE, - DEFAULT_INTERVAL_VALUE, - DEFAULT_BYTES_FORMAT, - DEFAULT_INDEX_PATTERN, -} from '../../../common/constants'; -import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core'; -import { StartServices } from '../../types'; -import { createSecuritySolutionStorageMock } from './mock_local_storage'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const mockUiSettings: Record = { - [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, - [DEFAULT_REFRESH_RATE_INTERVAL]: { pause: false, value: 0 }, - [DEFAULT_APP_TIME_RANGE]: { - from: DEFAULT_FROM, - to: DEFAULT_TO, - }, - [DEFAULT_APP_REFRESH_INTERVAL]: { - pause: DEFAULT_INTERVAL_PAUSE, - value: DEFAULT_INTERVAL_VALUE, - }, - [DEFAULT_INDEX_KEY]: DEFAULT_INDEX_PATTERN, - [DEFAULT_BYTES_FORMAT]: '0,0.[0]b', - [DEFAULT_DATE_FORMAT_TZ]: 'UTC', - [DEFAULT_DATE_FORMAT]: 'MMM D, YYYY @ HH:mm:ss.SSS', - [DEFAULT_DARK_MODE]: false, -}; - -export const createUseUiSettingMock = () => ( - key: string, - defaultValue?: T -): T => { - const result = mockUiSettings[key]; - - if (typeof result != null) return result; - - if (defaultValue != null) { - return defaultValue; - } - - throw new Error(`Unexpected config key: ${key}`); -}; - -export const createUseUiSetting$Mock = () => { - const useUiSettingMock = createUseUiSettingMock(); - - return ( - key: string, - defaultValue?: T - ): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()]; -}; - -export const createKibanaObservable$Mock = createKibanaCoreStartMock; - -export const createUseKibanaMock = () => { - const core = createKibanaCoreStartMock(); - const plugins = createKibanaPluginsStartMock(); - const useUiSetting = createUseUiSettingMock(); - const { storage } = createSecuritySolutionStorageMock(); - - const services = { - ...core, - ...plugins, - uiSettings: { - ...core.uiSettings, - get: useUiSetting, - }, - storage, - }; - - return () => ({ services }); -}; - -export const createStartServices = () => { - const core = createKibanaCoreStartMock(); - const plugins = createKibanaPluginsStartMock(); - - const services = ({ - ...core, - ...plugins, - } as unknown) as StartServices; - - return services; -}; - -export const createWithKibanaMock = () => { - const kibana = createUseKibanaMock()(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (Component: any) => (props: any) => { - return React.createElement(Component, { ...props, kibana }); - }; -}; - -export const createKibanaContextProviderMock = () => { - const kibana = createUseKibanaMock()(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ({ services, ...rest }: any) => - React.createElement(KibanaContextProvider, { - ...rest, - services: { ...kibana.services, ...services }, - }); -}; diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 010d2fac18af5..9ead8171bfef6 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -19,7 +19,10 @@ import { ThemeProvider } from 'styled-components'; import { createStore, State } from '../store'; import { mockGlobalState } from './global_state'; -import { createKibanaContextProviderMock, createStartServices } from './kibana_react'; +import { + createKibanaContextProviderMock, + createStartServicesMock, +} from '../lib/kibana/kibana_react.mock'; import { FieldHook, useForm } from '../../shared_imports'; import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; @@ -38,7 +41,7 @@ export const apolloClient = new ApolloClient({ }); export const apolloClientObservable = new BehaviorSubject(apolloClient); -export const kibanaObservable = new BehaviorSubject(createStartServices()); +export const kibanaObservable = new BehaviorSubject(createStartServicesMock()); Object.defineProperty(window, 'localStorage', { value: localStorageMock(), diff --git a/x-pack/plugins/security_solution/public/common/store/epic.ts b/x-pack/plugins/security_solution/public/common/store/epic.ts index d9de7951a86f4..51a9377b9fd04 100644 --- a/x-pack/plugins/security_solution/public/common/store/epic.ts +++ b/x-pack/plugins/security_solution/public/common/store/epic.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineEpics } from 'redux-observable'; +import { combineEpics, Epic } from 'redux-observable'; +import { Action } from 'redux'; + import { createTimelineEpic } from '../../timelines/store/timeline/epic'; import { createTimelineFavoriteEpic } from '../../timelines/store/timeline/epic_favorite'; import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note'; import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event'; import { createTimelineLocalStorageEpic } from '../../timelines/store/timeline/epic_local_storage'; +import { TimelineEpicDependencies } from '../../timelines/store/timeline/types'; -export const createRootEpic = () => +export const createRootEpic = (): Epic< + Action, + Action, + State, + TimelineEpicDependencies +> => combineEpics( createTimelineEpic(), createTimelineFavoriteEpic(), diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index a39c9f18bcdb8..f041e1fd82a9f 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -13,6 +13,7 @@ import { Middleware, Dispatch, PreloadedState, + CombinedState, } from 'redux'; import { createEpicMiddleware } from 'redux-observable'; @@ -30,6 +31,7 @@ import { Immutable } from '../../../common/endpoint/types'; import { State } from './types'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import { CoreStart } from '../../../../../../src/core/public'; +import { TimelineEpicDependencies } from '../../timelines/store/timeline/types'; type ComposeType = typeof compose; declare global { @@ -56,7 +58,7 @@ export const createStore = ( ): Store => { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - const middlewareDependencies = { + const middlewareDependencies: TimelineEpicDependencies = { apolloClient$: apolloClient, kibana$: kibana, selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector, @@ -80,7 +82,7 @@ export const createStore = ( ) ); - epicMiddleware.run(createRootEpic()); + epicMiddleware.run(createRootEpic>()); return store; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 5bab2e3c78970..ca17d331c67e5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -9,6 +9,8 @@ import ApolloClient from 'apollo-client'; import { Dispatch } from 'redux'; import { EuiText } from '@elastic/eui'; +import { RuleType } from '../../../../common/detection_engine/types'; +import { isMlRule } from '../../../../common/machine_learning/helpers'; import { RowRendererId } from '../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -39,6 +41,7 @@ import { import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { AddExceptionModalBaseProps } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; +import { isThresholdRule } from '../../../../common/detection_engine/utils'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -193,6 +196,7 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + 'signal.rule.type', 'signal.original_event.kind', 'signal.original_event.module', @@ -317,6 +321,15 @@ export const getAlertActions = ({ return module === 'endpoint' && kind === 'alert'; }; + const exceptionsAreAllowed = () => { + const ruleTypes = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.type', + }); + const [ruleType] = ruleTypes as RuleType[]; + return !isMlRule(ruleType) && !isThresholdRule(ruleType); + }; + return [ { ...getInvestigateInResolverAction({ dispatch, timelineId }), @@ -386,7 +399,7 @@ export const getAlertActions = ({ } }, id: 'addException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !exceptionsAreAllowed(), dataTestSubj: 'add-exception-menu-item', ariaLabel: 'Add Exception', content: {i18n.ACTION_ADD_EXCEPTION}, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 66423259ec155..07e69d850f173 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -105,7 +105,6 @@ export const AlertsTableComponent: React.FC = ({ updateTimelineIsLoading, }) => { const dispatch = useDispatch(); - const [selectAll, setSelectAll] = useState(false); const apolloClient = useApolloClient(); const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); @@ -120,6 +119,12 @@ export const AlertsTableComponent: React.FC = ({ ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); + const { + initializeTimeline, + setSelectAll, + setTimelineRowActions, + setIndexToAdd, + } = useManageTimeline(); const getGlobalQuery = useCallback( (customFilters: Filter[]) => { @@ -141,8 +146,7 @@ export const AlertsTableComponent: React.FC = ({ } return null; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [browserFields, globalFilters, globalQuery, indexPatterns, kibana, to, from] + [browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from] ); // Callback for creating a new timeline -- utilized by row/batch actions @@ -240,12 +244,15 @@ export const AlertsTableComponent: React.FC = ({ // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar useEffect(() => { - if (!isSelectAllChecked) { - setShowClearSelectionAction(false); + if (isSelectAllChecked) { + setSelectAll({ + id: timelineId, + selectAll: false, + }); } else { - setSelectAll(false); + setShowClearSelectionAction(false); } - }, [isSelectAllChecked]); + }, [isSelectAllChecked, setSelectAll, timelineId]); // Callback for when open/closed filter changes const onFilterGroupChangedCallback = useCallback( @@ -261,17 +268,23 @@ export const AlertsTableComponent: React.FC = ({ // Callback for clearing entire selection from utility bar const clearSelectionCallback = useCallback(() => { clearSelected!({ id: timelineId }); - setSelectAll(false); + setSelectAll({ + id: timelineId, + selectAll: false, + }); setShowClearSelectionAction(false); }, [clearSelected, setSelectAll, setShowClearSelectionAction, timelineId]); // Callback for selecting all events on all pages from utility bar // Dispatches to stateful_body's selectAll via TimelineTypeContext props // as scope of response data required to actually set selectedEvents - const selectAllCallback = useCallback(() => { - setSelectAll(true); + const selectAllOnAllPagesCallback = useCallback(() => { + setSelectAll({ + id: timelineId, + selectAll: true, + }); setShowClearSelectionAction(true); - }, [setSelectAll, setShowClearSelectionAction]); + }, [setSelectAll, setShowClearSelectionAction, timelineId]); const updateAlertsStatusCallback: UpdateAlertsStatusCallback = useCallback( async ( @@ -314,7 +327,7 @@ export const AlertsTableComponent: React.FC = ({ clearSelection={clearSelectionCallback} hasIndexWrite={hasIndexWrite} currentFilter={filterGroup} - selectAll={selectAllCallback} + selectAll={selectAllOnAllPagesCallback} selectedEventIds={selectedEventIds} showBuildingBlockAlerts={showBuildingBlockAlerts} onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} @@ -332,7 +345,7 @@ export const AlertsTableComponent: React.FC = ({ showBuildingBlockAlerts, onShowBuildingBlockAlertsChanged, loadingEventIds.length, - selectAllCallback, + selectAllOnAllPagesCallback, selectedEventIds, showClearSelectionAction, updateAlertsStatusCallback, @@ -384,7 +397,6 @@ export const AlertsTableComponent: React.FC = ({ } }, [defaultFilters, filterGroup]); const { filterManager } = useKibana().services.data.query; - const { initializeTimeline, setTimelineRowActions, setIndexToAdd } = useManageTimeline(); useEffect(() => { initializeTimeline({ @@ -395,7 +407,7 @@ export const AlertsTableComponent: React.FC = ({ id: timelineId, indexToAdd: defaultIndices, loadingText: i18n.LOADING_ALERTS, - selectAll: canUserCRUD ? selectAll : false, + selectAll: false, timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })], title: '', }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index 600bc999849d1..3a0a5b04c5874 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -21,6 +21,7 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import { assertUnreachable } from '../../../../../common/utility_types'; import * as i18nSeverity from '../severity_mapping/translations'; import * as i18nRiskScore from '../risk_score_mapping/translations'; import { Threshold } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -33,7 +34,6 @@ import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types'; import { SeverityBadge } from '../severity_badge'; import ListTreeIcon from './assets/list_tree_icon.svg'; -import { assertUnreachable } from '../../../../common/lib/helpers'; import { AboutStepRiskScore, AboutStepSeverity } from '../../../pages/detection_engine/rules/types'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx index 0f8e0fba1e3af..291587e9f69c5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const useListsConfig = jest.fn().mockReturnValue({}); +import { getUseListsConfigMock } from '../use_lists_config.mock'; + +export const useListsConfig = jest.fn(getUseListsConfigMock); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.mock.ts new file mode 100644 index 0000000000000..90f47972a3a2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UseListsConfigReturn } from './use_lists_config'; + +export const getUseListsConfigMock: () => jest.Mocked = () => ({ + canManageIndex: null, + canWriteIndex: null, + enabled: true, + loading: false, + needsConfiguration: false, +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.test.tsx new file mode 100644 index 0000000000000..a5ff29e2091b0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { useListsIndex } from './use_lists_index'; +import { useListsPrivileges } from './use_lists_privileges'; +import { getUseListsIndexMock } from './use_lists_index.mock'; +import { getUseListsPrivilegesMock } from './use_lists_privileges.mock'; +import { useListsConfig } from './use_lists_config'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_lists_index'); +jest.mock('./use_lists_privileges'); + +describe('useListsConfig', () => { + let listsIndexMock: ReturnType; + let listsPrivilegesMock: ReturnType; + + beforeEach(() => { + listsIndexMock = getUseListsIndexMock(); + listsPrivilegesMock = getUseListsPrivilegesMock(); + (useListsIndex as jest.Mock).mockReturnValue(listsIndexMock); + (useListsPrivileges as jest.Mock).mockReturnValue(listsPrivilegesMock); + }); + + it("returns the user's write permissions", () => { + listsPrivilegesMock.canWriteIndex = false; + const { result } = renderHook(() => useListsConfig()); + expect(result.current.canWriteIndex).toEqual(false); + + listsPrivilegesMock.canWriteIndex = true; + const { result: result2 } = renderHook(() => useListsConfig()); + expect(result2.current.canWriteIndex).toEqual(true); + }); + + describe('when lists are disabled', () => { + beforeEach(() => { + useKibana().services.lists = undefined; + }); + + it('indicates that lists are not enabled, and need configuration', () => { + const { result } = renderHook(() => useListsConfig()); + expect(result.current.enabled).toEqual(false); + expect(result.current.needsConfiguration).toEqual(true); + }); + }); + + describe('when lists are enabled but indexes do not exist', () => { + beforeEach(() => { + useKibana().services.lists = {}; + listsIndexMock.indexExists = false; + }); + + it('needs configuration if the user cannot manage indexes', () => { + listsPrivilegesMock.canManageIndex = false; + + const { result } = renderHook(() => useListsConfig()); + expect(result.current.needsConfiguration).toEqual(true); + expect(listsIndexMock.createIndex).not.toHaveBeenCalled(); + }); + + it('attempts to create the indexes if the user can manage indexes', () => { + listsPrivilegesMock.canManageIndex = true; + + renderHook(() => useListsConfig()); + expect(listsIndexMock.createIndex).toHaveBeenCalled(); + }); + }); + + describe('when lists are enabled and indexes exist', () => { + beforeEach(() => { + useKibana().services.lists = {}; + listsIndexMock.indexExists = true; + }); + + it('does not need configuration', () => { + const { result } = renderHook(() => useListsConfig()); + expect(result.current.needsConfiguration).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.mock.ts similarity index 51% rename from x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.mock.ts index abf6db416c7f4..e2169442d80e6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.mock.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export declare const defaultDeletePhase: any; -export declare const defaultColdPhase: any; -export declare const defaultWarmPhase: any; -export declare const defaultHotPhase: any; +import { UseListsIndexReturn } from './use_lists_index'; + +export const getUseListsIndexMock: () => jest.Mocked = () => ({ + createIndex: jest.fn(), + indexExists: null, + error: null, + loading: false, +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.mock.ts new file mode 100644 index 0000000000000..4f583a72460e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.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 { UseListsPrivilegesReturn } from './use_lists_privileges'; + +export const getUseListsPrivilegesMock: () => jest.Mocked = () => ({ + isAuthenticated: null, + canManageIndex: null, + canWriteIndex: null, + loading: false, +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx new file mode 100644 index 0000000000000..6721d89f2799b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +import * as api from './api'; +import { ruleMock } from './mock'; +import { + ReturnUseDissasociateExceptionList, + UseDissasociateExceptionListProps, + useDissasociateExceptionList, +} from './use_dissasociate_exception_list'; + +const mockKibanaHttpService = coreMock.createStart().http; + +describe('useDissasociateExceptionList', () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + + beforeEach(() => { + jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseDissasociateExceptionListProps, + ReturnUseDissasociateExceptionList + >(() => + useDissasociateExceptionList({ + http: mockKibanaHttpService, + ruleRuleId: 'rule_id', + onError, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, null]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx new file mode 100644 index 0000000000000..dffba3e6e0436 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useRef } from 'react'; + +import { HttpStart } from '../../../../../../../../src/core/public'; +import { List } from '../../../../../common/detection_engine/schemas/types/lists'; +import { patchRule } from './api'; + +type Func = (lists: List[]) => void; +export type ReturnUseDissasociateExceptionList = [boolean, Func | null]; + +export interface UseDissasociateExceptionListProps { + http: HttpStart; + ruleRuleId: string; + onError: (arg: Error) => void; + onSuccess: () => void; +} + +/** + * Hook for removing an exception list reference from a rule + * + * @param http Kibana http service + * @param ruleRuleId a rule_id (NOT id) + * @param onError error callback + * @param onSuccess success callback + * + */ +export const useDissasociateExceptionList = ({ + http, + ruleRuleId, + onError, + onSuccess, +}: UseDissasociateExceptionListProps): ReturnUseDissasociateExceptionList => { + const [isLoading, setLoading] = useState(false); + const dissasociateList = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const dissasociateListFromRule = (id: string) => async ( + exceptionLists: List[] + ): Promise => { + try { + if (isSubscribed) { + setLoading(true); + + await patchRule({ + ruleProperties: { + rule_id: id, + exceptions_list: exceptionLists, + }, + signal: abortCtrl.signal, + }); + + onSuccess(); + setLoading(false); + } + } catch (err) { + if (isSubscribed) { + setLoading(false); + onError(err); + } + } + }; + + dissasociateList.current = dissasociateListFromRule(ruleRuleId); + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [http, ruleRuleId, onError, onSuccess]); + + return [isLoading, dissasociateList.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index b07caa754aec9..9f486dc11e99d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -9,7 +9,6 @@ import { shallow, mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import '../../../../../common/mock/match_media'; -import { createKibanaContextProviderMock } from '../../../../../common/mock/kibana_react'; import { TestProviders } from '../../../../../common/mock'; // we don't have the types for waitFor just yet, so using "as waitFor" until when we do import { wait as waitFor } from '@testing-library/react'; @@ -182,23 +181,20 @@ describe('AllRules', () => { }); it('renders rules tab', async () => { - const KibanaContext = createKibanaContextProviderMock(); const wrapper = mount( - - - + ); @@ -211,24 +207,20 @@ describe('AllRules', () => { }); it('renders monitoring tab when monitoring tab clicked', async () => { - const KibanaContext = createKibanaContextProviderMock(); - const wrapper = mount( - - - + ); const monitoringTab = wrapper.find('[data-test-subj="allRulesTableTab-monitoring"] button'); diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index d72891fad8f53..8b795fca41512 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -8,6 +8,7 @@ import React, { useMemo, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { IIndexPattern } from 'src/plugins/data/public'; +import { assertUnreachable } from '../../../../common/utility_types'; import { Direction, HostFields, @@ -17,7 +18,6 @@ import { HostsSortField, OsFields, } from '../../../graphql/types'; -import { assertUnreachable } from '../../../common/lib/helpers'; import { State } from '../../../common/store'; import { Columns, diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx index af9d2b0ffefe3..9a971e0087d12 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx @@ -8,6 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { assertUnreachable } from '../../../../common/utility_types'; import { networkActions, networkModel, networkSelectors } from '../../store'; import { Direction, @@ -26,7 +27,6 @@ import { import { getUsersColumns } from './columns'; import * as i18n from './translations'; -import { assertUnreachable } from '../../../common/lib/helpers'; const tableType = networkModel.IpDetailsTableType.users; interface OwnProps { diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx index 99902a31975d0..446679ae26d9e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/no_cases/index.test.tsx @@ -9,29 +9,19 @@ import { mount } from 'enzyme'; import { useKibana } from '../../../../common/lib/kibana'; import '../../../../common/mock/match_media'; -import { createUseKibanaMock, TestProviders } from '../../../../common/mock'; +import { TestProviders } from '../../../../common/mock'; import { NoCases } from '.'; jest.mock('../../../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mock; - -let navigateToApp: jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; describe('RecentCases', () => { + let navigateToApp: jest.Mock; + beforeEach(() => { - jest.resetAllMocks(); navigateToApp = jest.fn(); - const kibanaMock = createUseKibanaMock()(); - useKibanaMock.mockReturnValue({ - ...kibanaMock, - services: { - application: { - navigateToApp, - getUrlForApp: jest.fn(), - }, - }, - }); + useKibanaMock().services.application.navigateToApp = navigateToApp; }); it('if no cases, you should be able to create a case by clicking on the link "start a new case"', () => { diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index f1a933fb34d66..1017cbb6a2c61 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -19,7 +19,6 @@ import { AppNavLinkStatus, } from '../../../../src/core/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; import { jiraActionType, resilientActionType } from './common/lib/connectors'; @@ -66,22 +65,36 @@ export class Plugin implements IPlugin, plugins: SetupPlugins) { + public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup { + const APP_NAME = i18n.translate('xpack.securitySolution.security.title', { + defaultMessage: 'Security', + }); + initTelemetry(plugins.usageCollection, APP_ID); - plugins.home.featureCatalogue.register({ - id: APP_ID, - title: i18n.translate('xpack.securitySolution.featureCatalogue.title', { - defaultMessage: 'Security', - }), - description: i18n.translate('xpack.securitySolution.featureCatalogue.description', { - defaultMessage: 'Explore security metrics and logs for events and alerts', - }), - icon: APP_ICON, - path: APP_OVERVIEW_PATH, - showOnHomePage: true, - category: FeatureCatalogueCategory.DATA, - }); + if (plugins.home) { + plugins.home.featureCatalogue.registerSolution({ + id: APP_ID, + title: APP_NAME, + subtitle: i18n.translate('xpack.securitySolution.featureCatalogue.subtitle', { + defaultMessage: 'SIEM & Endpoint Security', + }), + descriptions: [ + i18n.translate('xpack.securitySolution.featureCatalogueDescription1', { + defaultMessage: 'Prevent threats autonomously.', + }), + i18n.translate('xpack.securitySolution.featureCatalogueDescription2', { + defaultMessage: 'Detect and respond.', + }), + i18n.translate('xpack.securitySolution.featureCatalogueDescription3', { + defaultMessage: 'Investigate incidents.', + }), + ], + icon: 'logoSecurity', + path: APP_OVERVIEW_PATH, + order: 300, + }); + } plugins.triggers_actions_ui.actionTypeRegistry.register(jiraActionType()); plugins.triggers_actions_ui.actionTypeRegistry.register(resilientActionType()); @@ -105,9 +118,7 @@ export class Plugin implements IPlugin { @@ -319,7 +330,12 @@ export class Plugin implements IPlugin { + const { resolverPluginSetup } = await import('./resolver'); + return resolverPluginSetup(); + }, + }; } public start(core: CoreStart, plugins: StartPlugins) { diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index 016ebfa0faee4..dee53a624baff 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -25,7 +25,7 @@ export function dataAccessLayerFactory( * Used to get non-process related events for a node. */ async relatedEvents(entityID: string): Promise { - return context.services.http.get(`/api/endpoint/resolver/${entityID}/events`, { + return context.services.http.post(`/api/endpoint/resolver/${entityID}/events`, { query: { events: 100 }, }); }, diff --git a/x-pack/plugins/security_solution/public/resolver/index.ts b/x-pack/plugins/security_solution/public/resolver/index.ts new file mode 100644 index 0000000000000..409f82c9d1560 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/index.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 { Provider } from 'react-redux'; +import { ResolverPluginSetup } from './types'; +import { resolverStoreFactory } from './store/index'; +import { ResolverWithoutProviders } from './view/resolver_without_providers'; +import { noAncestorsTwoChildren } from './data_access_layer/mocks/no_ancestors_two_children'; + +/** + * These exports are used by the plugin 'resolverTest' defined in x-pack's plugin_functional suite. + */ + +/** + * Provide access to Resolver APIs. + */ +export function resolverPluginSetup(): ResolverPluginSetup { + return { + Provider, + storeFactory: resolverStoreFactory, + ResolverWithoutProviders, + mocks: { + dataAccessLayer: { + noAncestorsTwoChildren, + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index edda2ef984a9e..e087db9f74685 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -11,6 +11,7 @@ import * as selectors from './selectors'; import { DataState } from '../../types'; import { DataAction } from './action'; import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; /** * Test the data reducer and selector. @@ -175,6 +176,24 @@ describe('Resolver Data Middleware', () => { eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 ); }); + it('should return the correct related event detail metadata for a given related event', () => { + const relatedEventsByCategory = selectors.relatedEventsByCategory(store.getState()); + const someRelatedEventForTheFirstChild = relatedEventsByCategory(firstChildNodeInTree.id)( + categoryToOverCount + )[0]; + const relatedEventID = eventModel.eventId(someRelatedEventForTheFirstChild)!; + const relatedDisplayInfo = selectors.relatedEventDisplayInfoByEntityAndSelfID( + store.getState() + )(firstChildNodeInTree.id, relatedEventID); + const [, countOfSameType, , sectionData] = relatedDisplayInfo; + const hostEntries = sectionData.filter((section) => { + return section.sectionTitle === 'host'; + })[0].entries; + expect(hostEntries).toContainEqual({ title: 'os.platform', description: 'Windows' }); + expect(countOfSameType).toBe( + eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 + ); + }); it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => { const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 569a24bb8537e..965547f1e309a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -14,6 +14,7 @@ import { IndexedProcessNode, AABB, VisibleEntites, + SectionData, } from '../../types'; import { isGraphableProcess, @@ -29,11 +30,14 @@ import { ResolverNodeStats, ResolverRelatedEvents, SafeResolverEvent, + EndpointEvent, + LegacyEndpointEvent, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; +import { formatDate } from '../../view/panels/panel_content_utilities'; /** * If there is currently a request. @@ -173,6 +177,100 @@ export function relatedEventsByEntityId(data: DataState): Map
` entries + */ +const objectToDescriptionListEntries = function* ( + obj: object, + prefix = '' +): Generator<{ title: string; description: string }> { + const nextPrefix = prefix.length ? `${prefix}.` : ''; + for (const [metaKey, metaValue] of Object.entries(obj)) { + if (typeof metaValue === 'number' || typeof metaValue === 'string') { + yield { title: nextPrefix + metaKey, description: `${metaValue}` }; + } else if (metaValue instanceof Array) { + yield { + title: nextPrefix + metaKey, + description: metaValue + .filter((arrayEntry) => { + return typeof arrayEntry === 'number' || typeof arrayEntry === 'string'; + }) + .join(','), + }; + } else if (typeof metaValue === 'object') { + yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey); + } + } +}; + +/** + * Returns a function that returns the information needed to display related event details based on + * the related event's entityID and its own ID. + */ +export const relatedEventDisplayInfoByEntityAndSelfID: ( + state: DataState +) => ( + entityId: string, + relatedEventId: string | number +) => [ + EndpointEvent | LegacyEndpointEvent | undefined, + number, + string | undefined, + SectionData, + string +] = createSelector(relatedEventsByEntityId, function relatedEventDetails( + /* eslint-disable no-shadow */ + relatedEventsByEntityId + /* eslint-enable no-shadow */ +) { + return defaultMemoize((entityId: string, relatedEventId: string | number) => { + const relatedEventsForThisProcess = relatedEventsByEntityId.get(entityId); + if (!relatedEventsForThisProcess) { + return [undefined, 0, undefined, [], '']; + } + const specificEvent = relatedEventsForThisProcess.events.find( + (evt) => eventModel.eventId(evt) === relatedEventId + ); + // For breadcrumbs: + const specificCategory = specificEvent && eventModel.primaryEventCategory(specificEvent); + const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { + return eventModel.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; + }, 0); + + // Assuming these details (agent, ecs, process) aren't as helpful, can revisit + const { agent, ecs, process, ...relevantData } = specificEvent as ResolverEvent & { + // Type this with various unknown keys so that ts will let us delete those keys + ecs: unknown; + process: unknown; + }; + + let displayDate = ''; + const sectionData: SectionData = Object.entries(relevantData) + .map(([sectionTitle, val]) => { + if (sectionTitle === '@timestamp') { + displayDate = formatDate(val); + return { sectionTitle: '', entries: [] }; + } + if (typeof val !== 'object') { + return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] }; + } + return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] }; + }) + .filter((v) => v.sectionTitle !== '' && v.entries.length); + + return [specificEvent, countOfCategory, specificCategory, sectionData, displayDate]; + }); +}); + /** * Returns a function that returns a function (when supplied with an entity id for a node) * that returns related events for a node that match an event.category (when supplied with the category) diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index 950a61db33f17..ed8a5129c7ff6 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -11,7 +11,7 @@ import { resolverReducer } from './reducer'; import { resolverMiddlewareFactory } from './middleware'; import { ResolverAction } from './actions'; -export const storeFactory = ( +export const resolverStoreFactory = ( dataAccessLayer: DataAccessLayer ): Store => { const actionsDenylist: Array = ['userMovedPointer']; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 70a461909a99b..f50aeed3f4d48 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -122,6 +122,15 @@ export const relatedEventsByEntityId = composeSelectors( dataSelectors.relatedEventsByEntityId ); +/** + * Returns a function that returns the information needed to display related event details based on + * the related event's entityID and its own ID. + */ +export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventDisplayInfoByEntityAndSelfID +); + /** * Returns a function that returns a function (when supplied with an entity id for a node) * that returns related events for a node that match an event.category (when supplied with the category) diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index b79b7df48a6de..a6520c8f0e06f 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Store, createStore, applyMiddleware } from 'redux'; import { mount, ReactWrapper } from 'enzyme'; -import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; +import { History as HistoryPackageHistoryInterface, createMemoryHistory } from 'history'; import { CoreStart } from '../../../../../../../src/core/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { spyMiddlewareFactory } from '../spy_middleware_factory'; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 97d97700b11ae..9ebe3fa14e842 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -9,6 +9,7 @@ import { Store } from 'redux'; import { Middleware, Dispatch } from 'redux'; import { BBox } from 'rbush'; +import { Provider } from 'react-redux'; import { ResolverAction } from './store/actions'; import { ResolverRelatedEvents, @@ -159,6 +160,22 @@ export interface IndexedProcessNode extends BBox { position: Vector2; } +/** + * A type describing the shape of section titles and entries for description lists + */ +export type SectionData = Array<{ + sectionTitle: string; + entries: Array<{ title: string; description: string }>; +}>; + +/** + * The two query parameters we read/write on to control which view the table presents: + */ +export interface CrumbInfo { + crumbId: string; + crumbEvent: string; +} + /** * A type containing all things to actually be rendered to the DOM. */ @@ -410,7 +427,7 @@ export interface SideEffectSimulator { /** * Mocked `SideEffectors`. */ - mock: jest.Mocked> & Pick; + mock: SideEffectors; } /** @@ -532,3 +549,42 @@ export interface SpyMiddleware { */ debugActions: () => () => void; } + +/** + * values of this type are exposed by the Security Solution plugin's setup phase. + */ +export interface ResolverPluginSetup { + /** + * Provide access to the instance of the `react-redux` `Provider` that Resolver recognizes. + */ + Provider: typeof Provider; + /** + * Takes a `DataAccessLayer`, which could be a mock one, and returns an redux Store. + * All data acess (e.g. HTTP requests) are done through the store. + */ + storeFactory: (dataAccessLayer: DataAccessLayer) => Store; + + /** + * The Resolver component without the required Providers. + * You must wrap this component in: `I18nProvider`, `Router` (from react-router,) `KibanaContextProvider`, + * and the `Provider` component provided by this object. + */ + ResolverWithoutProviders: React.MemoExoticComponent< + React.ForwardRefExoticComponent> + >; + + /** + * A collection of mock objects that can be used in examples or in testing. + */ + mocks: { + /** + * Mock `DataAccessLayer`s. All of Resolver's HTTP access is provided by a `DataAccessLayer`. + */ + dataAccessLayer: { + /** + * A mock `DataAccessLayer` that returns a tree that has no ancestor nodes but which has 2 children nodes. + */ + noAncestorsTwoChildren: () => { dataAccessLayer: DataAccessLayer }; + }; + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index d9a0bf291d0e4..bcc420435e5d9 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { Provider } from 'react-redux'; -import { storeFactory } from '../store'; +import { resolverStoreFactory } from '../store'; import { StartServices } from '../../types'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { DataAccessLayer, ResolverProps } from '../types'; @@ -24,7 +24,7 @@ export const Resolver = React.memo((props: ResolverProps) => { ]); const store = useMemo(() => { - return storeFactory(dataAccessLayer); + return resolverStoreFactory(dataAccessLayer); }, [dataAccessLayer]); return ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx index 129aff776808a..c528ba547e6ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx @@ -8,10 +8,11 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; -import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; +import { StyledBreadcrumbs } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; +import { CrumbInfo } from '../../types'; /** * This view gives counts for all the related events of a process grouped by related event type. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx index c9a536fd5932d..b93ef6146f1cf 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx @@ -7,7 +7,8 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; -import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; +import { StyledBreadcrumbs } from './panel_content_utilities'; +import { CrumbInfo } from '../../types'; /** * Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 55b5be21fb4a4..5c7a4a476efba 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -23,14 +23,6 @@ const BetaHeader = styled(`header`)` margin-bottom: 1em; `; -/** - * The two query parameters we read/write on to control which view the table presents: - */ -export interface CrumbInfo { - crumbId: string; - crumbEvent: string; -} - const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` &.euiBreadcrumbs { background-color: ${(props) => props.background}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index adfcc4cc44d1f..15711909c4c9b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -19,7 +19,7 @@ import { FormattedMessage } from 'react-intl'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import * as selectors from '../../store/selectors'; import * as event from '../../../../common/endpoint/models/event'; -import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatDate, StyledBreadcrumbs } from './panel_content_utilities'; import { processPath, processPid, @@ -31,6 +31,7 @@ import { import { CubeForProcess } from './cube_for_process'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { useResolverTheme } from '../assets'; +import { CrumbInfo } from '../../types'; const StyledDescriptionList = styled(EuiDescriptionList)` &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx index 101711475c938..a710d3ad846b3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx @@ -10,18 +10,13 @@ import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; import styled from 'styled-components'; -import { - CrumbInfo, - formatDate, - StyledBreadcrumbs, - BoldCode, - StyledTime, -} from './panel_content_utilities'; +import { formatDate, StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { RelatedEventLimitWarning } from '../limit_warnings'; +import { CrumbInfo } from '../../types'; /** * This view presents a list of related events of a given type for a given process. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx index 1be4b4b055243..e42140feb928b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx @@ -16,12 +16,13 @@ import { useSelector } from 'react-redux'; import styled from 'styled-components'; import * as event from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; -import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatter, StyledBreadcrumbs } from './panel_content_utilities'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { SideEffectContext } from '../side_effect_context'; import { CubeForProcess } from './cube_for_process'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { LimitWarning } from '../limit_warnings'; +import { CrumbInfo } from '../../types'; const StyledLimitWarning = styled(LimitWarning)` flex-flow: row wrap; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx index 3579b1b2f69b8..da4cd3c9dacad 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx @@ -10,58 +10,19 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from ' import styled from 'styled-components'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; -import { - CrumbInfo, - formatDate, - StyledBreadcrumbs, - BoldCode, - StyledTime, -} from './panel_content_utilities'; +import { StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { PanelContentError } from './panel_content_error'; - -/** - * A helper function to turn objects into EuiDescriptionList entries. - * This reflects the strategy of more or less "dumping" metadata for related processes - * in description lists with little/no 'prettification'. This has the obvious drawback of - * data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields - * to the user "as they occur" in ECS, which may help them with e.g. EQL queries. - * - * Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so: - * {title: "a.b", description: "1"}, {title: "c", description: "d"} - * - * @param {object} obj The object to turn into `
` entries - */ -const objectToDescriptionListEntries = function* ( - obj: object, - prefix = '' -): Generator<{ title: string; description: string }> { - const nextPrefix = prefix.length ? `${prefix}.` : ''; - for (const [metaKey, metaValue] of Object.entries(obj)) { - if (typeof metaValue === 'number' || typeof metaValue === 'string') { - yield { title: nextPrefix + metaKey, description: `${metaValue}` }; - } else if (metaValue instanceof Array) { - yield { - title: nextPrefix + metaKey, - description: metaValue - .filter((arrayEntry) => { - return typeof arrayEntry === 'number' || typeof arrayEntry === 'string'; - }) - .join(','), - }; - } else if (typeof metaValue === 'object') { - yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey); - } - } -}; +import { CrumbInfo } from '../../types'; // Adding some styles to prevent horizontal scrollbars, per request from UX review const StyledDescriptionList = memo(styled(EuiDescriptionList)` &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { max-width: 8em; + overflow-wrap: break-word; } &.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description { max-width: calc(100% - 8.5em); @@ -69,6 +30,12 @@ const StyledDescriptionList = memo(styled(EuiDescriptionList)` } `); +// Also prevents horizontal scrollbars on long descriptive names +const StyledDescriptiveName = memo(styled(EuiText)` + padding-right: 1em; + overflow-wrap: break-word; +`); + // Styling subtitles, per UX review: const StyledFlexTitle = memo(styled('h3')` display: flex; @@ -90,6 +57,49 @@ const TitleHr = memo(() => { }); TitleHr.displayName = 'TitleHR'; +const GeneratedText = React.memo(function ({ children }) { + return <>{processedValue()}; + + function processedValue() { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const valueSplitByWordBoundaries = child.split(/\b/); + + if (valueSplitByWordBoundaries.length < 2) { + return valueSplitByWordBoundaries[0]; + } + + return [ + valueSplitByWordBoundaries[0], + ...valueSplitByWordBoundaries + .splice(1) + .reduce(function (generatedTextMemo: Array, value, index) { + return [...generatedTextMemo, value, ]; + }, []), + ]; + } else { + return child; + } + }); + } +}); +GeneratedText.displayName = 'GeneratedText'; + +/** + * Take description list entries and prepare them for display by + * seeding with `` tags. + * + * @param entries {title: string, description: string}[] + */ +function entriesForDisplay(entries: Array<{ title: string; description: string }>) { + return entries.map((entry) => { + return { + description: {entry.description}, + title: {entry.title}, + }; + }); +} + /** * This view presents a detailed view of all the available data for a related event, split and titled by the "section" * it appears in the underlying ResolverEvent @@ -138,60 +148,17 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ } }, [relatedsReady, dispatch, processEntityId]); - const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get( - processEntityId! + const [ + relatedEventToShowDetailsFor, + countBySameCategory, + relatedEventCategory = naString, + sections, + formattedDate, + ] = useSelector(selectors.relatedEventDisplayInfoByEntityAndSelfId)( + processEntityId, + relatedEventId ); - const [relatedEventToShowDetailsFor, countBySameCategory, relatedEventCategory] = useMemo(() => { - if (!relatedEventsForThisProcess) { - return [undefined, 0]; - } - const specificEvent = relatedEventsForThisProcess.events.find( - (evt) => event.eventId(evt) === relatedEventId - ); - // For breadcrumbs: - const specificCategory = specificEvent && event.primaryEventCategory(specificEvent); - const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { - return event.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; - }, 0); - return [specificEvent, countOfCategory, specificCategory || naString]; - }, [relatedEventsForThisProcess, naString, relatedEventId]); - - const [sections, formattedDate] = useMemo(() => { - if (!relatedEventToShowDetailsFor) { - // This could happen if user relaods from URL param and requests an eventId that no longer exists - return [[], naString]; - } - // Assuming these details (agent, ecs, process) aren't as helpful, can revisit - const { - agent, - ecs, - process, - ...relevantData - } = relatedEventToShowDetailsFor as ResolverEvent & { - // Type this with various unknown keys so that ts will let us delete those keys - ecs: unknown; - process: unknown; - }; - let displayDate = ''; - const sectionData: Array<{ - sectionTitle: string; - entries: Array<{ title: string; description: string }>; - }> = Object.entries(relevantData) - .map(([sectionTitle, val]) => { - if (sectionTitle === '@timestamp') { - displayDate = formatDate(val); - return { sectionTitle: '', entries: [] }; - } - if (typeof val !== 'object') { - return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] }; - } - return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] }; - }) - .filter((v) => v.sectionTitle !== '' && v.entries.length); - return [sectionData, displayDate]; - }, [relatedEventToShowDetailsFor, naString]); - const waitCrumbs = useMemo(() => { return [ { @@ -338,15 +305,18 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ - - - + + + + + {sections.map(({ sectionTitle, entries }, index) => { + const displayEntries = entriesForDisplay(entries); return ( {index === 0 ? null : } @@ -364,7 +334,7 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ align="left" titleProps={{ className: 'desc-title' }} compressed - listItems={entries} + listItems={displayEntries} /> {index === sections.length - 1 ? null : } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index aa0851916a7b4..b6c229181e9f7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -7,7 +7,7 @@ import { useCallback, useMemo } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { useQueryStringKeys } from './use_query_string_keys'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { CrumbInfo } from '../types'; export function useResolverQueryParams() { /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index a425f9b49add0..560d4c6928e4e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -71,6 +71,11 @@ type ActionManageTimeline = id: string; payload: string[]; } + | { + type: 'SET_SELECT_ALL'; + id: string; + payload: boolean; + } | { type: 'SET_TIMELINE_ACTIONS'; id: string; @@ -116,6 +121,14 @@ const reducerManageTimeline = ( indexToAdd: action.payload, }, } as ManageTimelineById; + case 'SET_SELECT_ALL': + return { + ...state, + [action.id]: { + ...state[action.id], + selectAll: action.payload, + }, + } as ManageTimelineById; case 'SET_TIMELINE_ACTIONS': return { ...state, @@ -145,6 +158,7 @@ export interface UseTimelineManager { isManagedTimeline: (id: string) => boolean; setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; + setSelectAll: (selectAllArgs: { id: string; selectAll: boolean }) => void; setTimelineRowActions: (actionsArgs: { id: string; queryFields?: string[]; @@ -205,6 +219,14 @@ export const useTimelineManager = ( }); }, []); + const setSelectAll = useCallback(({ id, selectAll }: { id: string; selectAll: boolean }) => { + dispatch({ + type: 'SET_SELECT_ALL', + id, + payload: selectAll, + }); + }, []); + const getTimelineFilterManager = useCallback( (id: string): FilterManager | undefined => state[id]?.filterManager, [state] @@ -238,6 +260,7 @@ export const useTimelineManager = ( isManagedTimeline, setIndexToAdd, setIsTimelineLoading, + setSelectAll, setTimelineRowActions, }; }; @@ -250,6 +273,7 @@ const init = { isManagedTimeline: () => false, setIndexToAdd: () => undefined, setIsTimelineLoading: () => noop, + setSelectAll: () => noop, setTimelineRowActions: () => noop, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts index 6d70795c422d9..609f690903bf2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { assertUnreachable } from '../../../../../../../common/utility_types'; import { Direction } from '../../../../../../graphql/types'; -import { assertUnreachable } from '../../../../../../common/lib/helpers'; import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; import { Sort, SortDirection } from '../../sort'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx index 15fa13b1a08f1..8deda03ece70e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx @@ -169,10 +169,10 @@ const StatefulBodyComponent = React.memo( // Sync to selectAll so parent components can select all events useEffect(() => { - if (selectAll) { + if (selectAll && !isSelectAllChecked) { onSelectAll({ isSelected: true }); } - }, [onSelectAll, selectAll]); + }, [isSelectAllChecked, onSelectAll, selectAll]); const enabledRowRenderers = useMemo(() => { if ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx index 754d7f9c47edf..d48be25b08897 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx @@ -15,8 +15,9 @@ import { DataProvider } from './data_provider'; import { mockDataProviders } from './mock/mock_data_providers'; import { ManageGlobalTimeline, getTimelineDefaults } from '../../manage_timeline'; import { FilterManager } from '../../../../../../../../src/plugins/data/public/query/filter_manager'; -import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; const filterManager = new FilterManager(mockUiSettingsForFilterManager); describe('DataProviders', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx index b788f70cb2e4a..3f371349aa750 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { TestProviders } from '../../../../common/mock/test_providers'; import { DroppableWrapper } from '../../../../common/components/drag_and_drop/droppable_wrapper'; import { FilterManager } from '../../../../../../../../src/plugins/data/public'; @@ -18,7 +18,7 @@ import { DELETE_CLASS_NAME, ENABLE_CLASS_NAME, EXCLUDE_CLASS_NAME } from './prov import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { ManageGlobalTimeline, getTimelineDefaults } from '../../manage_timeline'; -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; describe('Providers', () => { const isLoading: boolean = true; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index e7b0ce7b7428e..329bcf24ba7ed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -7,8 +7,8 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { mockIndexPattern } from '../../../../common/mock'; -import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; import { TestProviders } from '../../../../common/mock/test_providers'; import { FilterManager } from '../../../../../../../../src/plugins/data/public'; import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; @@ -17,7 +17,7 @@ import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { TimelineHeader } from '.'; import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; jest.mock('../../../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx index 75f684c629c70..6c8fd4975c657 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx @@ -7,11 +7,11 @@ import { mount } from 'enzyme'; import React from 'react'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { DEFAULT_FROM, DEFAULT_TO } from '../../../../../common/constants'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { convertKueryToElasticSearchQuery } from '../../../../common/lib/keury'; import { mockIndexPattern, TestProviders } from '../../../../common/mock'; -import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; import { QueryBar } from '../../../../common/components/query_bar'; import { FilterManager } from '../../../../../../../../src/plugins/data/public'; import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; @@ -19,7 +19,7 @@ import { buildGlobalQuery } from '../helpers'; import { QueryBarTimeline, QueryBarTimelineComponentProps, getDataProviderFilter } from './index'; -const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; jest.mock('../../../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts index e1bccbdff4889..7a8750b279b85 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts @@ -15,23 +15,16 @@ import { import { TimelineId } from '../../../../common/types/timeline'; import { mockTimelineModel, createSecuritySolutionStorageMock } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; -import { createUseKibanaMock } from '../../../common/mock/kibana_react'; jest.mock('../../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; describe('SiemLocalStorage', () => { const { localStorage, storage } = createSecuritySolutionStorageMock(); beforeEach(() => { - jest.resetAllMocks(); - useKibanaMock.mockImplementation(() => ({ - services: { - ...createUseKibanaMock()().services, - storage, - }, - })); + useKibanaMock().services.storage = storage; localStorage.clear(); }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index c64ed608339b6..8a5344e0754db 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -10,9 +10,9 @@ import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { AppApolloClient } from '../../../common/lib/lib'; import { inputsModel } from '../../../common/store/inputs'; import { NotesById } from '../../../common/store/app/model'; -import { StartServices } from '../../../types'; import { TimelineModel } from './model'; +import { CoreStart } from '../../../../../../../src/core/public'; export interface AutoSavedWarningMsg { timelineId: string | null; @@ -55,6 +55,6 @@ export interface TimelineEpicDependencies { selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery; selectNotesByIdSelector: (state: State) => NotesById; apolloClient$: Observable; - kibana$: Observable; + kibana$: Observable; storage: Storage; } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 3913b96b3e11a..d2b66207d8602 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -21,9 +21,10 @@ import { } from '../../triggers_actions_ui/public'; import { SecurityPluginSetup } from '../../security/public'; import { AppFrontendLibs } from './common/lib/lib'; +import { ResolverPluginSetup } from './resolver/types'; export interface SetupPlugins { - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; security: SecurityPluginSetup; triggers_actions_ui: TriggersActionsSetup; usageCollection?: UsageCollectionSetup; @@ -46,8 +47,9 @@ export type StartServices = CoreStart & storage: Storage; }; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginSetup {} +export interface PluginSetup { + resolver: () => Promise; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json b/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json index 5c68f8ee0abf2..67aa5768f34cf 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json +++ b/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.json", + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "target": "es2019", "resolveJsonModule": true diff --git a/x-pack/plugins/security_solution/scripts/endpoint/load_trusted_apps.js b/x-pack/plugins/security_solution/scripts/endpoint/load_trusted_apps.js new file mode 100755 index 0000000000000..872639dd9fb7e --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/load_trusted_apps.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../../src/setup_node_env'); +require('./trusted_apps').cli(); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts new file mode 100644 index 0000000000000..3bd27259ad80c --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { v4 as generateUUID } from 'uuid'; +// @ts-ignore +import minimist from 'minimist'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../lists/common/constants'; +import { TRUSTED_APPS_LIST_API } from '../../../common/endpoint/constants'; +import { ExceptionListItemSchema } from '../../../../lists/common/schemas/response'; + +interface RunOptions { + count?: number; +} + +const logger = new ToolingLog({ level: 'info', writeTo: process.stdout }); +const separator = '----------------------------------------'; + +export const cli = async () => { + const options: RunOptions = minimist(process.argv.slice(2), { + default: { + count: 10, + }, + }); + logger.write(`${separator} +Loading ${options.count} Trusted App Entries`); + await run(options); + logger.write(`Done! +${separator}`); +}; + +export const run: (options?: RunOptions) => Promise = async ({ + count = 10, +}: RunOptions = {}) => { + const kbnClient = new KbnClient(logger, { url: 'http://elastic:changeme@localhost:5601' }); + + // touch the Trusted Apps List so it can be created + await kbnClient.request({ + method: 'GET', + path: TRUSTED_APPS_LIST_API, + }); + + return Promise.all( + Array.from({ length: count }, () => { + return kbnClient + .request({ + method: 'POST', + path: '/api/exception_lists/items', + body: generateTrustedAppEntry(), + }) + .then((item) => (item as unknown) as ExceptionListItemSchema); + }) + ); +}; + +interface GenerateTrustedAppEntryOptions { + os?: 'windows' | 'macos' | 'linux'; + name?: string; +} + +const generateTrustedAppEntry: (options?: GenerateTrustedAppEntryOptions) => object = ({ + os = 'windows', + name = `Sample Endpoint Trusted App Entry ${Date.now()}`, +} = {}) => { + return { + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + item_id: `generator_endpoint_trusted_apps_${generateUUID()}`, + _tags: ['endpoint', `os:${os}`], + tags: ['user added string for a tag', 'malware'], + type: 'simple', + description: 'This is a sample agnostic endpoint trusted app entry', + name, + namespace_type: 'agnostic', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'match', + value: 'Elastic, N.V.', + }, + { + field: 'actingProcess.file.path', + operator: 'included', + type: 'match', + value: '/one/two/three', + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 0ec0db9f32776..6a8d56ff41a04 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -12,10 +12,12 @@ import { import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server'; import { getPackagePolicyCreateCallback } from './ingest_integration'; import { ManifestManager } from './services/artifacts'; +import { ExceptionListClient } from '../../../lists/server'; export type EndpointAppContextServiceStartContract = Partial< Pick > & { + exceptionsListService: ExceptionListClient; logger: Logger; manifestManager?: ManifestManager; registerIngestCallback?: IngestManagerStartContract['registerExternalCallback']; @@ -30,9 +32,11 @@ export class EndpointAppContextService { private agentService: AgentService | undefined; private manifestManager: ManifestManager | undefined; private savedObjectsStart: SavedObjectsServiceStart | undefined; + private exceptionsListService: ExceptionListClient | undefined; public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; + this.exceptionsListService = dependencies.exceptionsListService; this.manifestManager = dependencies.manifestManager; this.savedObjectsStart = dependencies.savedObjectsStart; @@ -50,6 +54,13 @@ export class EndpointAppContextService { return this.agentService; } + public getExceptionsList() { + if (!this.exceptionsListService) { + throw new Error('exceptionsListService not set'); + } + return this.exceptionsListService; + } + public getManifestManager(): ManifestManager | undefined { return this.manifestManager; } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index b5f35a198fa9e..03754c7be7a5d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -21,6 +21,7 @@ import { import { ManifestManager } from './services/artifacts/manifest_manager/manifest_manager'; import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { EndpointAppContext } from './types'; +import { listMock } from '../../../lists/server/mocks'; /** * Creates a mocked EndpointAppContext. @@ -58,6 +59,7 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< > => { return { agentService: createMockAgentService(), + exceptionsListService: listMock.getExceptionListClient(), logger: loggingSystemMock.create().get('mock_endpoint_app_context'), savedObjectsStart: savedObjectsServiceMock.createStartContract(), manifestManager: getManifestManagerMock(), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index 5c92b23d594de..3ec968e4a0e1a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -24,7 +24,7 @@ import { handleEntities } from './resolver/entity'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); - router.get( + router.post( { path: '/api/endpoint/resolver/{id}/events', validate: validateEvents, @@ -33,7 +33,7 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleEvents(log, endpointAppContext) ); - router.get( + router.post( { path: '/api/endpoint/resolver/{id}/alerts', validate: validateAlerts, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts index 830d92ef2efc0..8e641194ab899 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts @@ -14,11 +14,16 @@ import { EndpointAppContext } from '../../types'; export function handleAlerts( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf, + TypeOf +> { return async (context, req, res) => { const { params: { id }, query: { alerts, afterAlert, legacyEndpointID: endpointID }, + body, } = req; try { const client = context.core.elasticsearch.legacy.client; @@ -26,7 +31,7 @@ export function handleAlerts( const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ - body: await fetcher.alerts(alerts, afterAlert), + body: await fetcher.alerts(alerts, afterAlert, body?.filter), }); } catch (err) { log.warn(err); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts index 9e5c6be43f728..80d21ae118284 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts @@ -14,11 +14,16 @@ import { EndpointAppContext } from '../../types'; export function handleEvents( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf, + TypeOf +> { return async (context, req, res) => { const { params: { id }, query: { events, afterEvent, legacyEndpointID: endpointID }, + body, } = req; try { const client = context.core.elasticsearch.legacy.client; @@ -26,7 +31,7 @@ export function handleEvents( const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ - body: await fetcher.events(events, afterEvent), + body: await fetcher.events(events, afterEvent, body?.filter), }); } catch (err) { log.warn(err); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts index feb4a404b2359..54c6cf432aa89 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; +import { esKuery } from '../../../../../../../../src/plugins/data/server'; import { ResolverEvent } from '../../../../../common/endpoint/types'; import { ResolverQuery } from './base'; import { PaginationBuilder } from '../utils/pagination'; @@ -13,12 +14,17 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com * Builds a query for retrieving alerts for a node. */ export class AlertsQuery extends ResolverQuery { + private readonly kqlQuery: JsonObject[] = []; constructor( private readonly pagination: PaginationBuilder, indexPattern: string | string[], - endpointID?: string + endpointID?: string, + kql?: string ) { super(indexPattern, endpointID); + if (kql) { + this.kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); + } } protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { @@ -26,6 +32,7 @@ export class AlertsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'endgame.unique_pid': uniquePIDs }, }, @@ -38,7 +45,7 @@ export class AlertsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('endgame.serial_event_id'), + ...this.pagination.buildQueryFields('endgame.serial_event_id', 'asc'), }; } @@ -47,6 +54,7 @@ export class AlertsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'process.entity_id': entityIDs }, }, @@ -56,7 +64,7 @@ export class AlertsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('event.id'), + ...this.pagination.buildQueryFields('event.id', 'asc'), }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts index abc86826e77dd..0969a3c360e4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; +import { esKuery } from '../../../../../../../../src/plugins/data/server'; import { ResolverEvent } from '../../../../../common/endpoint/types'; import { ResolverQuery } from './base'; import { PaginationBuilder } from '../utils/pagination'; @@ -13,12 +14,18 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com * Builds a query for retrieving related events for a node. */ export class EventsQuery extends ResolverQuery { + private readonly kqlQuery: JsonObject[] = []; + constructor( private readonly pagination: PaginationBuilder, indexPattern: string | string[], - endpointID?: string + endpointID?: string, + kql?: string ) { super(indexPattern, endpointID); + if (kql) { + this.kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); + } } protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { @@ -26,6 +33,7 @@ export class EventsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'endgame.unique_pid': uniquePIDs }, }, @@ -45,7 +53,7 @@ export class EventsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('endgame.serial_event_id'), + ...this.pagination.buildQueryFields('endgame.serial_event_id', 'desc'), }; } @@ -54,6 +62,7 @@ export class EventsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'process.entity_id': entityIDs }, }, @@ -70,7 +79,7 @@ export class EventsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('event.id'), + ...this.pagination.buildQueryFields('event.id', 'desc'), }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts index ae17cf4c3a562..efffbc10473d4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts @@ -13,23 +13,35 @@ import { PaginationBuilder } from './pagination'; import { QueryInfo } from '../queries/multi_searcher'; import { SingleQueryHandler } from './fetch'; +/** + * Parameters for RelatedAlertsQueryHandler + */ +export interface RelatedAlertsParams { + limit: number; + entityID: string; + indexPattern: string; + after?: string; + legacyEndpointID?: string; + filter?: string; +} + /** * Requests related alerts for the given node. */ export class RelatedAlertsQueryHandler implements SingleQueryHandler { private relatedAlerts: ResolverRelatedAlerts | undefined; private readonly query: AlertsQuery; - constructor( - private readonly limit: number, - private readonly entityID: string, - after: string | undefined, - indexPattern: string, - legacyEndpointID: string | undefined - ) { + private readonly limit: number; + private readonly entityID: string; + + constructor(options: RelatedAlertsParams) { + this.limit = options.limit; + this.entityID = options.entityID; this.query = new AlertsQuery( - PaginationBuilder.createBuilder(limit, after), - indexPattern, - legacyEndpointID + PaginationBuilder.createBuilder(this.limit, options.after), + options.indexPattern, + options.legacyEndpointID, + options.filter ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts index 849dbc25fe4db..8792f917fb4d6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts @@ -13,23 +13,36 @@ import { PaginationBuilder } from './pagination'; import { QueryInfo } from '../queries/multi_searcher'; import { SingleQueryHandler } from './fetch'; +/** + * Parameters for the RelatedEventsQueryHandler + */ +export interface RelatedEventsParams { + limit: number; + entityID: string; + indexPattern: string; + after?: string; + legacyEndpointID?: string; + filter?: string; +} + /** * This retrieves the related events for the origin node of a resolver tree. */ export class RelatedEventsQueryHandler implements SingleQueryHandler { private relatedEvents: ResolverRelatedEvents | undefined; private readonly query: EventsQuery; - constructor( - private readonly limit: number, - private readonly entityID: string, - after: string | undefined, - indexPattern: string, - legacyEndpointID: string | undefined - ) { + private readonly limit: number; + private readonly entityID: string; + + constructor(options: RelatedEventsParams) { + this.limit = options.limit; + this.entityID = options.entityID; + this.query = new EventsQuery( - PaginationBuilder.createBuilder(limit, after), - indexPattern, - legacyEndpointID + PaginationBuilder.createBuilder(this.limit, options.after), + options.indexPattern, + options.legacyEndpointID, + options.filter ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts index 43c10d552ab4e..1b88f965909eb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts @@ -110,21 +110,21 @@ export class Fetcher { this.endpointID ); - const eventsHandler = new RelatedEventsQueryHandler( - options.events, - this.id, - options.afterEvent, - this.eventsIndexPattern, - this.endpointID - ); + const eventsHandler = new RelatedEventsQueryHandler({ + limit: options.events, + entityID: this.id, + after: options.afterEvent, + indexPattern: this.eventsIndexPattern, + legacyEndpointID: this.endpointID, + }); - const alertsHandler = new RelatedAlertsQueryHandler( - options.alerts, - this.id, - options.afterAlert, - this.alertsIndexPattern, - this.endpointID - ); + const alertsHandler = new RelatedAlertsQueryHandler({ + limit: options.alerts, + entityID: this.id, + after: options.afterAlert, + indexPattern: this.alertsIndexPattern, + legacyEndpointID: this.endpointID, + }); // we need to get the start events first because the API request defines how many nodes to return and we don't want // to count or limit ourselves based on the other lifecycle events (end, etc) @@ -228,17 +228,24 @@ export class Fetcher { /** * Retrieves the related events for the origin node. * - * @param limit the upper bound number of related events to return + * @param limit the upper bound number of related events to return. The limit is applied after the cursor is used to + * skip the previous results. * @param after a cursor to use as the starting point for retrieving related events + * @param filter a kql query for filtering the results */ - public async events(limit: number, after?: string): Promise { - const eventsHandler = new RelatedEventsQueryHandler( + public async events( + limit: number, + after?: string, + filter?: string + ): Promise { + const eventsHandler = new RelatedEventsQueryHandler({ limit, - this.id, + entityID: this.id, after, - this.eventsIndexPattern, - this.endpointID - ); + indexPattern: this.eventsIndexPattern, + legacyEndpointID: this.endpointID, + filter, + }); return eventsHandler.search(this.client); } @@ -246,17 +253,24 @@ export class Fetcher { /** * Retrieves the alerts for the origin node. * - * @param limit the upper bound number of alerts to return + * @param limit the upper bound number of alerts to return. The limit is applied after the cursor is used to + * skip the previous results. * @param after a cursor to use as the starting point for retrieving alerts + * @param filter a kql query string for filtering the results */ - public async alerts(limit: number, after?: string): Promise { - const alertsHandler = new RelatedAlertsQueryHandler( + public async alerts( + limit: number, + after?: string, + filter?: string + ): Promise { + const alertsHandler = new RelatedAlertsQueryHandler({ limit, - this.id, + entityID: this.id, after, - this.alertsIndexPattern, - this.endpointID - ); + indexPattern: this.alertsIndexPattern, + legacyEndpointID: this.endpointID, + filter, + }); return alertsHandler.search(this.client); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts index 4daa45aec2a74..8e567bfb59c65 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts @@ -42,5 +42,19 @@ describe('Pagination', () => { const fields = builder.buildQueryFields(''); expect(fields).not.toHaveProperty('search_after'); }); + + it('creates the sort field in ascending order', () => { + const builder = PaginationBuilder.createBuilder(100); + expect(builder.buildQueryFields('a').sort).toContainEqual({ '@timestamp': 'asc' }); + expect(builder.buildQueryFields('', 'asc').sort).toContainEqual({ '@timestamp': 'asc' }); + }); + + it('creates the sort field in descending order', () => { + const builder = PaginationBuilder.createBuilder(100); + expect(builder.buildQueryFields('a', 'desc').sort).toStrictEqual([ + { '@timestamp': 'desc' }, + { a: 'asc' }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts index f6ff4451b5d8e..4a6c65e55a6b6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts @@ -16,6 +16,11 @@ interface PaginationCursor { eventID: string; } +/** + * The sort direction for the timestamp field + */ +export type TimeSortDirection = 'asc' | 'desc'; + /** * Defines the sorting fields for queries that leverage pagination */ @@ -158,10 +163,14 @@ export class PaginationBuilder { * Helper for creates an object for adding the pagination fields to a query * * @param tiebreaker a unique field to use as the tiebreaker for the search_after + * @param timeSort is the timestamp sort direction * @returns an object containing the pagination information */ - buildQueryFieldsAsInterface(tiebreaker: string): PaginationFields { - const sort: SortFields = [{ '@timestamp': 'asc' }, { [tiebreaker]: 'asc' }]; + buildQueryFieldsAsInterface( + tiebreaker: string, + timeSort: TimeSortDirection = 'asc' + ): PaginationFields { + const sort: SortFields = [{ '@timestamp': timeSort }, { [tiebreaker]: 'asc' }]; let searchAfter: SearchAfterFields | undefined; if (this.timestamp && this.eventID) { searchAfter = [this.timestamp, this.eventID]; @@ -174,11 +183,12 @@ export class PaginationBuilder { * Creates an object for adding the pagination fields to a query * * @param tiebreaker a unique field to use as the tiebreaker for the search_after + * @param timeSort is the timestamp sort direction * @returns an object containing the pagination information */ - buildQueryFields(tiebreaker: string): JsonObject { + buildQueryFields(tiebreaker: string, timeSort: TimeSortDirection = 'asc'): JsonObject { const fields: JsonObject = {}; - const pagination = this.buildQueryFieldsAsInterface(tiebreaker); + const pagination = this.buildQueryFieldsAsInterface(tiebreaker, timeSort); fields.sort = pagination.sort; fields.size = pagination.size; if (pagination.searchAfter) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts new file mode 100644 index 0000000000000..6c29a2244c203 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.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 { RequestHandler } from 'kibana/server'; +import { + GetTrustedAppsListRequest, + GetTrustedListAppsResponse, +} from '../../../../common/endpoint/types'; +import { EndpointAppContext } from '../../types'; +import { exceptionItemToTrustedAppItem } from './utils'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; + +export const getTrustedAppsListRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + + return async (context, req, res) => { + const exceptionsListService = endpointAppContext.service.getExceptionsList(); + const { page, per_page: perPage } = req.query; + + try { + // Ensure list is created if it does not exist + await exceptionsListService?.createTrustedAppsList(); + const results = await exceptionsListService.findExceptionListItem({ + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + page, + perPage, + filter: undefined, + namespaceType: 'agnostic', + sortField: 'name', + sortOrder: 'asc', + }); + const body: GetTrustedListAppsResponse = { + data: results?.data.map(exceptionItemToTrustedAppItem) ?? [], + total: results?.total ?? 0, + page: results?.page ?? 1, + per_page: results?.per_page ?? perPage!, + }; + return res.ok({ body }); + } catch (error) { + logger.error(error); + return res.internalError({ body: error }); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts new file mode 100644 index 0000000000000..178aa06eee877 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.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 { IRouter } from 'kibana/server'; +import { GetTrustedAppsRequestSchema } from '../../../../common/endpoint/schema/trusted_apps'; +import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants'; +import { getTrustedAppsListRouteHandler } from './handlers'; +import { EndpointAppContext } from '../../types'; + +export const registerTrustedAppsRoutes = ( + router: IRouter, + endpointAppContext: EndpointAppContext +) => { + // GET list + router.get( + { + path: TRUSTED_APPS_LIST_API, + validate: GetTrustedAppsRequestSchema, + options: { authRequired: true }, + }, + getTrustedAppsListRouteHandler(endpointAppContext) + ); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts new file mode 100644 index 0000000000000..1d4a7919b89f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContext, + createMockEndpointAppContextServiceStartContract, +} from '../../mocks'; +import { IRouter, RequestHandler } from 'kibana/server'; +import { httpServerMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; +import { registerTrustedAppsRoutes } from './index'; +import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants'; +import { GetTrustedAppsListRequest } from '../../../../common/endpoint/types'; +import { xpackMocks } from '../../../../../../mocks'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; +import { EndpointAppContext } from '../../types'; +import { ExceptionListClient } from '../../../../../lists/server'; + +describe('when invoking endpoint trusted apps route handlers', () => { + let routerMock: jest.Mocked; + let endpointAppContextService: EndpointAppContextService; + let context: ReturnType; + let response: ReturnType; + let exceptionsListClient: jest.Mocked; + let endpointAppContext: EndpointAppContext; + + beforeEach(() => { + routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + const startContract = createMockEndpointAppContextServiceStartContract(); + exceptionsListClient = startContract.exceptionsListService as jest.Mocked; + endpointAppContextService.start(startContract); + endpointAppContext = { + ...createMockEndpointAppContext(), + service: endpointAppContextService, + }; + registerTrustedAppsRoutes(routerMock, endpointAppContext); + + // For use in individual API calls + context = xpackMocks.createRequestHandlerContext(); + response = httpServerMock.createResponseFactory(); + }); + + describe('when fetching list of trusted apps', () => { + let routeHandler: RequestHandler; + const createListRequest = (page: number = 1, perPage: number = 20) => { + return httpServerMock.createKibanaRequest({ + path: TRUSTED_APPS_LIST_API, + method: 'get', + query: { + page, + per_page: perPage, + }, + }); + }; + + beforeEach(() => { + // Get the registered List handler from the IRouter instance + [, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(TRUSTED_APPS_LIST_API) + )!; + }); + + it('should create the Trusted Apps List first', async () => { + const request = createListRequest(); + await routeHandler(context, request, response); + expect(exceptionsListClient.createTrustedAppsList).toHaveBeenCalled(); + expect(response.ok).toHaveBeenCalled(); + }); + + it('should pass pagination query params to exception list service', async () => { + const request = createListRequest(10, 100); + const emptyResponse = { + data: [], + page: 10, + per_page: 100, + total: 0, + }; + + exceptionsListClient.findExceptionListItem.mockResolvedValue(emptyResponse); + await routeHandler(context, request, response); + + expect(response.ok).toHaveBeenCalledWith({ body: emptyResponse }); + expect(exceptionsListClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + page: 10, + perPage: 100, + filter: undefined, + namespaceType: 'agnostic', + sortField: 'name', + sortOrder: 'asc', + }); + }); + + it('should log unexpected error if one occurs', async () => { + exceptionsListClient.findExceptionListItem.mockImplementation(() => { + throw new Error('expected error'); + }); + const request = createListRequest(10, 100); + await routeHandler(context, request, response); + expect(response.internalError).toHaveBeenCalled(); + expect(endpointAppContext.logFactory.get('trusted_apps').error).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts new file mode 100644 index 0000000000000..2b417a4c6a8e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.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 { ExceptionListItemSchema } from '../../../../../lists/common/shared_exports'; +import { TrustedApp } from '../../../../common/endpoint/types'; + +/** + * Map an ExcptionListItem to a TrustedApp item + * @param exceptionListItem + */ +export const exceptionItemToTrustedAppItem = ( + exceptionListItem: ExceptionListItemSchema +): TrustedApp => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { entries, description, created_by, created_at, name, _tags, id } = exceptionListItem; + const os = osFromTagsList(_tags); + return { + entries, + description, + created_at, + created_by, + name, + os, + id, + } as TrustedApp; +}; + +/** + * Retrieves the OS entry from a list of tags (property returned with ExcptionListItem). + * For Trusted Apps each entry must have at MOST 1 OS. + * */ +const osFromTagsList = (tags: string[]): TrustedApp['os'] | 'unknown' => { + for (const tag of tags) { + if (tag.startsWith('os:')) { + return tag.substr(3) as TrustedApp['os']; + } + } + return 'unknown'; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 3eefd3e665cd6..593ada470b118 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -79,6 +79,84 @@ describe('rules_notification_alert_type', () => { ); }); + it('should resolve results_link when meta is undefined to use "/app/security"', async () => { + const ruleAlert = getResult(); + delete ruleAlert.params.meta; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + + it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = {}; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + + it('should resolve results_link to custom kibana link when given one', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = { + kibana_siem_app_url: 'http://localhost', + }; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + 'http://localhost/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + it('should not call alertInstanceFactory if signalsCount was 0', async () => { const ruleAlert = getResult(); alertServices.savedObjectsClient.get.mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index ab824957087fc..0a899562d61c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -14,7 +14,7 @@ import { RuleAlertAttributes } from '../signals/types'; import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; import { scheduleNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; -import { parseScheduleDates } from '../signals/utils'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; export const rulesNotificationAlertType = ({ logger, @@ -64,8 +64,8 @@ export const rulesNotificationAlertType = ({ from: fromInMs, to: toInMs, id: ruleAlertSavedObject.id, - kibanaSiemAppUrl: (ruleAlertParams.meta as { kibana_siem_app_url?: string }) - .kibana_siem_app_url, + kibanaSiemAppUrl: (ruleAlertParams.meta as { kibana_siem_app_url?: string } | undefined) + ?.kibana_siem_app_url, }); logger.info( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index 4636618cc5ac0..06fcba36642ca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -161,6 +161,17 @@ describe('create_rules_bulk', () => { expect(result.ok).toHaveBeenCalled(); }); + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'post', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + test('disallows unknown rule type', async () => { const request = requestMock.create({ method: 'post', @@ -173,5 +184,21 @@ describe('create_rules_bulk', () => { 'Invalid value "unexpected_type" supplied to "type"' ); }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'post', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 59c64fbf8fce1..26febb0999ac7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -164,5 +164,30 @@ describe('create_rules', () => { 'Invalid value "unexpected_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index db32f7f4485b1..c162caa1278e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -183,5 +183,32 @@ describe('patch_rules_bulk', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index d3350bcb0d762..a406de593652b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -18,7 +18,7 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; -import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -156,7 +156,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), rule_id: undefined }, + body: { ...getPatchRulesSchemaMock(), rule_id: undefined }, }); const response = await server.inject(request, context); expect(response.body).toEqual({ @@ -169,7 +169,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'query' }, + body: { ...getPatchRulesSchemaMock(), type: 'query' }, }); const result = server.validate(request); @@ -180,7 +180,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'unknown_type' }, + body: { ...getPatchRulesSchemaMock(), type: 'unknown_type' }, }); const result = server.validate(request); @@ -188,5 +188,30 @@ describe('patch_rules', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getPatchRulesSchemaMock() }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getPatchRulesSchemaMock(), + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 9c5df89a52bed..ec5a2be255a2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -154,5 +154,33 @@ describe('update_rules_bulk', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock(), type: 'query' }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + type: 'query', + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 46fe773e1a88d..fd077c18b7983 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -19,7 +19,7 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { updateRulesRoute } from './update_rules_route'; -import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); @@ -130,7 +130,7 @@ describe('update_rules', () => { method: 'put', path: DETECTION_ENGINE_RULES_URL, body: { - ...getCreateRulesSchemaMock(), + ...getUpdateRulesSchemaMock(), rule_id: undefined, }, }); @@ -145,7 +145,7 @@ describe('update_rules', () => { const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'query' }, + body: { ...getUpdateRulesSchemaMock(), type: 'query' }, }); const result = await server.validate(request); @@ -156,7 +156,7 @@ describe('update_rules', () => { const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'unknown type' }, + body: { ...getUpdateRulesSchemaMock(), type: 'unknown type' }, }); const result = await server.validate(request); @@ -164,5 +164,31 @@ describe('update_rules', () => { 'Invalid value "unknown type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getUpdateRulesSchemaMock(), type: 'query' }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getUpdateRulesSchemaMock(), + type: 'query', + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json index 25274928aa2b7..a8be0fe97524e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies certutil.exe making a network connection. Adversaries could abuse certutil.exe to download a certificate, or malware, from a remote URL.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json index 6be1f037f967e..f2032b5bef218 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json index d5b069f7b81e7..306a38f5d2a28 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Some normal use of this command may originate from server or network administrators engaged in network troubleshooting." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json index b22b74ebc53bc..c80f24a21d958 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries can add the 'hidden' attribute to files to hide them from the user in an attempt to evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json index e2ba81da917b3..4d4f10bbaa599 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to disable the iptables or firewall service in an attempt to affect how a host is allowed to receive or send network traffic.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json index 4f4a9aacd79aa..3c34b04a77a50 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to disable the syslog service in an attempt to an attempt to disrupt event logging and evade detection by security controls.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json index 5bcc4a00ccd82..3cdfac92572b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json index a17fd6d2702dd..2d26d867b8718 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json index cf09bc512916f..60ce575148f4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies attempts to clear Windows event log stores. This is often done by attackers in an attempt to evade detection or destroy forensic evidence on a system.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json index 0c82444dd9397..50213b9f1a42c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the fsutil.exe to delete the volume USNJRNL. This technique is used by attackers to eliminate evidence of files created during post-exploitation activities.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json index c76c5f20fa88b..026735f413eab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the wbadmin.exe to delete the backup catalog. Ransomware and other malware may do this to prevent system recovery.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json index b38ed94e132e1..85d8bdcb2582f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to clear the bash command line history in an attempt to evade detection or forensic investigations.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json index 229a03de39600..d107c0b262091 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies potential attempts to disable Security-Enhanced Linux (SELinux), which is a Linux kernel security feature to support access control policies. Adversaries may disable security tools to avoid possible detection of their tools and activities.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json index 4800e87c180e2..6fbf9ca800f79 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the netsh.exe to disable or weaken the local firewall. Attackers will use this command line tool to disable the firewall during troubleshooting or to enable network mobility.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json index 075dd13d9819b..0d47aab2c64bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies the use of certutil.exe to encode or decode data. CertUtil is a native Windows component which is part of Certificate Services. CertUtil is often abused by attackers to encode or decode base64 data for stealthier command and control or exfiltration.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json index 133863f8e2148..df7fc85b63d4a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. It is quite unusual for this program to be started by an Office application like Word or Excel." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -57,5 +58,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json index 85d348bb14be0..aa4674f75bcd0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json index 38482c0a70fc9..da7d91933bd2a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json index 7db683caf2bb2..8e4f7366a7657 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json index 1c4666955dde0..4f353a6ff9e6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. If a build system triggers this rule it can be exempted by process, user or host name." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json index c375ea7b19b37..5b02f63a1c7f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Malware or other files dropped or created on a system by an adversary may leave traces behind as to what was done within a network and how. Adversaries may remove these files over the course of an intrusion to keep their footprint low or remove them at the end as part of the post-intrusion cleanup process.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json index 22090e1a241e7..8ee2d4fda7bf8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain programs or applications may modify files or change ownership in writable directories. These can be exempted by username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json index 00491937e9aae..f5345b2276e8a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json index 16a398011fc53..e66968a50709e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain tools may create hidden temporary files or directories upon installation or as part of their normal behavior. These events can be filtered by the process arguments, username, or process name values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -55,5 +56,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json index 11781cb719599..ad751a1031437 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json @@ -6,6 +6,7 @@ "false_positives": [ "There is usually no reason to remove modules, but some buggy modules require it. These can be exempted by username. Note that some Linux distributions are not built to support the removal of modules at all." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -57,5 +58,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json index 7d931725fa6eb..5b5f69a0aef74 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application allowlists and signature validation.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json index 1bffe7a1cfc24..6025fc5ca6452 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of bcdedit.exe to delete boot configuration data. This tactic is sometimes used as by malware or an attacker as a destructive technique.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json index f3cc5c2eec8a3..8a504281b03f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of vssadmin.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json index 334276142ca42..2ae938bb34104 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of wmic.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json index 0e4bea426c591..af9c4b5409964 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json @@ -6,6 +6,7 @@ "false_positives": [ "Security tools and device drivers may run these programs in order to enumerate kernel modules. Use of these programs by ordinary users is uncommon. These can be exempted by process name or username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json index 6ac2bbf355961..f1a214b7cd436 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies the SYSTEM account using the Net utility. The Net utility is a component of the Windows operating system. It is used in command line operations for control of users, groups, services, and network connections.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json index e73aa5f4566a7..d913a92e2ee0e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain tools or automated software may enumerate hardware information. These tools can be exempted via user name or process arguments to eliminate potential noise." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json index 0017186787139..a8b34362d9579 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing tools and frameworks may run this command. Some normal use of this command may originate from automation tools and frameworks." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json index 0ba6480fe42a1..46208f3753fa1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json @@ -6,6 +6,7 @@ "false_positives": [ "Administrators may use the command prompt for regular administrative tasks. It's important to baseline your environment for network connections being made from the command prompt to determine any abnormal use of this tool." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json index 2d3edb0f5f6cc..c619d8f764bc4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from PowerShell.exe.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json index 3a4b4915f3c8b..140212e4148eb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from svchost.exe", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json index a2eb76b9831f0..963c6b2e53ed6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Compiled HTML files (.chm) are commonly distributed as part of the Microsoft HTML Help system. Adversaries may conceal malicious code in a CHM file and deliver it to a victim for execution. CHM content is loaded by the HTML Help executable program (hh.exe).", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json index e43ab9de86ef7..7b20cefdc67f0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of sc.exe to create, modify, or start services on remote hosts. This could be indicative of adversary lateral movement but will be noisy if commonly done by admins.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json index 9d480259d49de..629efa90a71ea 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies MsBuild.exe making outbound network connections. This may indicate adversarial activity as MsBuild is often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json index cdef5f16e5cd7..7af823070889f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies mshta.exe making a network connection. This may indicate adversarial activity as mshta.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json index d501bda08c3a5..1dc75575636fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies msxsl.exe making a network connection. This may indicate adversarial activity as msxsl.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json index e82b42869e44d..9b6ee099116f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies when a terminal (tty) is spawned via Perl. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json index e4c84fd3c3b83..f647d8d00e084 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json @@ -6,6 +6,7 @@ "false_positives": [ "PsExec is a dual-use tool that can be used for benign or malicious activity. It's important to baseline your environment to determine the amount of noise to expect from this tool." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json index 3aa9ac20bba9e..d9c26a9c26cc9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies when a terminal (tty) is spawned via Python. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json index 0a1ba97bd01ea..b3b6a2b0c7fab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json index 7305247192f57..6d7f11f01fae0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a PowerShell process launched by either cscript.exe or wscript.exe. Observing Windows scripting processes executing a PowerShell script, may be indicative of malicious activity.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json index 7ff8eb9424d5f..005a0c38c8a8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of frequently targeted Microsoft Office applications (Word, PowerPoint, Excel). These child processes are often launched during exploitation of Office applications or from documents with malicious macros.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json index e923407765f8f..74e21c7d17479 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of Microsoft Outlook. These child processes are often associated with spear phishing activity.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json index 24a744ce30832..adf1a76bfb901 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of PDF reader applications. These child processes are often launched via exploitation of PDF applications or social engineering.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json index 529f2199e46dc..1104159350655 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies unusual instances of rundll32.exe making outbound network connections. This may indicate adversarial activity and may identify malicious DLLs.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json index 69a25b3b24bac..854ecc40d76ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies network activity from unexpected system applications. This may indicate adversarial activity as these applications are often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json index cae5d1b7e0f1f..d9dcbfe25a4c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "RegSvcs.exe and RegAsm.exe are Windows command line utilities that are used to register .NET Component Object Model (COM) assemblies. Adversaries can use RegSvcs.exe and RegAsm.exe to proxy execution of code through a trusted Windows utility.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json index 8a68b26abad20..e4014b22a6c09 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies unexpected processes making network connections over port 445. Windows File Sharing is typically implemented over Server Message Block (SMB), which communicates between hosts using port 445. When legitimate, these network connections are established by the kernel. Processes making 445/tcp connections may be port scanners, exploits, or suspicious user-level processes moving laterally.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json index 2ea75dbd758cb..e4804329c0f30 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json @@ -6,6 +6,7 @@ "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json index 4379759608aba..30312987d166c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json @@ -6,6 +6,7 @@ "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json index 24104439cd0ec..3a5c4d9e69d49 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Normal use of hping is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json index 73bf20a5a175e..63c82c5662df6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Normal use of Iodine is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json index 1895caf4dea81..37d5468c773bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Mknod is a Linux system program. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json index ac46bcbdbc083..bce10f640691b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json @@ -6,6 +6,7 @@ "false_positives": [ "Netcat is a dual-use tool that can be used for benign or malicious activity. Netcat is included in some Linux distributions so its presence is not necessarily suspicious. Some normal use of this program, while uncommon, may originate from scripts, automation tools, and frameworks." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -27,5 +28,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json index 2825dc28ad18f..5d9e338425bda 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing tools and frameworks may run `Nmap` in the course of security auditing. Some normal use of this command may originate from security engineers and network or server administrators. Use of nmap by ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json index 234a09e9607b9..bd019c9a80c4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Some normal use of this command may originate from security engineers and network or server administrators, but this is usually not routine or unannounced. Use of `Nping` by non-engineers or ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json index 759622804444e..f0bbc892d7d9c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json @@ -6,6 +6,7 @@ "false_positives": [ "Build systems, like Jenkins, may start processes in the `/tmp` directory. These can be exempted by name or by username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -22,5 +23,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json index cd38aff3f2164..fac03d31b57bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Socat is a dual-use tool that can be used for benign or malicious activity. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json index 7fcb9f915c560..c1b782d612ccb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Strace is a dual-use tool that can be used for benign or malicious activity. Some normal use of this command may originate from developers or SREs engaged in debugging or system call tracing." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json index 3392a1bff23b8..a4c62b98fb060 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Detects writing executable files that will be automatically launched by Adobe on launch.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json index e76379d171bf7..e3dedeef07eb5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Security tools and device drivers may run these programs in order to load legitimate kernel modules. Use of these programs by ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json index b9e7f941ee5df..8b81789f6aa8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json @@ -6,6 +6,7 @@ "false_positives": [ "Legitimate scheduled tasks may be created during installation of new software." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json index 0cf6fcdb3875a..2aaf0012acabf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json @@ -6,6 +6,7 @@ "false_positives": [ "Network monitoring or management products may have a web server component that runs shell commands as part of normal behavior." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json index 59715dae441f4..32d78480325e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Windows services typically run as SYSTEM and can be used as a privilege escalation opportunity. Malware or penetration testers may run a shell as a service to gain SYSTEM permissions.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json index 7465751d5cd49..3f2e00f0976de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies attempts to create new local users. This is sometimes done by attackers to increase access to a system or domain.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json index 9550eea6ca6aa..bb0856c0452d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "An adversary may add the setgid bit to a file or directory in order to run a file with the privileges of the owning group. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setgid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -52,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json index 343426953add6..4cf60d2c9d0de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "An adversary may add the setuid bit to a file or directory in order to run a file with the privileges of the owning user. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setuid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -52,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json index 44b50c74bafe6..73a804fcbda8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "A sudoers file specifies the commands that users or groups can run and from which terminals. Adversaries can take advantage of these configurations to execute commands as other users or spawn processes with higher privileges.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json index 50692dae3856f..740ff47e5abe5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies User Account Control (UAC) bypass via eventvwr.exe. Attackers bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json index 8f938c0ceee6d..c6c5cbce2c095 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies Windows programs run from unexpected parent processes. This could indicate masquerading or other strange activity on a system.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json new file mode 100644 index 0000000000000..6569a641de3a2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json @@ -0,0 +1,42 @@ +{ + "type": "query", + "index": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "filters": [], + "language": "kuery", + "query": "host.name: *", + "author": [], + "false_positives": [], + "references": [], + "risk_score": 50, + "risk_score_mapping": [], + "severity": "low", + "severity_mapping": [], + "threat": [], + "name": "Host Name Test", + "description": "Host Name Test", + "tags": [], + "license": "", + "interval": "5m", + "from": "now-30s", + "to": "now", + "actions": [ + { + "group": "default", + "id": "4c42ecf2-5e9b-4ce6-8a7a-ab620fd8b169", + "params": { + "body": "{}" + }, + "action_type_id": ".webhook" + } + ], + "enabled": true, + "throttle": "rule" +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts index 67dc1d50eefcd..f77485f39a98d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { assertUnreachable } from '../../../../common/utility_types'; import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter'; import { LanguageOrUndefined, @@ -15,7 +16,6 @@ import { } from '../../../../common/detection_engine/schemas/common/schemas'; import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { AlertServices } from '../../../../../alerts/server'; -import { assertUnreachable } from '../../../utils/build_query'; import { PartialFilter } from '../types'; import { BadRequestError } from '../errors/bad_request_error'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.ts index 0f4b8d1472b3f..8fdbe282eece5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { assertUnreachable } from '../../../../common/utility_types'; import { JobStatus } from '../../../../common/detection_engine/schemas/common/schemas'; -import { assertUnreachable } from '../../../utils/build_query'; import { IRuleStatusAttributes } from '../rules/types'; import { getOrCreateRuleStatuses } from './get_or_create_rule_statuses'; import { RuleStatusSavedObjectsClient } from './rule_status_saved_objects_client'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index b0c855afa8be9..a7213c30eb3fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -17,6 +17,7 @@ import { getExceptions, sortExceptionItems, } from './utils'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { RuleExecutorOptions } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; @@ -36,6 +37,7 @@ jest.mock('./utils'); jest.mock('../notifications/schedule_notification_actions'); jest.mock('./find_ml_signals'); jest.mock('./bulk_create_ml_signals'); +jest.mock('./../../../../common/detection_engine/utils'); const getPayload = (ruleAlert: RuleAlertType, services: AlertServicesMock) => ({ alertId: ruleAlert.id, @@ -227,6 +229,105 @@ describe('rules_notification_alert_type', () => { ); }); + it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = {}; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + payload.params.meta = {}; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + + it('should resolve results_link when meta is undefined use "/app/security"', async () => { + const ruleAlert = getResult(); + delete ruleAlert.params.meta; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + delete payload.params.meta; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + + it('should resolve results_link with a custom link', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost' }; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + payload.params.meta = { kibana_siem_app_url: 'http://localhost' }; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + 'http://localhost/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + describe('ML rule', () => { it('should throw an error if ML plugin was not available', async () => { const ruleAlert = getMlResult(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 0e859ecef31c6..c5124edcaf187 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -14,6 +14,7 @@ import { SERVER_APP_ID, } from '../../../../common/constants'; import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { SetupPlugins } from '../../../plugin'; import { getInputIndex } from './get_input_output_index'; import { @@ -24,7 +25,6 @@ import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns, - parseScheduleDates, getListsClient, getExceptions, getGapMaxCatchupRatio, @@ -356,7 +356,8 @@ export const signalRulesAlertType = ({ from: fromInMs, to: toInMs, id: savedObject.id, - kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string }).kibana_siem_app_url, + kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined) + ?.kibana_siem_app_url, }); logger.info( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 3c41f29625a51..a2e2fec3309c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -13,11 +13,11 @@ import { buildRuleMessageFactory } from './rule_messages'; import { ExceptionListClient } from '../../../../../lists/server'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { generateId, parseInterval, - parseScheduleDates, getDriftTolerance, getGapBetweenRuns, getGapMaxCatchupRatio, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 9519720d0bbec..92cc9be69839f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -14,7 +14,7 @@ import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; import { BulkResponse, BulkResponseErrorAggregation, isValidUnit } from './types'; import { BuildRuleMessage } from './rule_messages'; -import { hasLargeValueList } from '../../../../common/detection_engine/utils'; +import { hasLargeValueList, parseScheduleDates } from '../../../../common/detection_engine/utils'; import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; interface SortExceptionsReturn { @@ -220,18 +220,6 @@ export const parseInterval = (intervalString: string): moment.Duration | null => } }; -export const parseScheduleDates = (time: string): moment.Moment | null => { - const isValidDateString = !isNaN(Date.parse(time)); - const isValidInput = isValidDateString || time.trim().startsWith('now'); - const formattedDate = isValidDateString - ? moment(time) - : isValidInput - ? dateMath.parse(time) - : null; - - return formattedDate ?? null; -}; - export const getDriftTolerance = ({ from, to, diff --git a/x-pack/plugins/security_solution/server/lib/events/query.last_event_time.dsl.ts b/x-pack/plugins/security_solution/server/lib/events/query.last_event_time.dsl.ts index 6c443fed3c99d..02badd3ccee8f 100644 --- a/x-pack/plugins/security_solution/server/lib/events/query.last_event_time.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/events/query.last_event_time.dsl.ts @@ -6,9 +6,9 @@ import { isEmpty } from 'lodash/fp'; +import { assertUnreachable } from '../../../common/utility_types'; import { LastEventTimeRequestOptions } from './types'; import { LastEventIndexKey } from '../../graphql/types'; -import { assertUnreachable } from '../../utils/build_query'; interface EventIndices { [key: string]: string[]; diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts index 013afd5cd58f5..dfe45a00e0513 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts @@ -6,8 +6,9 @@ import { isEmpty } from 'lodash/fp'; +import { assertUnreachable } from '../../../common/utility_types'; import { Direction, HostsFields, HostsSortField } from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; +import { createQueryFilterClauses } from '../../utils/build_query'; import { HostsRequestOptions } from '.'; diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts index 20bc1387a3c4e..e8883111c95f6 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts @@ -6,16 +6,21 @@ import { sortBy } from 'lodash/fp'; -import { formatIndexFields } from './elasticsearch_adapter'; +import { + formatIndexFields, + formatFirstFields, + formatSecondFields, + createFieldItem, +} from './elasticsearch_adapter'; import { mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField } from './mock'; describe('Index Fields', () => { describe('formatIndexFields', () => { - test('Test Basic functionality', async () => { + test('Basic functionality', async () => { expect( sortBy( 'name', - formatIndexFields( + await formatIndexFields( [mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField], ['auditbeat', 'filebeat', 'packetbeat'] ) @@ -130,4 +135,557 @@ describe('Index Fields', () => { ); }); }); + + describe('formatFirstFields', () => { + test('Basic functionality', async () => { + const fields = await formatFirstFields( + [mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField], + ['auditbeat', 'filebeat', 'packetbeat'] + ); + expect(fields).toEqual([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['auditbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['auditbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['filebeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['filebeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['packetbeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + }); + }); + + describe('formatSecondFields', () => { + test('Basic functionality', async () => { + const fields = await formatSecondFields([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['auditbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['filebeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['filebeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['packetbeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + expect(fields).toEqual([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'filebeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'packetbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + }); + }); + + describe('createFieldItem', () => { + test('Basic functionality', () => { + const item = createFieldItem( + ['auditbeat'], + { + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + }, + 0 + ); + expect(item).toEqual({ + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat'], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts index bb0a4b9e2ba9b..777b1cf3bb80d 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts @@ -4,45 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty, get } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { IndexField } from '../../graphql/types'; -import { - baseCategoryFields, - getDocumentation, - getIndexAlias, - hasDocumentation, - IndexAlias, -} from '../../utils/beat_schema'; +import { baseCategoryFields, getDocumentation, hasDocumentation } from '../../utils/beat_schema'; import { FrameworkAdapter, FrameworkRequest } from '../framework'; import { FieldsAdapter, IndexFieldDescriptor } from './types'; export class ElasticsearchIndexFieldAdapter implements FieldsAdapter { constructor(private readonly framework: FrameworkAdapter) {} - public async getIndexFields(request: FrameworkRequest, indices: string[]): Promise { const indexPatternsService = this.framework.getIndexPatternsService(request); - const indexesAliasIndices = indices.reduce>((accumulator, indice) => { - const key = getIndexAlias(indices, indice); - - if (get(key, accumulator)) { - accumulator[key] = [...accumulator[key], indice]; - } else { - accumulator[key] = [indice]; - } - return accumulator; - }, {}); - const responsesIndexFields: IndexFieldDescriptor[][] = await Promise.all( - Object.values(indexesAliasIndices).map((indicesByGroup) => - indexPatternsService.getFieldsForWildcard({ - pattern: indicesByGroup, - }) - ) - ); - return formatIndexFields( - responsesIndexFields, - Object.keys(indexesAliasIndices) as IndexAlias[] + const responsesIndexFields = await Promise.all( + indices.map((index) => { + return indexPatternsService.getFieldsForWildcard({ + pattern: index, + }); + }) ); + return formatIndexFields(responsesIndexFields, indices); } } @@ -63,51 +43,128 @@ const missingFields = [ }, ]; -export const formatIndexFields = ( +/** + * Creates a single field item. + * + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time calling this function repeatedly. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. + * @param indexesAlias The index alias + * @param index The index its self + * @param indexesAliasIdx The index within the alias + */ +export const createFieldItem = ( + indexesAlias: string[], + index: IndexFieldDescriptor, + indexesAliasIdx: number +): IndexField => { + const alias = indexesAlias[indexesAliasIdx]; + const splitName = index.name.split('.'); + const category = baseCategoryFields.includes(splitName[0]) ? 'base' : splitName[0]; + return { + ...(hasDocumentation(alias, index.name) ? getDocumentation(alias, index.name) : {}), + ...index, + category, + indexes: [alias], + }; +}; + +/** + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time when being called. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. The `.push`, and `forEach` operations are expected within this function + * to speed up performance. + * + * This intentionally waits for the next tick on the event loop to process as the large 4.7 megs + * has already consumed a lot of the event loop processing up to this function and we want to give + * I/O opportunity to occur by scheduling this on the next loop. + * @param responsesIndexFields The response index fields to loop over + * @param indexesAlias The index aliases such as filebeat-* + */ +export const formatFirstFields = async ( responsesIndexFields: IndexFieldDescriptor[][], - indexesAlias: IndexAlias[] -): IndexField[] => - responsesIndexFields - .reduce( - (accumulator: IndexField[], indexFields: IndexFieldDescriptor[], indexesAliasIdx: number) => [ - ...accumulator, - ...[...missingFields, ...indexFields].reduce( - (itemAccumulator: IndexField[], index: IndexFieldDescriptor) => { - const alias: IndexAlias = indexesAlias[indexesAliasIdx]; - const splitName = index.name.split('.'); - const category = baseCategoryFields.includes(splitName[0]) ? 'base' : splitName[0]; - return [ - ...itemAccumulator, - { - ...(hasDocumentation(alias, index.name) ? getDocumentation(alias, index.name) : {}), - ...index, - category, - indexes: [alias], - } as IndexField, - ]; + indexesAlias: string[] +): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + responsesIndexFields.reduce( + ( + accumulator: IndexField[], + indexFields: IndexFieldDescriptor[], + indexesAliasIdx: number + ) => { + missingFields.forEach((index) => { + const item = createFieldItem(indexesAlias, index, indexesAliasIdx); + accumulator.push(item); + }); + indexFields.forEach((index) => { + const item = createFieldItem(indexesAlias, index, indexesAliasIdx); + accumulator.push(item); + }); + return accumulator; }, [] - ), - ], - [] - ) - .reduce((accumulator: IndexField[], indexfield: IndexField) => { - const alreadyExistingIndexField = accumulator.findIndex( - (acc) => acc.name === indexfield.name + ) ); - if (alreadyExistingIndexField > -1) { - const existingIndexField = accumulator[alreadyExistingIndexField]; - return [ - ...accumulator.slice(0, alreadyExistingIndexField), - { - ...existingIndexField, - description: isEmpty(existingIndexField.description) - ? indexfield.description - : existingIndexField.description, - indexes: Array.from(new Set([...existingIndexField.indexes, ...indexfield.indexes])), - }, - ...accumulator.slice(alreadyExistingIndexField + 1), - ]; - } - return [...accumulator, indexfield]; - }, []); + }); + }); +}; + +/** + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time when being called. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. The `.push`, and `forEach` operations are expected within this function + * to speed up performance. The "indexFieldNameHash" side effect hash avoids additional expensive n^2 + * look ups. + * + * This intentionally waits for the next tick on the event loop to process as the large 4.7 megs + * has already consumed a lot of the event loop processing up to this function and we want to give + * I/O opportunity to occur by scheduling this on the next loop. + * @param fields The index fields to create the secondary fields for + */ +export const formatSecondFields = async (fields: IndexField[]): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + const indexFieldNameHash: Record = {}; + const reduced = fields.reduce((accumulator: IndexField[], indexfield: IndexField) => { + const alreadyExistingIndexField = indexFieldNameHash[indexfield.name]; + if (alreadyExistingIndexField != null) { + const existingIndexField = accumulator[alreadyExistingIndexField]; + if (isEmpty(accumulator[alreadyExistingIndexField].description)) { + accumulator[alreadyExistingIndexField].description = indexfield.description; + } + accumulator[alreadyExistingIndexField].indexes = Array.from( + new Set([...existingIndexField.indexes, ...indexfield.indexes]) + ); + return accumulator; + } + accumulator.push(indexfield); + indexFieldNameHash[indexfield.name] = accumulator.length - 1; + return accumulator; + }, []); + resolve(reduced); + }); + }); +}; + +/** + * Formats the index fields into a format the UI wants. + * + * NOTE: This will have array sizes up to 4.7 megs in size at a time when being called. + * This function should be as optimized as possible and should avoid any and all creation + * of new arrays, iterating over the arrays or performing any n^2 operations. + * @param responsesIndexFields The response index fields to format + * @param indexesAlias The index alias + */ +export const formatIndexFields = async ( + responsesIndexFields: IndexFieldDescriptor[][], + indexesAlias: string[] +): Promise => { + const fields = await formatFirstFields(responsesIndexFields, indexesAlias); + const secondFields = await formatSecondFields(fields); + return secondFields; +}; diff --git a/x-pack/plugins/security_solution/server/lib/ip_details/query_users.dsl.ts b/x-pack/plugins/security_solution/server/lib/ip_details/query_users.dsl.ts index 10678dc033eb5..293a487777fd2 100644 --- a/x-pack/plugins/security_solution/server/lib/ip_details/query_users.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/ip_details/query_users.dsl.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { assertUnreachable } from '../../../common/utility_types'; import { Direction, UsersFields, UsersSortField } from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; +import { createQueryFilterClauses } from '../../utils/build_query'; import { UsersRequestOptions } from './index'; diff --git a/x-pack/plugins/security_solution/server/lib/network/query_dns.dsl.ts b/x-pack/plugins/security_solution/server/lib/network/query_dns.dsl.ts index e7c86e1d3d66b..90781e7b48b4a 100644 --- a/x-pack/plugins/security_solution/server/lib/network/query_dns.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/network/query_dns.dsl.ts @@ -6,8 +6,9 @@ import { isEmpty } from 'lodash/fp'; +import { assertUnreachable } from '../../../common/utility_types'; import { Direction, NetworkDnsFields, NetworkDnsSortField } from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; +import { createQueryFilterClauses } from '../../utils/build_query'; import { NetworkDnsRequestOptions } from './index'; diff --git a/x-pack/plugins/security_solution/server/lib/network/query_top_countries.dsl.ts b/x-pack/plugins/security_solution/server/lib/network/query_top_countries.dsl.ts index 93ffc35161fa9..be0b8fb64c76a 100644 --- a/x-pack/plugins/security_solution/server/lib/network/query_top_countries.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/network/query_top_countries.dsl.ts @@ -10,8 +10,8 @@ import { NetworkTopTablesSortField, NetworkTopTablesFields, } from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; - +import { createQueryFilterClauses } from '../../utils/build_query'; +import { assertUnreachable } from '../../../common/utility_types'; import { NetworkTopCountriesRequestOptions } from './index'; const getCountAgg = (flowTarget: FlowTargetSourceDest) => ({ diff --git a/x-pack/plugins/security_solution/server/lib/network/query_top_n_flow.dsl.ts b/x-pack/plugins/security_solution/server/lib/network/query_top_n_flow.dsl.ts index 7cb8b76e7b524..14a9c5e33aca0 100644 --- a/x-pack/plugins/security_solution/server/lib/network/query_top_n_flow.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/network/query_top_n_flow.dsl.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { assertUnreachable } from '../../../common/utility_types'; import { Direction, FlowTargetSourceDest, NetworkTopTablesSortField, NetworkTopTablesFields, } from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; +import { createQueryFilterClauses } from '../../utils/build_query'; import { NetworkTopNFlowRequestOptions } from './index'; diff --git a/x-pack/plugins/security_solution/server/lib/tls/query_tls.dsl.ts b/x-pack/plugins/security_solution/server/lib/tls/query_tls.dsl.ts index 82f16ff58d135..f6921ddcdf508 100644 --- a/x-pack/plugins/security_solution/server/lib/tls/query_tls.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/tls/query_tls.dsl.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createQueryFilterClauses, assertUnreachable } from '../../utils/build_query'; +import { assertUnreachable } from '../../../common/utility_types'; +import { createQueryFilterClauses } from '../../utils/build_query'; import { TlsRequestOptions } from './index'; import { TlsSortField, Direction, TlsFields } from '../../graphql/types'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 25ca89ce9186e..38120bf42fbba 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -44,7 +44,6 @@ import { createConfig$, ConfigType } from './config'; import { initUiSettings } from './ui_settings'; import { APP_ID, - APP_ICON, SERVER_APP_ID, SecurityPageName, SIGNALS_ID, @@ -60,6 +59,7 @@ import { EndpointAppContext } from './endpoint/types'; import { registerDownloadExceptionListRoute } from './endpoint/routes/artifacts'; import { initUsageCollectors } from './usage'; import { AppRequestContext } from './types'; +import { registerTrustedAppsRoutes } from './endpoint/routes/trusted_apps'; import { securitySolutionSearchStrategyProvider } from './search_strategy/security_solution'; export interface SetupPlugins { @@ -167,6 +167,7 @@ export class Plugin implements IPlugin { return { lastSeen: sort.direction }; case 'hostName': return { _key: sort.direction }; - default: - return assertUnreachable(sort.field); } }; diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts index 5f002aa7fad7b..29944edf382f4 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts @@ -6,7 +6,7 @@ import { cloneDeep, isArray } from 'lodash/fp'; -import { convertSchemaToAssociativeArray, getIndexSchemaDoc, getIndexAlias } from '.'; +import { convertSchemaToAssociativeArray, getIndexSchemaDoc } from '.'; import { auditbeatSchema, filebeatSchema, packetbeatSchema } from './8.0.0'; import { Schema } from './type'; @@ -394,24 +394,4 @@ describe('Schema Beat', () => { ]); }); }); - - describe('getIndexAlias', () => { - test('getIndexAlias handles values with leading wildcard', () => { - const leadingWildcardIndex = '*-auditbeat-*'; - const result = getIndexAlias([leadingWildcardIndex], leadingWildcardIndex); - expect(result).toBe(leadingWildcardIndex); - }); - - test('getIndexAlias no match returns "unknown" string', () => { - const index = 'auditbeat-*'; - const result = getIndexAlias([index], 'hello'); - expect(result).toBe('unknown'); - }); - - test('empty index should not cause an error to return although it will cause an invalid regular expression to occur', () => { - const index = ''; - const result = getIndexAlias([index], 'hello'); - expect(result).toBe('unknown'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts index 6ec15d328714d..58627a199a181 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts @@ -76,21 +76,6 @@ const convertFieldsToAssociativeArray = ( }, {}) : {}; -export const getIndexAlias = (defaultIndex: string[], indexName: string): string => { - try { - const found = defaultIndex.find((index) => `\\${indexName}`.match(`\\${index}`) != null); - if (found != null) { - return found; - } else { - return 'unknown'; - } - } catch (error) { - // if we encounter an error because the index contains invalid regular expressions then we should return an unknown - // rather than blow up with a toaster error upstream - return 'unknown'; - } -}; - export const getIndexSchemaDoc = memoize((index: string) => { if (index.match('auditbeat') != null) { return { diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts index 2b7be8f4b7539..722589ce7e2bb 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export type IndexAlias = 'auditbeat' | 'filebeat' | 'packetbeat' | 'ecs' | 'winlogbeat' | 'unknown'; - /* * BEAT Interface * diff --git a/x-pack/plugins/security_solution/server/utils/build_query/index.ts b/x-pack/plugins/security_solution/server/utils/build_query/index.ts index 233ba70968fa1..f0f4ba07ab2ae 100644 --- a/x-pack/plugins/security_solution/server/utils/build_query/index.ts +++ b/x-pack/plugins/security_solution/server/utils/build_query/index.ts @@ -9,13 +9,6 @@ export * from './filters'; export * from './merge_fields_with_hits'; export * from './calculate_timeseries_interval'; -export const assertUnreachable = ( - x: unknown, - message: string = 'Unknown Field in switch statement' -): never => { - throw new Error(`${message} ${x}`); -}; - export const inspectStringifyObject = (obj: unknown) => { try { return JSON.stringify(obj, null, 2); diff --git a/x-pack/plugins/security_solution/yarn.lock b/x-pack/plugins/security_solution/yarn.lock deleted file mode 120000 index 6e09764ec763b..0000000000000 --- a/x-pack/plugins/security_solution/yarn.lock +++ /dev/null @@ -1 +0,0 @@ -../../../yarn.lock \ No newline at end of file diff --git a/x-pack/plugins/snapshot_restore/kibana.json b/x-pack/plugins/snapshot_restore/kibana.json index 92f3e27d6d5b8..e0a29581ea076 100644 --- a/x-pack/plugins/snapshot_restore/kibana.json +++ b/x-pack/plugins/snapshot_restore/kibana.json @@ -4,18 +4,19 @@ "server": true, "ui": true, "requiredPlugins": [ - "home", "licensing", "management" ], "optionalPlugins": [ "usageCollection", "security", - "cloud" + "cloud", + "home" ], "configPath": ["xpack", "snapshot_restore"], "requiredBundles": [ "esUiShared", - "kibanaReact" + "kibanaReact", + "home" ] } diff --git a/x-pack/plugins/snapshot_restore/public/plugin.ts b/x-pack/plugins/snapshot_restore/public/plugin.ts index b864e70708652..510fc33dd8024 100644 --- a/x-pack/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/plugins/snapshot_restore/public/plugin.ts @@ -8,6 +8,10 @@ import { CoreSetup, PluginInitializerContext } from 'src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; +import { + FeatureCatalogueCategory, + HomePublicPluginSetup, +} from '../../../../src/plugins/home/public'; import { PLUGIN } from '../common/constants'; import { ClientConfigType } from './types'; @@ -20,6 +24,7 @@ import { UIM_APP_NAME } from './application/constants'; interface PluginsDependencies { usageCollection: UsageCollectionSetup; management: ManagementSetup; + home?: HomePublicPluginSetup; } export class SnapshotRestoreUIPlugin { @@ -33,7 +38,7 @@ export class SnapshotRestoreUIPlugin { public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): void { const config = this.initializerContext.config.get(); const { http } = coreSetup; - const { management, usageCollection } = plugins; + const { home, management, usageCollection } = plugins; // Initialize services this.uiMetricService.setup(usageCollection); @@ -54,6 +59,24 @@ export class SnapshotRestoreUIPlugin { return await mountManagementSection(coreSetup, services, config, params); }, }); + + if (home) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.snapshotRestore.featureCatalogueTitle', { + defaultMessage: 'Back up and restore', + }), + description: i18n.translate('xpack.snapshotRestore.featureCatalogueDescription', { + defaultMessage: + 'Save snapshots to a backup repository, and restore to recover index and cluster state.', + }), + icon: 'storage', + path: '/app/management/data/snapshot_restore', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + order: 630, + }); + } } public start() {} diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts index 30004c739ee7a..aad77f2bbcef9 100644 --- a/x-pack/plugins/spaces/common/model/types.ts +++ b/x-pack/plugins/spaces/common/model/types.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace' | 'findSavedObjects'; +export type GetSpacePurpose = + | 'any' + | 'copySavedObjectsIntoSpace' + | 'findSavedObjects' + | 'shareSavedObjectsIntoSpace'; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx new file mode 100644 index 0000000000000..4e49a2da3e534 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { ReactWrapper } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { CopyModeControl, CopyModeControlProps } from './copy_mode_control'; + +describe('CopyModeControl', () => { + const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values + const updateSelection = jest.fn(); + + const getOverwriteRadio = (wrapper: ReactWrapper) => + wrapper.find('EuiRadioGroup[data-test-subj="cts-copyModeControl-overwriteRadioGroup"]'); + const getOverwriteEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteEnabled"]'); + const getOverwriteDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteDisabled"]'); + const getCreateNewCopiesDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesDisabled"]'); + const getCreateNewCopiesEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesEnabled"]'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: CopyModeControlProps = { initialValues, updateSelection }; + + it('should allow the user to toggle `overwrite`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { createNewCopies } = initialValues; + + getOverwriteDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false }); + + getOverwriteEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true }); + }); + + it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => { + const wrapper = mountWithIntl(); + + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true); + }); + + it('should allow the user to toggle `createNewCopies`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { overwrite } = initialValues; + + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite }); + + getCreateNewCopiesDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite }); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx new file mode 100644 index 0000000000000..42fbf8954396e --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { + EuiFormFieldset, + EuiTitle, + EuiCheckableCard, + EuiRadioGroup, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface CopyModeControlProps { + initialValues: CopyMode; + updateSelection: (result: CopyMode) => void; +} + +export interface CopyMode { + createNewCopies: boolean; + overwrite: boolean; +} + +const createNewCopiesDisabled = { + id: 'createNewCopiesDisabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledTitle', + { defaultMessage: 'Check for existing objects' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledText', + { + defaultMessage: + 'Check if each object was previously copied or imported into the destination space.', + } + ), +}; +const createNewCopiesEnabled = { + id: 'createNewCopiesEnabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledTitle', + { defaultMessage: 'Create new objects with random IDs' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledText', + { defaultMessage: 'All copied objects will be created with new random IDs.' } + ), +}; +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.enabledLabel', + { defaultMessage: 'Automatically try to overwrite conflicts' } + ), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.disabledLabel', + { defaultMessage: 'Request action when conflict occurs' } + ), +}; +const includeRelated = { + id: 'includeRelated', + text: i18n.translate('xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.title', { + defaultMessage: 'Include related saved objects', + }), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.text', + { + defaultMessage: + 'This will copy any other objects this has references to -- for example, a dashboard may have references to multiple visualizations.', + } + ), +}; +const copyOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.copyOptionsTitle', + { defaultMessage: 'Copy options' } +); +const relationshipOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.relationshipOptionsTitle', + { defaultMessage: 'Relationship options' } +); + +const createLabel = ({ text, tooltip }: { text: string; tooltip: string }) => ( + + + {text} + + + + + +); + +export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeControlProps) => { + const [createNewCopies, setCreateNewCopies] = useState(initialValues.createNewCopies); + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.createNewCopies !== undefined) { + setCreateNewCopies(partial.createNewCopies); + } else if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ createNewCopies, overwrite, ...partial }); + }; + + return ( + <> + + {copyOptionsTitle} + + ), + }} + > + onChange({ createNewCopies: false })} + > + onChange({ overwrite: id === overwriteEnabled.id })} + disabled={createNewCopies} + data-test-subj={'cts-copyModeControl-overwriteRadioGroup'} + /> + + + + + onChange({ createNewCopies: true })} + /> + + + + + + {relationshipOptionsTitle} + + ), + }} + > + {}} // noop + disabled + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx index 62f9503443951..158d7a9a43ef6 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiText, EuiIconTip } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ImportRetry } from '../types'; import { SummarizedCopyToSpaceResult, SummarizedSavedObjectResult } from '..'; interface Props { summarizedCopyResult: SummarizedCopyToSpaceResult; object: { type: string; id: string }; - overwritePending: boolean; + pendingObjectRetry?: ImportRetry; conflictResolutionInProgress: boolean; } export const CopyStatusIndicator = (props: Props) => { - const { summarizedCopyResult, conflictResolutionInProgress } = props; + const { summarizedCopyResult, conflictResolutionInProgress, pendingObjectRetry } = props; if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } @@ -25,32 +26,55 @@ export const CopyStatusIndicator = (props: Props) => { const objectResult = summarizedCopyResult.objects.find( (o) => o.type === props.object!.type && o.id === props.object!.id ) as SummarizedSavedObjectResult; + const { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite } = objectResult; + const hasConflicts = conflict && !pendingObjectRetry?.overwrite; + const successful = !hasMissingReferences && !hasUnresolvableErrors && !hasConflicts; - const successful = - !objectResult.hasUnresolvableErrors && - (objectResult.conflicts.length === 0 || props.overwritePending === true); - const successColor = props.overwritePending ? 'warning' : 'success'; - const hasConflicts = objectResult.conflicts.length > 0; - const hasUnresolvableErrors = objectResult.hasUnresolvableErrors; - - if (successful) { - const message = props.overwritePending ? ( + if (successful && !pendingObjectRetry) { + // there is no retry pending, so this object was actually copied + const message = overwrite ? ( + // the object was overwritten ) : ( + // the object was not overwritten ); - return ; + return ; } + + if (successful && pendingObjectRetry) { + const message = overwrite ? ( + // this is an "automatic overwrite", e.g., the "Overwrite all conflicts" option was selected + + ) : pendingObjectRetry?.overwrite ? ( + // this is a manual overwrite, e.g., the individual "Overwrite?" switch was enabled + + ) : ( + // this object is pending success, but it will not result in an overwrite + + ); + return ; + } + if (hasUnresolvableErrors) { return ( { /> ); } + if (hasConflicts) { return ( -

- -

-

- -

- + + + + + } /> ); } - return null; + + return hasMissingReferences ? ( + + ) : conflict ? ( + + ) : ( + + ) + } + /> + ) : null; }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss new file mode 100644 index 0000000000000..d1c3cbbd2b6af --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss @@ -0,0 +1,7 @@ +.spcCopyToSpace__summaryCountBadge { + margin-left: $euiSizeXS; +} + +.spcCopyToSpace__missingReferencesIcon { + margin-left: $euiSizeXS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx index 9d73c216c73ce..4bc7e5cfaf31a 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx @@ -4,30 +4,50 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; +import './copy_status_summary_indicator.scss'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Space } from '../../../common/model/space'; +import { ImportRetry } from '../types'; +import { ResolveAllConflicts } from './resolve_all_conflicts'; import { SummarizedCopyToSpaceResult } from '..'; interface Props { space: Space; summarizedCopyResult: SummarizedCopyToSpaceResult; conflictResolutionInProgress: boolean; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; } -export const CopyStatusSummaryIndicator = (props: Props) => { - const { summarizedCopyResult } = props; - const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${props.space.id}`; +const renderIcon = (props: Props) => { + const { + space, + summarizedCopyResult, + conflictResolutionInProgress, + retries, + onRetriesChange, + onDestinationMapChange, + } = props; + const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${space.id}`; - if (summarizedCopyResult.processing || props.conflictResolutionInProgress) { + if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } - if (summarizedCopyResult.successful) { + const { + successful, + hasUnresolvableErrors, + hasMissingReferences, + hasConflicts, + } = summarizedCopyResult; + + if (successful) { return ( { } /> ); } - if (summarizedCopyResult.hasUnresolvableErrors) { + + if (hasUnresolvableErrors) { return ( { } /> ); } - if (summarizedCopyResult.hasConflicts) { - return ( + + const missingReferences = hasMissingReferences ? ( + } /> + + ) : null; + + if (hasConflicts) { + return ( + + + + } + /> + {missingReferences} + ); } - return null; + + return missingReferences; +}; + +export const CopyStatusSummaryIndicator = (props: Props) => { + const { summarizedCopyResult } = props; + + return ( + + {renderIcon(props)} + + {summarizedCopyResult.objects.length} + + + ); }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index 99b4e184c071a..dfc908d81887a 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -17,6 +17,7 @@ import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; interface SetupOpts { mockSpaces?: Space[]; @@ -73,8 +74,8 @@ const setup = async (opts: SetupOpts = {}) => { name: 'My Viz', }, ], - meta: { icon: 'dashboard', title: 'foo' }, - }; + meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, + } as SavedObjectsManagementRecord; const wrapper = mountWithIntl( { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, }, { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -223,8 +226,12 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + const overwriteSwitch = findTestSubject( + wrapper, + `cts-overwrite-conflict-index-pattern:conflicting-ip` + ); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -282,6 +289,7 @@ describe('CopyToSpaceFlyout', () => { [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], ['space-1', 'space-2'], true, + false, true ); @@ -309,21 +317,45 @@ describe('CopyToSpaceFlyout', () => { mockSpacesManager.copySavedObjects.mockResolvedValue({ 'space-1': { success: true, - successCount: 3, + successCount: 5, }, 'space-2': { success: false, successCount: 1, errors: [ + // regular conflict without destinationId { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, + }, + // regular conflict with destinationId + { + type: 'search', + id: 'conflicting-search', + error: { type: 'conflict', destinationId: 'another-search' }, + meta: {}, + }, + // ambiguous conflict + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + error: { + type: 'ambiguous_conflict', + destinations: [ + { id: 'another-canvas', title: 'foo', updatedAt: undefined }, + { id: 'yet-another-canvas', title: 'bar', updatedAt: undefined }, + ], + }, + meta: {}, }, + // negative test case (skip) { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -358,8 +390,15 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + [ + 'index-pattern:conflicting-ip', + 'search:conflicting-search', + 'canvas-workpad:conflicting-canvas', + ].forEach((id) => { + const overwriteSwitch = findTestSubject(wrapper, `cts-overwrite-conflict-${id}`); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); + }); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -372,16 +411,148 @@ describe('CopyToSpaceFlyout', () => { expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], { - 'space-2': [{ type: 'index-pattern', id: 'conflicting-ip', overwrite: true }], + 'space-1': [], + 'space-2': [ + { type: 'index-pattern', id: 'conflicting-ip', overwrite: true }, + { + type: 'search', + id: 'conflicting-search', + overwrite: true, + destinationId: 'another-search', + }, + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + overwrite: true, + destinationId: 'another-canvas', + }, + ], }, - true + true, + false + ); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + }); + + it('displays a warning when missing references are encountered', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToCopy, + } = await setup(); + + mockSpacesManager.copySavedObjects.mockResolvedValue({ + 'space-1': { + success: false, + successCount: 1, + errors: [ + // my-viz-1 just has a missing_references error + { + type: 'visualization', + id: 'my-viz-1', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + // my-viz-2 has both a missing_references error and a conflict error + { + type: 'visualization', + id: 'my-viz-2', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + { + type: 'visualization', + id: 'my-viz-2', + error: { type: 'conflict' }, + meta: {}, + }, + ], + successResults: [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id, meta: {} }], + }, + }); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1']); + }); + + const startButton = findTestSubject(wrapper, 'cts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1); + + const spaceResult = findTestSubject(wrapper, `cts-space-result-space-1`); + spaceResult.simulate('click'); + + const errorIconTip1 = spaceResult.find( + 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-1"]' + ); + expect(errorIconTip1.props()).toMatchInlineSnapshot(` + Object { + "color": "warning", + "content": , + "data-test-subj": "cts-object-result-missing-references-my-viz-1", + "type": "link", + } + `); + + const myViz2Icon = 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-2"]'; + expect(spaceResult.find(myViz2Icon)).toHaveLength(0); + + // TODO: test for a missing references icon by selecting overwrite for the my-viz-2 conflict + + const finishButton = findTestSubject(wrapper, 'cts-finish-button'); + await act(async () => { + finishButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( + [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], + { + 'space-1': [ + { type: 'dashboard', id: 'my-dash', overwrite: false }, + { + type: 'visualization', + id: 'my-viz-1', + overwrite: false, + ignoreMissingReferences: true, + }, + ], + }, + true, + false ); expect(onClose).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); }); - it('displays an error when missing references are encountered', async () => { + it('displays an error when an unresolvable error is encountered', async () => { const { wrapper, onClose, mockSpacesManager, mockToastNotifications } = await setup(); mockSpacesManager.copySavedObjects.mockResolvedValue({ @@ -396,11 +567,8 @@ describe('CopyToSpaceFlyout', () => { { type: 'visualization', id: 'my-viz', - error: { - type: 'missing_references', - blocking: [], - references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], - }, + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + meta: {}, }, ], }, @@ -441,7 +609,7 @@ describe('CopyToSpaceFlyout', () => { values={Object {}} />, "data-test-subj": "cts-object-result-error-my-viz", - "type": "cross", + "type": "alert", } `); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index 47fc603ee46e8..f9b81be2d6b4b 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -22,17 +22,17 @@ import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + processImportResponse, + SavedObjectsManagementRecord, +} from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; import { CopyOptions, ImportRetry } from '../types'; -import { - ProcessedImportResponse, - processImportResponse, -} from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { onClose: () => void; @@ -41,11 +41,16 @@ interface Props { toastNotifications: ToastsStart; } +const INCLUDE_RELATED_DEFAULT = true; +const CREATE_NEW_COPIES_DEFAULT = false; +const OVERWRITE_ALL_DEFAULT = true; + export const CopySavedObjectsToSpaceFlyout = (props: Props) => { const { onClose, savedObject, spacesManager, toastNotifications } = props; const [copyOptions, setCopyOptions] = useState({ - includeRelated: true, - overwrite: true, + includeRelated: INCLUDE_RELATED_DEFAULT, + createNewCopies: CREATE_NEW_COPIES_DEFAULT, + overwrite: OVERWRITE_ALL_DEFAULT, selectedSpaceIds: [], }); @@ -90,18 +95,48 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, + copyOptions.createNewCopies, copyOptions.overwrite ); const processedResult = mapValues(copySavedObjectsResult, processImportResponse); setCopyResult(processedResult); + + // retry all successful imports + const getAutomaticRetries = (response: ProcessedImportResponse): ImportRetry[] => { + const { failedImports, successfulImports } = response; + if (!failedImports.length) { + // if no imports failed for this space, return an empty array + return []; + } + + // get missing references failures that do not also have a conflict + const nonMissingReferencesFailures = failedImports + .filter(({ error }) => error.type !== 'missing_references') + .reduce((acc, { obj: { type, id } }) => acc.add(`${type}:${id}`), new Set()); + const missingReferencesToRetry = failedImports.filter( + ({ obj: { type, id }, error }) => + error.type === 'missing_references' && + !nonMissingReferencesFailures.has(`${type}:${id}`) + ); + + // otherwise, some imports failed for this space, so retry any successful imports (if any) + return [ + ...successfulImports.map(({ type, id, overwrite, destinationId, createNewCopy }) => { + return { type, id, overwrite: overwrite === true, destinationId, createNewCopy }; + }), + ...missingReferencesToRetry.map(({ obj: { type, id } }) => ({ + type, + id, + overwrite: false, + ignoreMissingReferences: true, + })), + ]; + }; + const automaticRetries = mapValues(processedResult, getAutomaticRetries); + setRetries(automaticRetries); } catch (e) { setCopyInProgress(false); toastNotifications.addError(e, { @@ -113,27 +148,22 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { } async function finishCopy() { - const needsConflictResolution = Object.values(retries).some((spaceRetry) => - spaceRetry.some((retry) => retry.overwrite) - ); + // if any retries are present, attempt to resolve errors again + const needsErrorResolution = Object.values(retries).some((spaceRetry) => spaceRetry.length); - if (needsConflictResolution) { + if (needsErrorResolution) { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], retries, - copyOptions.includeRelated + copyOptions.includeRelated, + copyOptions.createNewCopies ); toastNotifications.addSuccess( i18n.translate('xpack.spaces.management.copyToSpace.resolveCopySuccessTitle', { - defaultMessage: 'Overwrite successful', + defaultMessage: 'Copy successful', }) ); @@ -184,7 +214,12 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { // Step 2: Copy has not been initiated yet; User must fill out form to continue. if (!copyInProgress) { return ( - + ); } @@ -208,14 +243,14 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - +

@@ -247,6 +282,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { copyResult={copyResult} numberOfSelectedSpaces={copyOptions.selectedSpaceIds.length} retries={retries} + onClose={onClose} onCopyStart={startCopy} onCopyFinish={finishCopy} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx index d7ded819771fc..524361bf6ef1d 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx @@ -5,11 +5,18 @@ */ import React, { Fragment } from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiStat, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiHorizontalRule, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { ImportRetry } from '../types'; -import { ProcessedImportResponse } from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { copyInProgress: boolean; @@ -18,33 +25,54 @@ interface Props { copyResult: Record; retries: Record; numberOfSelectedSpaces: number; + onClose: () => void; onCopyStart: () => void; onCopyFinish: () => void; } + +const isResolvableError = ({ error: { type } }: FailedImport) => + ['conflict', 'ambiguous_conflict', 'missing_references'].includes(type); +const isUnresolvableError = (failure: FailedImport) => !isResolvableError(failure); + export const CopyToSpaceFlyoutFooter = (props: Props) => { - const { copyInProgress, initialCopyFinished, copyResult, retries } = props; + const { + copyInProgress, + conflictResolutionInProgress, + initialCopyFinished, + copyResult, + retries, + } = props; let summarizedResults = { successCount: 0, - overwriteConflictCount: 0, - conflictCount: 0, - unresolvableErrorCount: 0, + pendingCount: 0, + skippedCount: 0, + errorCount: 0, }; if (copyResult) { summarizedResults = Object.entries(copyResult).reduce((acc, result) => { const [spaceId, spaceResult] = result; - const overwriteCount = (retries[spaceId] || []).filter((c) => c.overwrite).length; + let successCount = 0; + let pendingCount = 0; + let skippedCount = 0; + let errorCount = 0; + if (spaceResult.status === 'success') { + successCount = spaceResult.importCount; + } else { + const uniqueResolvableErrors = spaceResult.failedImports + .filter(isResolvableError) + .reduce((set, { obj: { type, id } }) => set.add(`${type}:${id}`), new Set()); + pendingCount = (retries[spaceId] || []).length; + skippedCount = + uniqueResolvableErrors.size + spaceResult.successfulImports.length - pendingCount; + errorCount = spaceResult.failedImports.filter(isUnresolvableError).length; + } return { loading: false, - successCount: acc.successCount + spaceResult.importCount, - overwriteConflictCount: acc.overwriteConflictCount + overwriteCount, - conflictCount: - acc.conflictCount + - spaceResult.failedImports.filter((i) => i.error.type === 'conflict').length - - overwriteCount, - unresolvableErrorCount: - acc.unresolvableErrorCount + - spaceResult.failedImports.filter((i) => i.error.type !== 'conflict').length, + successCount: acc.successCount + successCount, + pendingCount: acc.pendingCount + pendingCount, + skippedCount: acc.skippedCount + skippedCount, + errorCount: acc.errorCount + errorCount, }; }, summarizedResults); } @@ -52,13 +80,13 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { const getButton = () => { let actionButton; if (initialCopyFinished) { - const hasPendingOverwrites = summarizedResults.overwriteConflictCount > 0; + const hasPendingRetries = summarizedResults.pendingCount > 0; - const buttonText = hasPendingOverwrites ? ( + const buttonText = hasPendingRetries ? ( ) : ( { actionButton = ( { } return ( - + + + props.onClose()} + data-test-subj="cts-cancel-button" + disabled={ + // Cannot cancel while the operation is in progress, or after some objects have already been created + (copyInProgress && !initialCopyFinished) || + conflictResolutionInProgress || + summarizedResults.successCount > 0 + } + > + + + {actionButton} ); @@ -141,35 +186,33 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { } />
- {summarizedResults.overwriteConflictCount > 0 && ( - - 0 ? 'primary' : 'subdued'} - isLoading={!initialCopyFinished} - textAlign="center" - description={ - - } - /> - - )} 0 ? 'primary' : 'subdued'} + isLoading={!initialCopyFinished} + textAlign="center" + description={ + + } + /> + + + 0 ? 'primary' : 'subdued'} + titleColor={summarizedResults.skippedCount > 0 ? 'primary' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ } @@ -178,9 +221,9 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { 0 ? 'danger' : 'subdued'} + titleColor={summarizedResults.errorCount > 0 ? 'danger' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 0df2a7720e587..fdc8d8c73e324 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -4,78 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import './copy_to_space_form.scss'; import React from 'react'; -import { - EuiSwitch, - EuiSpacer, - EuiHorizontalRule, - EuiFormRow, - EuiListGroup, - EuiListGroupItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CopyOptions } from '../types'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SelectableSpacesControl } from './selectable_spaces_control'; +import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { + savedObject: SavedObjectsManagementRecord; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const setOverwrite = (overwrite: boolean) => props.onUpdate({ ...props.copyOptions, overwrite }); + const { savedObject, spaces, onUpdate, copyOptions } = props; + + // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists + const getDisabledSpaceIds = (createNewCopies: boolean) => + createNewCopies + ? new Set() + : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + + const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { + const disabled = getDisabledSpaceIds(createNewCopies); + const selectedSpaceIds = copyOptions.selectedSpaceIds.filter((x) => !disabled.has(x)); + onUpdate({ ...copyOptions, createNewCopies, overwrite, selectedSpaceIds }); + }; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => - props.onUpdate({ ...props.copyOptions, selectedSpaceIds }); + onUpdate({ ...copyOptions, selectedSpaceIds }); return (
- - - - - } - /> - - - - - - } - checked={props.copyOptions.overwrite} - onChange={(e) => setOverwrite(e.target.checked)} + changeCopyMode(newValues)} /> - + } fullWidth > setSelectedSpaceIds(selection)} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index 255268d388eb8..ceaa1dc9f5e21 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -19,7 +19,7 @@ import { } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { CopyOptions, ImportRetry } from '../types'; -import { SpaceResult } from './space_result'; +import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { @@ -33,6 +33,52 @@ interface Props { copyOptions: CopyOptions; } +const renderCopyOptions = ({ createNewCopies, overwrite, includeRelated }: CopyOptions) => { + const createNewCopiesLabel = createNewCopies ? ( + + ) : ( + + ); + const overwriteLabel = overwrite ? ( + + ) : ( + + ); + const includeRelatedLabel = includeRelated ? ( + + ) : ( + + ); + + return ( + + + {!createNewCopies && ( + + )} + + + ); +}; + export const ProcessingCopyToSpace = (props: Props) => { function updateRetries(spaceId: string, updatedRetries: ImportRetry[]) { props.onRetriesChange({ @@ -43,46 +89,13 @@ export const ProcessingCopyToSpace = (props: Props) => { return (
- - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - + {renderCopyOptions(props.copyOptions)}
@@ -90,22 +103,22 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult( - props.savedObject, - spaceCopyResult, - props.copyOptions.includeRelated - ); + const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); return ( - updateRetries(space.id, retries)} - conflictResolutionInProgress={props.conflictResolutionInProgress} - /> + {summarizedSpaceCopyResult.processing ? ( + + ) : ( + updateRetries(space.id, retries)} + conflictResolutionInProgress={props.conflictResolutionInProgress} + /> + )} ); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss new file mode 100644 index 0000000000000..ce019d17ceaf7 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss @@ -0,0 +1,4 @@ +.spcCopyToSpace__resolveAllConflictsLink { + font-size: $euiFontSizeS; + margin-right: $euiSizeS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx new file mode 100644 index 0000000000000..7da265d8f9958 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { act } from '@testing-library/react'; +import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { ResolveAllConflicts, ResolveAllConflictsProps } from './resolve_all_conflicts'; +import { SummarizedCopyToSpaceResult } from '..'; +import { ImportRetry } from '../types'; +describe('ResolveAllConflicts', () => { + const summarizedCopyResult = ({ + objects: [ + // these objects have minimal attributes to exercise test scenarios; these are not fully realistic results + { type: 'type-1', id: 'id-1', conflict: undefined }, // not a conflict + { type: 'type-2', id: 'id-2', conflict: { error: { type: 'conflict' } } }, // conflict without a destinationId + { + // conflict with a destinationId + type: 'type-3', + id: 'id-3', + conflict: { error: { type: 'conflict', destinationId: 'dest-3' } }, + }, + { + // ambiguous conflict with two destinations + type: 'type-4', + id: 'id-4', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-4a' }, { id: 'dest-4b' }], + }, + }, + }, + { + // ambiguous conflict with two destinations (a retry already exists for dest-5b) + type: 'type-5', + id: 'id-5', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-5a' }, { id: 'dest-5b' }], + }, + }, + }, + ], + } as unknown) as SummarizedCopyToSpaceResult; + const retries: ImportRetry[] = [ + { type: 'type-1', id: 'id-1', overwrite: false }, + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, + ]; + const onRetriesChange = jest.fn(); + const onDestinationMapChange = jest.fn(); + + const getOverwriteOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-overwrite'); + const getSkipOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-skip'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: ResolveAllConflictsProps = { + summarizedCopyResult, + retries, + onRetriesChange, + onDestinationMapChange, + }; + const openPopover = async (wrapper: ReactWrapper) => { + await act(async () => { + wrapper.setState({ isPopoverOpen: true }); + await nextTick(); + wrapper.update(); + }); + }; + + it('should render as expected', async () => { + const wrapper = shallowWithIntl(); + + expect(wrapper).toMatchInlineSnapshot(` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="resolveAllConflictsVisibilityPopover" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + > + + Overwrite all + , + + Skip all + , + ] + } + /> + + `); + }); + + it('should add overwrite retries when "Overwrite all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + + getOverwriteOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, // unchanged + { type: 'type-2', id: 'id-2', overwrite: true }, // added without a destinationId + { type: 'type-3', id: 'id-3', overwrite: true, destinationId: 'dest-3' }, // added with the destinationId + { type: 'type-4', id: 'id-4', overwrite: true, destinationId: 'dest-4a' }, // added with the first destinationId + ]); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + }); + + it('should remove overwrite retries when "Skip all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + + getSkipOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + ]); + expect(onDestinationMapChange).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx new file mode 100644 index 0000000000000..a4ded022debe8 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 './resolve_all_conflicts.scss'; + +import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component } from 'react'; +import { ImportRetry } from '../types'; +import { SummarizedCopyToSpaceResult } from '..'; + +export interface ResolveAllConflictsProps { + summarizedCopyResult: SummarizedCopyToSpaceResult; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; +} + +interface State { + isPopoverOpen: boolean; +} + +interface ResolveOption { + id: 'overwrite' | 'skip'; + text: string; +} + +const options: ResolveOption[] = [ + { + id: 'overwrite', + text: i18n.translate('xpack.spaces.management.copyToSpace.overwriteAllConflictsText', { + defaultMessage: 'Overwrite all', + }), + }, + { + id: 'skip', + text: i18n.translate('xpack.spaces.management.copyToSpace.skipAllConflictsText', { + defaultMessage: 'Skip all', + }), + }, +]; + +export class ResolveAllConflicts extends Component { + public state = { + isPopoverOpen: false, + }; + + public render() { + const button = ( + + + + ); + + const items = options.map((item) => { + return ( + { + this.onSelect(item.id); + }} + > + {item.text} + + ); + }); + + return ( + + + + ); + } + + private onSelect = (selection: ResolveOption['id']) => { + const { summarizedCopyResult, retries, onRetriesChange, onDestinationMapChange } = this.props; + const overwrite = selection === 'overwrite'; + + if (overwrite) { + const existingOverwrites = retries.filter((retry) => retry.overwrite === true); + const newOverwrites = summarizedCopyResult.objects.reduce((acc, { type, id, conflict }) => { + if ( + conflict && + !existingOverwrites.some((retry) => retry.type === type && retry.id === id) + ) { + const { error } = conflict; + // if this is a regular conflict, use its destinationId if it has one; + // otherwise, this is an ambiguous conflict, so use the first destinationId available + const destinationId = + error.type === 'conflict' ? error.destinationId : error.destinations[0].id; + return [...acc, { type, id, overwrite, ...(destinationId && { destinationId }) }]; + } + return acc; + }, new Array()); + onRetriesChange([...retries, ...newOverwrites]); + } else { + const objectsToSkip = summarizedCopyResult.objects.reduce( + (acc, { type, id, conflict }) => (conflict ? acc.add(`${type}:${id}`) : acc), + new Set() + ); + const filtered = retries.filter(({ type, id }) => !objectsToSkip.has(`${type}:${id}`)); + onRetriesChange(filtered); + onDestinationMapChange(undefined); + } + + this.setState({ isPopoverOpen: false }); + }; + + private onButtonClick = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + }; + + private closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx index 9db045f4f068a..2a8b5e660f38c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -5,42 +5,53 @@ */ import './selectable_spaces_control.scss'; -import React, { Fragment, useState } from 'react'; -import { EuiSelectable, EuiLoadingSpinner } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelectable, EuiSelectableOption, EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; import { SpaceAvatar } from '../../space_avatar'; import { Space } from '../../../common/model/space'; interface Props { spaces: Space[]; selectedSpaceIds: string[]; + disabledSpaceIds: Set; onChange: (selectedSpaceIds: string[]) => void; disabled?: boolean; } -interface SpaceOption { - label: string; - prepend?: any; - checked: 'on' | 'off' | null; - ['data-space-id']: string; - disabled?: boolean; -} +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; export const SelectableSpacesControl = (props: Props) => { - const [options, setOptions] = useState([]); - - // TODO: update once https://github.com/elastic/eui/issues/2071 is fixed - if (options.length === 0) { - setOptions( - props.spaces.map((space) => ({ - label: space.name, - prepend: , - checked: props.selectedSpaceIds.includes(space.id) ? 'on' : null, - ['data-space-id']: space.id, - ['data-test-subj']: `cts-space-selector-row-${space.id}`, - })) - ); + if (props.spaces.length === 0) { + return ; } + const disabledIndicator = ( + + } + position="left" + type="iInCircle" + /> + ); + + const options = props.spaces.map((space) => { + const disabled = props.disabledSpaceIds.has(space.id); + return { + label: space.name, + prepend: , + append: disabled ? disabledIndicator : null, + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + disabled, + ['data-space-id']: space.id, + ['data-test-subj']: `cts-space-selector-row-${space.id}`, + }; + }); + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { if (props.disabled) return; @@ -49,17 +60,11 @@ export const SelectableSpacesControl = (props: Props) => { .map((opt) => opt['data-space-id']); props.onChange(selectedSpaceIds); - // TODO: remove once https://github.com/elastic/eui/issues/2071 is fixed - setOptions(selectedOptions); - } - - if (options.length === 0) { - return ; } return ( updateSelectedSpaces(newOptions as SpaceOption[])} listProps={{ bordered: true, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx index f1a8f64a61449..eefd9f8ea2467 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx @@ -5,8 +5,15 @@ */ import './space_result.scss'; -import React from 'react'; -import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiLoadingSpinner, +} from '@elastic/eui'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { SummarizedCopyToSpaceResult } from '../index'; import { SpaceAvatar } from '../../space_avatar'; @@ -24,6 +31,39 @@ interface Props { conflictResolutionInProgress: boolean; } +const getInitialDestinationMap = (objects: SummarizedCopyToSpaceResult['objects']) => + objects.reduce((acc, { type, id, conflict }) => { + if (conflict?.error.type === 'ambiguous_conflict') { + acc.set(`${type}:${id}`, conflict.error.destinations[0].id); + } + return acc; + }, new Map()); + +export const SpaceResultProcessing = (props: Pick) => { + const { space } = props; + return ( + + + + + + {space.name} + + + } + extraAction={} + > + + + + ); +}; + export const SpaceResult = (props: Props) => { const { space, @@ -33,7 +73,12 @@ export const SpaceResult = (props: Props) => { savedObject, conflictResolutionInProgress, } = props; + const { objects } = summarizedCopyResult; const spaceHasPendingOverwrites = retries.some((r) => r.overwrite); + const [destinationMap, setDestinationMap] = useState(getInitialDestinationMap(objects)); + const onDestinationMapChange = (value?: Map) => { + setDestinationMap(value || getInitialDestinationMap(objects)); + }; return ( { extraAction={ @@ -65,6 +113,8 @@ export const SpaceResult = (props: Props) => { space={space} retries={retries} onRetriesChange={onRetriesChange} + destinationMap={destinationMap} + onDestinationMapChange={onDestinationMapChange} conflictResolutionInProgress={conflictResolutionInProgress && spaceHasPendingOverwrites} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss index 7702987220282..bca07da9eae42 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss @@ -11,3 +11,28 @@ // Constrains name to the flex item, and allows for truncation when necessary min-width: 0; } + +.spcCopyToSpaceResultDetails__selectControl { + margin-left: $euiSizeL; +} + +.spcCopyToSpaceResultDetails__selectControl__childWrapper { + // Derived from euiAccordion + visibility: hidden; + opacity: 0; + height: 0; + overflow: hidden; + transform: translatez(0); + // sass-lint:disable-block indentation + transition: + height $euiAnimSpeedNormal $euiAnimSlightResistance, + opacity $euiAnimSpeedNormal $euiAnimSlightResistance; +} + +.spcCopyToSpaceResultDetails__selectControl.spcCopyToSpaceResultDetails__selectControl-isOpen { + .spcCopyToSpaceResultDetails__selectControl__childWrapper { + visibility: visible; + opacity: 1; + height: auto; + } +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx index ef7931260e643..776ed99c41120 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx @@ -5,9 +5,23 @@ */ import './space_result_details.scss'; -import React from 'react'; -import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSwitchEvent, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import moment from 'moment'; import { SummarizedCopyToSpaceResult } from '../index'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; @@ -20,104 +34,161 @@ interface Props { space: Space; retries: ImportRetry[]; onRetriesChange: (retries: ImportRetry[]) => void; + destinationMap: Map; + onDestinationMapChange: (value?: Map) => void; conflictResolutionInProgress: boolean; } -export const SpaceCopyResultDetails = (props: Props) => { - const onOverwriteClick = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); - - props.onRetriesChange([ - ...props.retries.filter((r) => r !== retry), - { - type: object.type, - id: object.id, - overwrite: retry ? !retry.overwrite : true, - }, - ]); - }; - - const hasPendingOverwrite = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); +function getSavedObjectLabel(type: string) { + switch (type) { + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return 'index patterns'; + default: + return type; + } +} - return Boolean(retry && retry.overwrite); - }; +const isAmbiguousConflictError = ( + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError +): error is SavedObjectsImportAmbiguousConflictError => error.type === 'ambiguous_conflict'; - const { objects } = props.summarizedCopyResult; +export const SpaceCopyResultDetails = (props: Props) => { + const { destinationMap, onDestinationMapChange, summarizedCopyResult } = props; + const { objects } = summarizedCopyResult; return (
{objects.map((object, index) => { - const objectOverwritePending = hasPendingOverwrite(object); + const { type, id, name, icon, conflict } = object; + const pendingObjectRetry = props.retries.find((r) => r.type === type && r.id === id); + const isOverwritePending = Boolean(pendingObjectRetry?.overwrite); + const switchProps = { + show: conflict && !props.conflictResolutionInProgress, + label: i18n.translate('xpack.spaces.management.copyToSpace.copyDetail.overwriteSwitch', { + defaultMessage: 'Overwrite?', + }), + onChange: ({ target: { checked } }: EuiSwitchEvent) => { + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const { error } = conflict!; - const showOverwriteButton = - object.conflicts.length > 0 && - !objectOverwritePending && - !props.conflictResolutionInProgress; - - const showSkipButton = - !showOverwriteButton && objectOverwritePending && !props.conflictResolutionInProgress; + if (!checked) { + props.onRetriesChange(filtered); + if (isAmbiguousConflictError(error)) { + // reset the selection to the first entry + const value = error.destinations[0].id; + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + } + } else { + const destinationId = isAmbiguousConflictError(error) + ? destinationMap.get(`${type}:${id}`) + : error.destinationId; + const retry = { type, id, overwrite: true, ...(destinationId && { destinationId }) }; + props.onRetriesChange([...filtered, retry]); + } + }, + }; + const selectProps = { + options: + conflict?.error && isAmbiguousConflictError(conflict.error) + ? conflict.error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ ID: {destination.id} +
+ Last updated: {lastUpdated} +

+
+
+ ), + }; + }) + : [], + onChange: (value: string) => { + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const retry = { type, id, overwrite: true, destinationId: value }; + props.onRetriesChange([...filtered, retry]); + }, + }; + const selectContainerClass = + selectProps.options.length > 0 && isOverwritePending + ? ' spcCopyToSpaceResultDetails__selectControl-isOpen' + : ''; return ( - - - -

- {object.type}: {object.name || object.id} -

-
-
- {showOverwriteButton && ( - - - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-overwrite-conflict-${object.id}`} - > - - - + + + + + + - )} - {showSkipButton && ( - + - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-skip-conflict-${object.id}`} - > - - +

+ {name} +

- )} - -
- + + + )} + +
+ +
+
+ +
+
+
- - +
+ ); })}
diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx index 28b48044a1783..9bbde31ff6fea 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx @@ -21,10 +21,13 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem defaultMessage: 'Copy to space', }), description: i18n.translate('xpack.spaces.management.copyToSpace.actionDescription', { - defaultMessage: 'Copy this saved object to one or more spaces', + defaultMessage: 'Make a copy of this saved object in one or more spaces', }), - icon: 'spacesApp', + icon: 'copy', type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType !== 'agnostic'; + }, onClick: (object: SavedObjectsManagementRecord) => { this.start(object); }, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index a8ecd7c7b9d9f..b8fc89f47a3e0 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -5,50 +5,123 @@ */ import { summarizeCopyResult } from './summarize_copy_result'; -import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + FailedImport, + SavedObjectsManagementRecord, +} from 'src/plugins/saved_objects_management/public'; -const createSavedObjectsManagementRecord = () => ({ - type: 'dashboard', - id: 'foo', - meta: { icon: 'foo-icon', title: 'my-dashboard' }, - references: [ - { - type: 'visualization', - id: 'foo-viz', - name: 'Foo Viz', - }, - { - type: 'visualization', - id: 'bar-viz', - name: 'Bar Viz', - }, - ], -}); +// Sample data references: +// +// /-> Visualization bar -> Index pattern foo +// My dashboard +// \-> Visualization baz -> Index pattern bar +// +// Dashboard has references to visualizations, and transitive references to index patterns + +const OBJECTS = { + MY_DASHBOARD: { + type: 'dashboard', + id: 'foo', + meta: { title: 'my-dashboard-title', icon: 'dashboardApp', namespaceType: 'single' }, + references: [ + { type: 'visualization', id: 'foo', name: 'Visualization foo' }, + { type: 'visualization', id: 'bar', name: 'Visualization bar' }, + ], + } as SavedObjectsManagementRecord, + VISUALIZATION_FOO: { + type: 'visualization', + id: 'bar', + meta: { title: 'visualization-foo-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'foo', name: 'Index pattern foo' }], + } as SavedObjectsManagementRecord, + VISUALIZATION_BAR: { + type: 'visualization', + id: 'baz', + meta: { title: 'visualization-bar-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'bar', name: 'Index pattern bar' }], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_FOO: { + type: 'index-pattern', + id: 'foo', + meta: { title: 'index-pattern-foo-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_BAR: { + type: 'index-pattern', + id: 'bar', + meta: { title: 'index-pattern-bar-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, +}; + +interface ObjectProperties { + type: string; + id: string; + meta: { title?: string; icon?: string }; +} +const createSuccessResult = ({ type, id, meta }: ObjectProperties) => { + return { type, id, meta }; +}; +const createFailureConflict = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { obj: { type, id, meta }, error: { type: 'conflict' } }; +}; +const createFailureMissingReferences = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + error: { type: 'missing_references', references: [] }, + }; +}; +const createFailureUnresolvable = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + // currently, unresolvable errors are 'unsupported_type' and 'unknown'; either would work for this test case + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + }; +}; const createCopyResult = ( - opts: { withConflicts?: boolean; withUnresolvableError?: boolean } = {} + opts: { + withConflicts?: boolean; + withMissingReferencesError?: boolean; + withUnresolvableError?: boolean; + overwrite?: boolean; + } = {} ) => { - const failedImports: ProcessedImportResponse['failedImports'] = []; + let successfulImports: ProcessedImportResponse['successfulImports'] = [ + createSuccessResult(OBJECTS.MY_DASHBOARD), + ]; + let failedImports: ProcessedImportResponse['failedImports'] = []; if (opts.withConflicts) { - failedImports.push( - { - obj: { type: 'visualization', id: 'foo-viz' }, - error: { type: 'conflict' }, - }, - { - obj: { type: 'index-pattern', id: 'transient-index-pattern-conflict' }, - error: { type: 'conflict' }, - } - ); + failedImports.push(createFailureConflict(OBJECTS.VISUALIZATION_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.VISUALIZATION_FOO)); } if (opts.withUnresolvableError) { - failedImports.push({ - obj: { type: 'visualization', id: 'bar-viz' }, - error: { type: 'missing_references', blocking: [], references: [] }, - }); + failedImports.push(createFailureUnresolvable(OBJECTS.INDEX_PATTERN_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.INDEX_PATTERN_FOO)); + } + if (opts.withMissingReferencesError) { + failedImports.push(createFailureMissingReferences(OBJECTS.VISUALIZATION_BAR)); + // INDEX_PATTERN_BAR is not present in the source space, therefore VISUALIZATION_BAR resulted in a missing_references error + } else { + successfulImports.push( + createSuccessResult(OBJECTS.VISUALIZATION_BAR), + createSuccessResult(OBJECTS.INDEX_PATTERN_BAR) + ); + } + + if (opts.overwrite) { + failedImports = failedImports.map(({ obj, error }) => ({ + obj: { ...obj, overwrite: true }, + error, + })); + successfulImports = successfulImports.map((obj) => ({ ...obj, overwrite: true })); } const copyResult: ProcessedImportResponse = { + successfulImports, failedImports, } as ProcessedImportResponse; @@ -57,109 +130,101 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); const copyResult = undefined; - const includeRelated = true; - - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", - "type": "visualization", - }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", - }, ], "processing": true, } `); }); - it('processes failedImports to extract conflicts, including transient conflicts', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const includeRelated = true; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": true, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "foo-viz", - "type": "visualization", + "conflict": Object { + "error": Object { + "type": "conflict", + }, + "obj": Object { + "id": "bar", + "meta": Object { + "icon": "visualizeApp", + "namespaceType": "single", + "title": "visualization-foo-title", }, + "type": "visualization", }, - ], + }, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "transient-index-pattern-conflict", - "type": "index-pattern", - }, - }, - ], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "transient-index-pattern-conflict", - "name": "transient-index-pattern-conflict", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, "type": "index-pattern", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": false, @@ -167,40 +232,54 @@ describe('summarizeCopyResult', () => { `); }); - it('processes failedImports to extract unresolvable errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult({ withUnresolvableError: true }); - const includeRelated = true; + it('processes failedImports to extract missing references errors', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": true, + "hasMissingReferences": true, + "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": true, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], - "hasUnresolvableErrors": true, - "id": "bar-viz", - "name": "Bar Viz", + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, ], @@ -210,75 +289,147 @@ describe('summarizeCopyResult', () => { `); }); - it('processes a result without errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult(); - const includeRelated = true; + it('processes failedImports to extract unresolvable errors', () => { + const copyResult = createCopyResult({ withUnresolvableError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": false, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, ], "processing": false, - "successful": true, + "successful": false, } `); }); - it('does not include references unless requested', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes a result without errors', () => { const copyResult = createCopyResult(); - const includeRelated = false; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, + "type": "visualization", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": true, } `); }); + + it('indicates when successes and failures have been overwritten', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + + expect(summarizedResult.objects).toHaveLength(4); + for (const obj of summarizedResult.objects) { + expect(obj.overwrite).toBe(true); + } + }); }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 518e89df579a6..0c07d1a5da7eb 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -7,19 +7,28 @@ import { SavedObjectsManagementRecord, ProcessedImportResponse, + FailedImport, } from 'src/plugins/saved_objects_management/public'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; export interface SummarizedSavedObjectResult { type: string; id: string; name: string; - conflicts: ProcessedImportResponse['failedImports']; + icon: string; + conflict?: FailedImportConflict; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; + overwrite: boolean; } interface SuccessfulResponse { successful: true; hasConflicts: false; + hasMissingReferences: false; hasUnresolvableErrors: false; objects: SummarizedSavedObjectResult[]; processing: false; @@ -27,6 +36,7 @@ interface SuccessfulResponse { interface UnsuccessfulResponse { successful: false; hasConflicts: boolean; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; objects: SummarizedSavedObjectResult[]; processing: false; @@ -37,6 +47,19 @@ interface ProcessingResponse { processing: true; } +interface FailedImportConflict { + obj: FailedImport['obj']; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError; +} + +const isAnyConflict = (failure: FailedImport): failure is FailedImportConflict => + failure.error.type === 'conflict' || failure.error.type === 'ambiguous_conflict'; +const isMissingReferences = (failure: FailedImport) => failure.error.type === 'missing_references'; +const isUnresolvableError = (failure: FailedImport) => + !isAnyConflict(failure) && !isMissingReferences(failure); +const typeComparator = (a: { type: string }, b: { type: string }) => + a.type > b.type ? 1 : a.type < b.type ? -1 : 0; + export type SummarizedCopyToSpaceResult = | SuccessfulResponse | UnsuccessfulResponse @@ -44,69 +67,61 @@ export type SummarizedCopyToSpaceResult = export function summarizeCopyResult( savedObject: SavedObjectsManagementRecord, - copyResult: ProcessedImportResponse | undefined, - includeRelated: boolean + copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { - const successful = Boolean(copyResult && copyResult.failedImports.length === 0); - - const conflicts = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type === 'conflict') - : []; - - const unresolvableErrors = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type !== 'conflict') - : []; - - const hasConflicts = conflicts.length > 0; - - const hasUnresolvableErrors = Boolean( - copyResult && copyResult.failedImports.some((failed) => failed.error.type !== 'conflict') - ); + const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; + const missingReferences = copyResult?.failedImports.filter(isMissingReferences) ?? []; + const unresolvableErrors = + copyResult?.failedImports.filter((failed) => isUnresolvableError(failed)) ?? []; + const getExtraFields = ({ type, id }: { type: string; id: string }) => { + const conflict = conflicts.find(({ obj }) => obj.type === type && obj.id === id); + const missingReference = missingReferences.find( + ({ obj }) => obj.type === type && obj.id === id + ); + const hasMissingReferences = missingReference !== undefined; + const hasUnresolvableErrors = unresolvableErrors.some( + ({ obj }) => obj.type === type && obj.id === id + ); + const overwrite = conflict + ? false + : missingReference + ? missingReference.obj.overwrite === true + : copyResult?.successfulImports.some( + (obj) => obj.type === type && obj.id === id && obj.overwrite + ) === true; + + return { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite }; + }; - const objectMap = new Map(); + const objectMap = new Map(); objectMap.set(`${savedObject.type}:${savedObject.id}`, { type: savedObject.type, id: savedObject.id, name: savedObject.meta.title, - conflicts: conflicts.filter( - (c) => c.obj.type === savedObject.type && c.obj.id === savedObject.id - ), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === savedObject.type && e.obj.id === savedObject.id - ), + icon: savedObject.meta.icon, + ...getExtraFields(savedObject), }); - if (includeRelated) { - savedObject.references.forEach((ref) => { - objectMap.set(`${ref.type}:${ref.id}`, { - type: ref.type, - id: ref.id, - name: ref.name, - conflicts: conflicts.filter((c) => c.obj.type === ref.type && c.obj.id === ref.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === ref.type && e.obj.id === ref.id - ), - }); - }); - - // The `savedObject.references` array only includes the direct references. It does not include any references of references. - // Therefore, if there are conflicts detected in these transitive references, we need to include them here so that they are visible - // in the UI as resolvable conflicts. - const transitiveConflicts = conflicts.filter( - (c) => !objectMap.has(`${c.obj.type}:${c.obj.id}`) - ); - transitiveConflicts.forEach((conflict) => { - objectMap.set(`${conflict.obj.type}:${conflict.obj.id}`, { - type: conflict.obj.type, - id: conflict.obj.id, - name: conflict.obj.title || conflict.obj.id, - conflicts: conflicts.filter((c) => c.obj.type === conflict.obj.type && conflict.obj.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === conflict.obj.type && e.obj.id === conflict.obj.id - ), + const addObjectsToMap = ( + objects: Array<{ id: string; type: string; meta: { title?: string; icon?: string } }> + ) => { + objects.forEach((obj) => { + const { type, id, meta } = obj; + objectMap.set(`${type}:${id}`, { + type, + id, + name: meta.title || `${type} [id=${id}]`, + icon: meta.icon || 'apps', + ...getExtraFields(obj), }); }); - } + }; + const failedImports = (copyResult?.failedImports ?? []) + .map(({ obj }) => obj) + .sort(typeComparator); + addObjectsToMap(failedImports); + const successfulImports = (copyResult?.successfulImports ?? []).sort(typeComparator); + addObjectsToMap(successfulImports); if (typeof copyResult === 'undefined') { return { @@ -115,20 +130,26 @@ export function summarizeCopyResult( }; } + const successful = Boolean(copyResult && copyResult.failedImports.length === 0); if (successful) { return { successful, hasConflicts: false, objects: Array.from(objectMap.values()), + hasMissingReferences: false, hasUnresolvableErrors: false, processing: false, }; } + const hasConflicts = conflicts.length > 0; + const hasMissingReferences = missingReferences.length > 0; + const hasUnresolvableErrors = unresolvableErrors.length > 0; return { successful, hasConflicts, objects: Array.from(objectMap.values()), + hasMissingReferences, hasUnresolvableErrors, processing: false, }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 9fcc5a89736cc..2310f6c96937c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -8,6 +8,7 @@ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/pu export interface CopyOptions { includeRelated: boolean; + createNewCopies: boolean; overwrite: boolean; selectedSpaceIds: string[]; } diff --git a/x-pack/plugins/spaces/public/create_feature_catalogue_entry.ts b/x-pack/plugins/spaces/public/create_feature_catalogue_entry.ts index 15d141ccc328e..c18a8279ce321 100644 --- a/x-pack/plugins/spaces/public/create_feature_catalogue_entry.ts +++ b/x-pack/plugins/spaces/public/create_feature_catalogue_entry.ts @@ -20,7 +20,7 @@ export const createSpacesFeatureCatalogueEntry = (): FeatureCatalogueEntry => { description: getSpacesFeatureDescription(), icon: 'spacesApp', path: '/app/management/kibana/spaces', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }; }; diff --git a/x-pack/plugins/spaces/public/plugin.test.ts b/x-pack/plugins/spaces/public/plugin.test.ts index d8eecb9c7e606..c1282aef8b343 100644 --- a/x-pack/plugins/spaces/public/plugin.test.ts +++ b/x-pack/plugins/spaces/public/plugin.test.ts @@ -57,7 +57,7 @@ describe('Spaces plugin', () => { category: 'admin', icon: 'spacesApp', id: 'spaces', - showOnHomePage: true, + showOnHomePage: false, }) ); }); diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 8589993a97e02..cd31a4aa17fc3 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -15,6 +15,7 @@ import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry'; import { CopySavedObjectsToSpaceService } from './copy_saved_objects_to_space'; +import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space'; import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; @@ -67,6 +68,12 @@ export class SpacesPlugin implements Plugin void; + disabled?: boolean; +} + +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; + +const activeSpaceProps = { + append: Current, + disabled: true, + checked: 'on' as 'on', +}; + +export const SelectableSpacesControl = (props: Props) => { + if (props.spaces.length === 0) { + return ; + } + + const options = props.spaces + .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .map((space) => ({ + label: space.name, + prepend: , + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + ['data-space-id']: space.id, + ['data-test-subj']: `sts-space-selector-row-${space.id}`, + ...(space.isActiveSpace ? activeSpaceProps : {}), + })); + + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { + if (props.disabled) return; + + const selectedSpaceIds = selectedOptions + .filter((opt) => opt.checked && !opt.disabled) + .map((opt) => opt['data-space-id']); + + props.onChange(selectedSpaceIds); + } + + return ( + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: 40, + className: 'spcShareToSpace__spacesList', + 'data-test-subj': 'sts-form-space-selector', + }} + searchable + > + {(list, search) => { + return ( + + {search} + {list} + + ); + }} + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx new file mode 100644 index 0000000000000..c17a2dcb1a831 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 Boom from 'boom'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import { Space } from '../../../common/model/space'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { SpacesManager } from '../../spaces_manager'; +import { ToastsApi } from 'src/core/public'; +import { EuiCallOut } from '@elastic/eui'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onObjectUpdated = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + const mockToastNotifications = { + addError: jest.fn(), + addSuccess: jest.fn(), + }; + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + references: [ + { + type: 'visualization', + id: 'my-viz', + name: 'My Viz', + }, + ], + meta: { icon: 'dashboard', title: 'foo' }, + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + } as SavedObjectsManagementRecord; + + const wrapper = mountWithIntl( + + ); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + }); + + it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ mockSpaces: [] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-button'); // this button is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx new file mode 100644 index 0000000000000..10cc5777cdcff --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { Space } from '../../../common/model/space'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions, SpaceTarget } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; + +interface Props { + onClose: () => void; + onObjectUpdated: () => void; + savedObject: SavedObjectsManagementRecord; + spacesManager: SpacesManager; + toastNotifications: ToastsStart; +} + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { + const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { namespaces: currentNamespaces = [] } = savedObject; + const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: SpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace'); + const getActiveSpace = spacesManager.getActiveSpace(); + Promise.all([getSpaces, getActiveSpace]) + .then(([allSpaces, activeSpace]) => { + const createSpaceTarget = (space: Space): SpaceTarget => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + }); + setSpacesState({ + isLoading: false, + spaces: allSpaces.map((space) => createSpaceTarget(space)), + }); + setShareOptions({ + selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [currentNamespaces, spacesManager, toastNotifications]); + + const getSelectionChanges = () => { + const activeSpace = spaces.find((space) => space.isActiveSpace); + if (!activeSpace) { + return { changed: false, spacesToAdd: [], spacesToRemove: [] }; + } + const initialSelection = currentNamespaces.filter( + (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' + ); + const { selectedSpaceIds } = shareOptions; + const changed = !arraysAreEqual(initialSelection, selectedSpaceIds); + const spacesToAdd = selectedSpaceIds.filter((spaceId) => !initialSelection.includes(spaceId)); + const spacesToRemove = initialSelection.filter( + (spaceId) => !selectedSpaceIds.includes(spaceId) + ); + return { changed, spacesToAdd, spacesToRemove }; + }; + const { changed: isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + const { type, id, meta } = savedObject; + const title = + currentNamespaces.length === 1 + ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { + defaultMessage: 'Saved Object is now shared!', + }) + : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { + defaultMessage: 'Saved Object updated', + }); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceNames = spacesToAdd.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessText', { + defaultMessage: `'{object}' was added to the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const spaceNames = spacesToRemove.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessText', { + defaultMessage: `'{object}' was removed from the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + onObjectUpdated(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { + defaultMessage: 'Error updating saved object', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // Step 1a: assets loaded, but no spaces are available for share. + // The `spaces` array includes the current space, so at minimum it will have a length of 1. + if (spaces.length < 2) { + return ( + + +

+ } + title={ +

+ +

+ } + /> + ); + } + + const showShareWarning = currentNamespaces.length === 1; + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + return ( + + + + + + + + +

+ +

+
+
+
+
+ + + + + + + +

{savedObject.meta.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={!isSelectionChanged || shareInProgress} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss similarity index 74% rename from x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss index 87af5d83629a9..41a9c907de745 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss @@ -1,10 +1,10 @@ // make icon occupy the same space as an EuiSwitch // icon is size m, which is the native $euiSize value // see @elastic/eui/src/components/icon/_variables.scss -.spcCopyToSpaceIncludeRelated .euiIcon { +.spcShareToSpaceIncludeRelated .euiIcon { margin-right: $euiSwitchWidth - $euiSize; } -.spcCopyToSpaceIncludeRelated__label { +.spcShareToSpaceIncludeRelated__label { font-size: $euiFontSizeS; } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx new file mode 100644 index 0000000000000..24402fec8d771 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './share_to_space_form.scss'; +import React, { Fragment } from 'react'; +import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ShareOptions, SpaceTarget } from '../types'; +import { SelectableSpacesControl } from './selectable_spaces_control'; + +interface Props { + spaces: SpaceTarget[]; + onUpdate: (shareOptions: ShareOptions) => void; + shareOptions: ShareOptions; + showShareWarning: boolean; + makeCopy: () => void; +} + +export const ShareToSpaceForm = (props: Props) => { + const setSelectedSpaceIds = (selectedSpaceIds: string[]) => + props.onUpdate({ ...props.shareOptions, selectedSpaceIds }); + + const getShareWarning = () => { + if (!props.showShareWarning) { + return null; + } + + return ( + + + } + color="warning" + > + + + props.makeCopy()} + color="warning" + data-test-subj="sts-copy-button" + size="s" + > + + + + + + + ); + }; + + return ( +
+ {getShareWarning()} + + + } + labelAppend={ + + } + fullWidth + > + setSelectedSpaceIds(selection)} + /> + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts new file mode 100644 index 0000000000000..037fcb684b47d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx new file mode 100644 index 0000000000000..ba9a6473999df --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'src/core/public'; +import { + SavedObjectsManagementAction, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { ShareSavedObjectsToSpaceFlyout } from './components'; +import { SpacesManager } from '../spaces_manager'; + +export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { + public id: string = 'share_saved_objects_to_space'; + + public euiAction = { + name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + defaultMessage: 'Share to space', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + defaultMessage: 'Share this saved object to one or more spaces', + }), + icon: 'share', + type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType === 'multiple'; + }, + onClick: (object: SavedObjectsManagementRecord) => { + this.isDataChanged = false; + this.start(object); + }, + }; + public refreshOnFinish = () => this.isDataChanged; + + private isDataChanged: boolean = false; + + constructor( + private readonly spacesManager: SpacesManager, + private readonly notifications: NotificationsStart + ) { + super(); + } + + public render = () => { + if (!this.record) { + throw new Error('No record available! `render()` was likely called before `start()`.'); + } + + return ( + (this.isDataChanged = true)} + savedObject={this.record} + spacesManager={this.spacesManager} + toastNotifications={this.notifications.toasts} + /> + ); + }; + + private onClose = () => { + this.finish(); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx new file mode 100644 index 0000000000000..e8649faa120be --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { SpaceTarget } from './types'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceColor } from '..'; + +const SPACES_DISPLAY_COUNT = 5; + +type SpaceMap = Map; +interface ColumnDataProps { + namespaces?: string[]; + data?: SpaceMap; +} + +const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!data) { + return null; + } + + const authorized = namespaces?.filter((namespace) => namespace !== '?') ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = data.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + authorizedSpaceTargets.push({ + id: namespace, + name: namespace, + disabledFeatures: [], + isActiveSpace: false, + }); + } else if (!spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === '?') ?? []).length; + const unauthorizedTooltip = i18n.translate( + 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', + { defaultMessage: 'You do not have permission to view these spaces' } + ); + + const displayedSpaces = isExpanded + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); + const showButton = authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT; + + const unauthorizedCountBadge = + (isExpanded || !showButton) && unauthorizedCount > 0 ? ( + + + +{unauthorizedCount} + + + ) : null; + + let button: ReactNode = null; + if (showButton) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + + return ( + + {displayedSpaces.map(({ id, name, color }) => ( + + {name} + + ))} + {unauthorizedCountBadge} + {button} + + ); +}; + +export class ShareToSpaceSavedObjectsManagementColumn + implements SavedObjectsManagementColumn { + public id: string = 'share_saved_objects_to_space'; + public data: Map | undefined; + + public euiColumn = { + field: 'namespaces', + name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + defaultMessage: 'Shared spaces', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + defaultMessage: 'The other spaces that this object is currently shared to', + }), + render: (namespaces: string[] | undefined, _object: SavedObjectsManagementRecord) => ( + + ), + }; + + constructor(private readonly spacesManager: SpacesManager) {} + + public loadData = () => { + this.data = undefined; + return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( + ([spaces, activeSpace]) => { + this.data = spaces + .map((space) => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + color: getSpaceColor(space), + })) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + return this.data; + } + ); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts new file mode 100644 index 0000000000000..0f0fa7d22214f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.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 { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +// import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ShareSavedObjectsToSpaceService } from '.'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; + +describe('ShareSavedObjectsToSpaceService', () => { + describe('#setup', () => { + it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { + const deps = { + spacesManager: spacesManagerMock.create(), + notificationsSetup: notificationServiceMock.createSetupContract(), + savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), + }; + + const service = new ShareSavedObjectsToSpaceService(); + service.setup(deps); + + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledTimes(1); + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledWith( + expect.any(ShareToSpaceSavedObjectsManagementAction) + ); + + // expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledTimes(1); + // expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledWith( + // expect.any(ShareToSpaceSavedObjectsManagementColumn) + // ); + expect(deps.savedObjectsManagementSetup.columns.register).not.toHaveBeenCalled(); // ensure this test fails after column code is uncommented + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts new file mode 100644 index 0000000000000..9f6e57c355380 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { NotificationsSetup } from 'src/core/public'; +import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +// import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { SpacesManager } from '../spaces_manager'; + +interface SetupDeps { + spacesManager: SpacesManager; + savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; + notificationsSetup: NotificationsSetup; +} + +export class ShareSavedObjectsToSpaceService { + public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); + savedObjectsManagementSetup.actions.register(action); + // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // savedObjectsManagementSetup.columns.register(column); + } +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts new file mode 100644 index 0000000000000..fe41f4a5fadc8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.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 { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; +import { Space } from '..'; + +export interface ShareOptions { + selectedSpaceIds: string[]; +} + +export type ImportRetry = Omit; + +export interface ShareSavedObjectsToSpaceResponse { + [spaceId: string]: SavedObjectsImportResponse; +} + +export interface SpaceTarget extends Space { + isActiveSpace: boolean; +} diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts index 6186ac7fd93be..f666c823bd365 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts @@ -18,6 +18,8 @@ function createSpacesManagerMock() { updateSpace: jest.fn().mockResolvedValue(undefined), deleteSpace: jest.fn().mockResolvedValue(undefined), copySavedObjects: jest.fn().mockResolvedValue(undefined), + shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), + shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), } as unknown) as jest.Mocked; diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index ac5cb56084cfc..2daf9ab420efc 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -11,6 +11,8 @@ import { Space } from '../../common/model/space'; import { GetSpacePurpose } from '../../common/model/types'; import { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; +type SavedObject = Pick; + export class SpacesManager { private activeSpace$: BehaviorSubject = new BehaviorSubject(null); @@ -72,9 +74,10 @@ export class SpacesManager { } public async copySavedObjects( - objects: Array>, + objects: SavedObject[], spaces: string[], includeReferences: boolean, + createNewCopies: boolean, overwrite: boolean ): Promise { return this.http.post('/api/spaces/_copy_saved_objects', { @@ -82,25 +85,39 @@ export class SpacesManager { objects, spaces, includeReferences, - overwrite, + ...(createNewCopies ? { createNewCopies } : { overwrite }), }), }); } public async resolveCopySavedObjectsErrors( - objects: Array>, + objects: SavedObject[], retries: unknown, - includeReferences: boolean + includeReferences: boolean, + createNewCopies: boolean ): Promise { return this.http.post(`/api/spaces/_resolve_copy_saved_objects_errors`, { body: JSON.stringify({ objects, includeReferences, + createNewCopies, retries, }), }); } + public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_add`, { + body: JSON.stringify({ object, spaces }), + }); + } + + public async shareSavedObjectRemove(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_remove`, { + body: JSON.stringify({ object, spaces }), + }); + } + public redirectToSpaceSelector() { window.location.href = `${this.serverBasePath}/spaces/space_selector`; } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index 9679dd8c52523..d49dfa2015dc6 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -3,14 +3,20 @@ * 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 { SavedObjectsImportResponse, SavedObjectsImportOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; -import { Readable } from 'stream'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; jest.mock('../../../../../../src/core/server', () => { return { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,29 @@ const expectStreamToContainObjects = async ( }; describe('copySavedObjectsToSpaces', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + { type: 'globaltype', id: 'my-globaltype', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +88,12 @@ describe('copySavedObjectsToSpaces', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,10 +113,15 @@ describe('copySavedObjectsToSpaces', () => { (importSavedObjectsFromStream as jest.Mock).mockImplementation( async (opts: SavedObjectsImportOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return Promise.resolve(response); @@ -115,261 +133,95 @@ describe('copySavedObjectsToSpaces', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of imports', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await copySavedObjectsToSpaces('sourceSpace', ['destination1', 'destination2'], { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const result = await copySavedObjectsToSpaces(namespace, ['destination1', 'destination2'], { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } `); - expect((importSavedObjectsFromStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + overwrite: true, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + }); + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + }); }); it(`doesn't stop copy if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, importSavedObjectsFromStreamImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -378,7 +230,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -388,58 +240,44 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: false, } ); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "non-existent-space": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], - exportSavedObjectsToStreamImpl: (opts) => { + objects: mockExportResults, + exportSavedObjectsToStreamImpl: (_opts) => { return Promise.resolve( new Readable({ objectMode: true, @@ -455,7 +293,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -466,12 +304,8 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: false, } ) ).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts index dca6f2a6206ab..5575052d7bbb8 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts @@ -12,11 +12,11 @@ import { } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function copySavedObjectsToSpacesFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,6 @@ export function copySavedObjectsToSpacesFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -56,13 +54,15 @@ export function copySavedObjectsToSpacesFactory( objectLimit: getImportExportObjectLimit(), overwrite: options.overwrite, savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, + createNewCopies: options.createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -78,11 +78,15 @@ export function copySavedObjectsToSpacesFactory( const response: CopyResponse = {}; const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, options); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const spaceId of destinationSpaceIds) { response[spaceId] = await importObjectsToSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), + createReadableStreamFromArray(filteredObjects), options ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts deleted file mode 100644 index e5f2c5b18bd00..0000000000000 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.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 { SavedObjectTypeRegistry } from 'src/core/server'; - -export function getEligibleTypes( - typeRegistry: Pick -) { - return typeRegistry - .getAllTypes() - .filter((type) => !typeRegistry.isNamespaceAgnostic(type.name)) - .map((type) => type.name); -} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts new file mode 100644 index 0000000000000..91d4cb13b98eb --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.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 { SavedObjectTypeRegistry } from 'src/core/server'; + +/** + * This function returns any importable/exportable saved object types that are namespace-agnostic. Even if these are eligible for + * import/export, we should not include them in the copy operation because it will result in a conflict that needs to overwrite itself to be + * resolved. + */ +export function getIneligibleTypes( + typeRegistry: Pick< + SavedObjectTypeRegistry, + 'getImportableAndExportableTypes' | 'isNamespaceAgnostic' + > +) { + return typeRegistry + .getImportableAndExportableTypes() + .filter((type) => typeRegistry.isNamespaceAgnostic(type.name)) + .map((type) => type.name); +} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index 7bb4c61ed51a0..6a77bf7397cb5 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -3,13 +3,19 @@ * 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 { SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; -import { Readable } from 'stream'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; jest.mock('../../../../../../src/core/server', () => { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,28 @@ const expectStreamToContainObjects = async ( }; describe('resolveCopySavedObjectsToSpacesConflicts', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +87,12 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,11 +112,16 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { (resolveSavedObjectsImportErrors as jest.Mock).mockImplementation( async (opts: SavedObjectsResolveImportErrorsOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return response; @@ -116,290 +133,100 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of conflict resolution calls', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const retries = { + destination1: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], + destination2: [{ type: 'visualization', id: 'my-visualization', overwrite: false }], + }; + const result = await resolveCopySavedObjectsToSpacesConflicts(namespace, { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], - retries: { - destination1: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], - destination2: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - }, + objects, + retries, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } `); - expect((resolveSavedObjectsImportErrors as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": true, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": false, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + retries: [{ ...retries.destination1[0], replaceReferences: [] }], + }); + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + retries: [{ ...retries.destination2[0], replaceReferences: [] }], + }); }); it(`doesn't stop resolution if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, resolveSavedObjectsImportErrorsImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -408,64 +235,50 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], retries: { - ['failure-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], + ['failure-space']: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], ['non-existent-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - ['marketing']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, + { type: 'visualization', id: 'my-visualization', overwrite: false }, ], + marketing: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], }, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "non-existent-space": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { @@ -487,7 +300,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -496,6 +309,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { includeReferences: true, objects: [], retries: {}, + createNewCopies: false, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Something went wrong while reading this stream"` diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts index a355d19b305a3..d433712bb9412 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts @@ -5,18 +5,18 @@ */ import { Readable } from 'stream'; -import { SavedObject, CoreStart, KibanaRequest } from 'src/core/server'; +import { SavedObject, CoreStart, KibanaRequest, SavedObjectsImportRetry } from 'src/core/server'; import { exportSavedObjectsToStream, resolveSavedObjectsImportErrors, } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, ResolveConflictsOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,6 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -47,26 +45,24 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const resolveConflictsForSpace = async ( spaceId: string, objectsStream: Readable, - retries: Array<{ - type: string; - id: string; - overwrite: boolean; - replaceReferences: Array<{ type: string; from: string; to: string }>; - }> + retries: SavedObjectsImportRetry[], + createNewCopies: boolean ) => { try { const importResponse = await resolveSavedObjectsImportErrors({ namespace: spaceIdToNamespace(spaceId), objectLimit: getImportExportObjectLimit(), savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, retries, + createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -84,6 +80,10 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( includeReferences: options.includeReferences, objects: options.objects, }); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const entry of Object.entries(options.retries)) { const [spaceId, entryRetries] = entry; @@ -92,8 +92,9 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( response[spaceId] = await resolveConflictsForSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), - retries + createReadableStreamFromArray(filteredObjects), + retries, + options.createNewCopies ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts index 1bbe5aa6625b0..8d4169f972795 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts @@ -5,26 +5,33 @@ */ import { Payload } from 'boom'; -import { SavedObjectsImportError } from 'src/core/server'; +import { + SavedObjectsImportSuccess, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from 'src/core/server'; export interface CopyOptions { objects: Array<{ type: string; id: string }>; overwrite: boolean; includeReferences: boolean; + createNewCopies: boolean; } export interface ResolveConflictsOptions { objects: Array<{ type: string; id: string }>; includeReferences: boolean; retries: { - [spaceId: string]: Array<{ type: string; id: string; overwrite: boolean }>; + [spaceId: string]: Array>; }; + createNewCopies: boolean; } export interface CopyResponse { [spaceId: string]: { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: Array; }; } diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 8375296d869e6..dabdcf553edb4 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -20,7 +20,7 @@ import { loggingSystemMock, coreMock, } from '../../../../../../src/core/server/mocks'; -import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import * as kbnTestServer from '../../../../../../src/core/test_helpers/kbn_server'; import { SpacesService } from '../../spaces_service'; import { SpacesAuditLogger } from '../audit_logger'; import { convertSavedObjectToSpace } from '../../routes/lib'; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts index 1558c6425f542..998c6ca18983d 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts @@ -15,7 +15,7 @@ import { IRouter, } from '../../../../../../src/core/server'; -import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import * as kbnTestServer from '../../../../../../src/core/test_helpers/kbn_server'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; // FAILING: https://github.com/elastic/kibana/issues/58942 diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap index c2df94a0a2936..9544d7e8bb481 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap +++ b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap @@ -28,6 +28,8 @@ exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpa exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; +exports[`#getAll useRbacForRequest is true with purpose='shareSavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 61b1985c5a0b9..90ce2b01bfd20 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -242,6 +242,11 @@ describe('#getAll', () => { expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => mockAuthorization.actions.savedObject.get('config', 'find'), }, + { + purpose: 'shareSavedObjectsIntoSpace' as GetSpacePurpose, + expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => + mockAuthorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + }, ].forEach((scenario) => { describe(`with purpose='${scenario.purpose}'`, () => { test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index dd2e0d40f31ed..b1d6e3200ab3a 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -17,6 +17,7 @@ const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [ 'any', 'copySavedObjectsIntoSpace', 'findSavedObjects', + 'shareSavedObjectsIntoSpace', ]; const PURPOSE_PRIVILEGE_MAP: Record< @@ -30,6 +31,9 @@ const PURPOSE_PRIVILEGE_MAP: Record< findSavedObjects: (authorization) => { return [authorization.actions.savedObject.get('config', 'find')]; }, + shareSavedObjectsIntoSpace: (authorization) => [ + authorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + ], }; export class SpacesClient { diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index 034d212a33035..ce93591f492f1 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -43,41 +43,6 @@ export const createMockSavedObjectsService = (spaces: any[] = []) => { const { savedObjects } = coreMock.createStart(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'dashboard', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'index-pattern', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'globalType', - namespaceType: 'agnostic', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'space', - namespaceType: 'agnostic', - hidden: true, - mappings: { properties: {} }, - }, - ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') - ); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); savedObjects.getScopedClient.mockReturnValue(mockSavedObjectsClientContract); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index b604554cbc59a..bec3a5dcb0b71 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -191,54 +191,35 @@ describe('copy to space', () => { ); }); - it(`requires objects to be unique`, async () => { + it(`does not allow "overwrite" to be used with "createNewCopies"`, async () => { const payload = { spaces: ['a-space'], - objects: [ - { type: 'foo', id: 'bar' }, - { type: 'foo', id: 'bar' }, - ], + objects: [{ type: 'foo', id: 'bar' }], + overwrite: true, + createNewCopies: true, }; const { copyToSpace } = await setup(); expect(() => (copyToSpace.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); + ).toThrowErrorMatchingInlineSnapshot(`"cannot use [overwrite] with [createNewCopies]"`); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + it(`requires objects to be unique`, async () => { const payload = { spaces: ['a-space'], objects: [ - { type: 'globalType', id: 'bar' }, - { type: 'visualization', id: 'bar' }, + { type: 'foo', id: 'bar' }, + { type: 'foo', id: 'bar' }, ], }; const { copyToSpace } = await setup(); - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await copyToSpace.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(importSavedObjectsFromStream).toHaveBeenCalledTimes(1); - const [importCallOptions] = (importSavedObjectsFromStream as jest.Mock).mock.calls[0]; - - expect(importCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(() => + (copyToSpace.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); }); it('copies to multiple spaces', async () => { @@ -365,58 +346,6 @@ describe('copy to space', () => { ); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [ - { - type: 'globalType', - id: 'bar', - }, - { type: 'visualization', id: 'bar' }, - ], - }; - - const { resolveConflicts } = await setup(); - - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await resolveConflicts.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(resolveSavedObjectsImportErrors).toHaveBeenCalledTimes(1); - const [ - resolveImportErrorsCallOptions, - ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - - expect(resolveImportErrorsCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - it('resolves conflicts for multiple spaces', async () => { const payload = { objects: [{ type: 'visualization', id: 'bar' }], @@ -459,19 +388,13 @@ describe('copy to space', () => { resolveImportErrorsFirstCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - expect(resolveImportErrorsFirstCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ namespace: 'a-space' }); const [ resolveImportErrorsSecondCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[1]; - expect(resolveImportErrorsSecondCallOptions).toMatchObject({ - namespace: 'b-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ namespace: 'b-space' }); }); }); }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 87c2fee4ea9bf..fef1646067fde 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -30,39 +30,49 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { tags: ['access:copySavedObjectsToSpaces'], }, validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; - } - }, - }), - { - validate: (spaceIds) => { - if (_.uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - objects: schema.arrayOf( - schema.object({ - type: schema.string(), - id: schema.string(), - }), - { - validate: (objects) => { - if (!areObjectsUnique(objects)) { - return 'duplicate objects are not allowed'; - } - }, - } - ), - includeReferences: schema.boolean({ defaultValue: false }), - overwrite: schema.boolean({ defaultValue: false }), - }), + body: schema.object( + { + spaces: schema.arrayOf( + schema.string({ + validate: (value) => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: (spaceIds) => { + if (_.uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: (objects) => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + overwrite: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.createNewCopies) { + return 'cannot use [overwrite] with [createNewCopies]'; + } + }, + } + ), }, }, createLicensedRouteHandler(async (context, request, response) => { @@ -73,12 +83,19 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { spaces: destinationSpaceIds, objects, includeReferences, overwrite } = request.body; + const { + spaces: destinationSpaceIds, + objects, + includeReferences, + overwrite, + createNewCopies, + } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, includeReferences, overwrite, + createNewCopies, }); return response.ok({ body: copyResponse }); }) @@ -105,6 +122,9 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { type: schema.string(), id: schema.string(), overwrite: schema.boolean({ defaultValue: false }), + destinationId: schema.maybe(schema.string()), + createNewCopy: schema.maybe(schema.boolean()), + ignoreMissingReferences: schema.maybe(schema.boolean()), }) ) ), @@ -122,6 +142,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { } ), includeReferences: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: false }), }), }, }, @@ -133,7 +154,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { objects, includeReferences, retries } = request.body; + const { objects, includeReferences, retries, createNewCopies } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, @@ -141,6 +162,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { objects, includeReferences, retries, + createNewCopies, } ); return response.ok({ body: resolveConflictsResponse }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index ec841808f771d..a9b701a8ea395 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -119,6 +119,22 @@ describe('GET /spaces/space', () => { expect(response.payload).toEqual(spaces); }); + it(`returns all available spaces with the 'shareSavedObjectsIntoSpace' purpose`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + query: { + purpose: 'shareSavedObjectsIntoSpace', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + it(`returns http/403 when the license is invalid`, async () => { const { routeHandler } = await setup(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts index cd1e03eb10b0a..088409471fa55 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts @@ -19,7 +19,11 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) { validate: { query: schema.object({ purpose: schema.oneOf( - [schema.literal('any'), schema.literal('copySavedObjectsIntoSpace')], + [ + schema.literal('any'), + schema.literal('copySavedObjectsIntoSpace'), + schema.literal('shareSavedObjectsIntoSpace'), + ], { defaultValue: 'any', } diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 51c59212bef16..c9c17d091cd55 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -90,7 +90,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.get(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -117,7 +117,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkGet(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -263,6 +263,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#checkConflicts', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-expect-error + client.checkConflicts(null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { errors: [] }; + baseClient.checkConflicts.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const objects = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.checkConflicts(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.checkConflicts).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = await createSpacesSavedObjectsClient(); @@ -280,7 +308,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.create(type, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -307,7 +335,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkCreate(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -323,7 +351,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.update(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -337,7 +365,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.update(type, id, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -353,7 +381,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.bulkUpdate(null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -387,7 +415,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.delete(null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -400,7 +428,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.delete(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -416,7 +444,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.addToNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -430,7 +458,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -446,7 +474,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -460,7 +488,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 7e2b302d7cff5..4e830d6149537 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -59,6 +60,25 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { this.errors = baseClient.errors; } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param objects + * @param options + */ + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.checkConflicts(objects, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Persists an object * diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 70295046d19f4..d7dcf779376bf 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -42,7 +42,7 @@ export class TaskManagerPlugin .toPromise(); setupSavedObjects(core.savedObjects, this.config); - this.taskManagerId = core.uuid.getInstanceUuid(); + this.taskManagerId = this.initContext.env.instanceUuid; return { addMiddleware: (middleware: Middleware) => { diff --git a/x-pack/plugins/transform/public/register_feature.ts b/x-pack/plugins/transform/public/register_feature.ts index 796fa370dab25..4ed16824cc3a6 100644 --- a/x-pack/plugins/transform/public/register_feature.ts +++ b/x-pack/plugins/transform/public/register_feature.ts @@ -23,7 +23,7 @@ export const registerFeature = (home: HomePublicPluginSetup) => { }), icon: 'managementApp', // there is currently no Transforms icon, so using the general management app icon path: '/app/management/data/transform', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index da5392848475b..d6e611e65154b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1651,26 +1651,6 @@ "expressions.functions.varset.name.help": "変数の名前を指定", "expressions.functions.varset.val.help": "変数の値を指定指定がない場合、インプットコンテキストが使用されます", "expressions.types.number.fromStringConversionErrorMessage": "\"{string}\" ストリンクを数字に変換できません", - "home.addData.apm.addApmButtonLabel": "APM を追加", - "home.addData.apm.nameDescription": "APM は、集約内から自動的に詳細なパフォーマンスメトリックやエラーを収集します。", - "home.addData.apm.nameTitle": "APM", - "home.addData.logging.addLogDataButtonLabel": "ログデータを追加", - "home.addData.logging.nameDescription": "頻繁に使用するデータソースからログを投入し、構成済みのダッシュボードで簡単に可視化できます。", - "home.addData.logging.nameTitle": "ログ", - "home.addData.metrics.addMetricsDataButtonLabel": "メトリックデータを追加", - "home.addData.metrics.nameDescription": "サーバーのオペレーティングシステムと実行中のサービスからメトリックを収集します。", - "home.addData.metrics.nameTitle": "メトリック", - "home.addData.sampleDataLink": "データセットと Kibana ダッシュボードを読み込む", - "home.addData.sampleDataTitle": "サンプルデータの追加", - "home.addData.securitySolution.addSecurityEventsButtonLabel": "イベントを追加", - "home.addData.securitySolution.nameDescription": "即利用可能なビジュアライゼーションで、セキュリティイベントをまとめてインタラクティブな調査を可能にします。", - "home.addData.securitySolution.nameTitle": "セキュリティ", - "home.addData.title.observability": "オブザーバビリティ", - "home.addData.title.security": "セキュリティ", - "home.addData.uploadFileLink": "CSV、NDJSON、またはログファイルをインポート", - "home.addData.uploadFileTitle": "ログファイルからデータをアップロード", - "home.addData.yourDataLink": "Elasticsearch インデックスに接続", - "home.addData.yourDataTitle": "Elasticsearch データの使用", "home.breadcrumbs.addDataTitle": "データの追加", "home.breadcrumbs.homeTitle": "ホーム", "home.dataManagementDisableCollection": " 収集を停止するには、] ", @@ -1679,10 +1659,6 @@ "home.dataManagementDisclaimerPrivacyLink": "プライバシーポリシーをご覧ください。", "home.dataManagementEnableCollection": " 収集を開始するには、 ", "home.dataManagementEnableCollectionLink": "ここで使用状況データを有効にします。", - "home.directories.manage.nameTitle": "Elastic Stack の管理", - "home.directories.notFound.description": "お探しのものが見つかりませんでしたか?", - "home.directories.notFound.viewFullButtonLabel": "Kibana プラグインの完全なディレクトリを表示", - "home.directories.visualize.nameTitle": "データの可視化と閲覧", "home.directory.directoryTitle": "ディレクトリ", "home.directory.tabs.administrativeTitle": "管理", "home.directory.tabs.allTitle": "すべて", @@ -2405,7 +2381,6 @@ "home.tutorials.zookeeperMetrics.longDescription": "「{moduleName}」Metricbeat モジュールは、Zookeeper サーバーから内部メトリックを取得します。 [詳細]({learnMoreLink})。", "home.tutorials.zookeeperMetrics.nameTitle": "Zookeeper メトリック", "home.tutorials.zookeeperMetrics.shortDescription": "Zookeeper サーバーから内部メトリックを取得します。", - "home.welcomeHomePageHeader": "Kibana ホーム", "home.welcomeTitle": "Elasticへようこそ", "indexPatternManagement.actions.cancelButton": "キャンセル", "indexPatternManagement.actions.createButton": "フィールドを作成", @@ -2775,14 +2750,6 @@ "inspector.requests.statisticsTabLabel": "統計", "inspector.title": "インスペクター", "inspector.view": "{viewName} を表示", - "kbn.advancedSettings.visualization.showRegionMapWarningsText": "用語がマップの形に合わない場合に地域マップに警告を表示するかどうかです。", - "kbn.advancedSettings.visualization.showRegionMapWarningsTitle": "地域マップに警告を表示", - "kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText": "ディメンションの説明", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionText": "マップに表示されるジオハッシュの最高精度です。7 が高い、10 が非常に高い、12 が最高を意味します。{cellDimensionsLink}", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle": "タイルマップの最高精度", - "kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText": "プロパティ", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsText": "座標マップの WMS マップサーバーサポートのデフォルトの {propertiesLink} です。", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle": "デフォルトの WMS プロパティ", "kibana_legacy.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストで接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", "kibana_legacy.notify.toaster.errorMessage": "エラー: {errorMessage}\n {errorStack}", @@ -2913,8 +2880,6 @@ "savedObjects.saveDuplicateRejectedDescription": "重複ファイルの保存確認が拒否されました", "savedObjects.saveModal.cancelButtonLabel": "キャンセル", "savedObjects.saveModal.descriptionLabel": "説明", - "savedObjects.saveModal.duplicateTitleDescription": "{confirmSaveLabel} をクリックすると {objectType} がこの重複したタイトルで保存されます。", - "savedObjects.saveModal.duplicateTitleLabel": "「{title}」というタイトルの {objectType} が既に存在します", "savedObjects.saveModal.saveAsNewLabel": "新しい {objectType} として保存", "savedObjects.saveModal.saveButtonLabel": "保存", "savedObjects.saveModal.saveTitle": "{objectType} を保存", @@ -2957,10 +2922,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "失敗したオブジェクトを再試行中…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "保存された検索が正しくリンクされていることを確認してください…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "矛盾を保存中…", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "{title}を上書きしてよろしいですか?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "キャンセル", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "上書き", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "{type}を上書きしますか?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "申し訳ございません、エラーが発生しました", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "キャンセル", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "インポート", @@ -2983,7 +2944,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "保存されたオブジェクトのファイル形式が無効なため、インポートできません。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "最新のレポートでNDJSONファイルを作成すれば完了です。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "JSONファイルのサポートが終了します", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "すべての保存されたオブジェクトを自動的に上書きしますか?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンのIDです", @@ -4599,7 +4559,7 @@ "xpack.actions.serverSideErrors.predefinedActionUpdateDisabled": "あらかじめ構成されたアクション{id}は更新できません。", "xpack.actions.serverSideErrors.unavailableLicenseErrorMessage": "現時点でライセンス情報を入手できないため、アクションタイプ {actionTypeId} は無効です。", "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "グラフを利用できません。現在ライセンス情報が利用できません。", - "xpack.actions.urlWhitelistConfigurationError": "target {field} \"{value}\" は Kibana 構成 xpack.actions.whitelistedHosts にはホワイトリスト化されていません。", + "xpack.actions.urlAllowedHostsConfigurationError": "target {field} \"{value}\" は Kibana 構成 xpack.actions.allowedHosts にはホワイトリスト化されていません。", "xpack.alertingBuiltins.indexThreshold.actionGroupThresholdMetTitle": "しきい値一致", "xpack.alertingBuiltins.indexThreshold.actionVariableContextDateLabel": "アラートがしきい値を超えた日付。", "xpack.alertingBuiltins.indexThreshold.actionVariableContextGroupLabel": "しきい値を超えたグループ。", @@ -4909,7 +4869,6 @@ "xpack.apm.registerTransactionDurationAlertType.variables.serviceName": "サービス名", "xpack.apm.registerTransactionDurationAlertType.variables.transactionType": "トランザクションタイプ", "xpack.apm.rum.dashboard.backend": "バックエンド", - "xpack.apm.rum.dashboard.dateTime.label": "日付/時刻", "xpack.apm.rum.dashboard.frontend": "フロントエンド", "xpack.apm.rum.dashboard.overall.label": "全体", "xpack.apm.rum.dashboard.pageLoadDistribution.label": "ページ読み込み分布", @@ -5667,7 +5626,6 @@ "xpack.canvas.functions.joinRows.args.separatorHelpText": "行の値の間で使用する区切り文字", "xpack.canvas.functions.joinRows.columnNotFoundErrorMessage": "列が見つかりません。'{column}'", "xpack.canvas.functions.joinRowsHelpText": "データベースの行の値を文字列に結合", - "xpack.canvas.functions.locationHelpText": "ブラウザの {geolocationAPI} を使用して現在位置を取得します。パフォーマンスに違いはありますが、比較的正確です。{url} を参照。", "xpack.canvas.functions.lt.args.valueHelpText": "{CONTEXT} と比較される値です。", "xpack.canvas.functions.lte.args.valueHelpText": "{CONTEXT} と比較される値です。", "xpack.canvas.functions.lteHelpText": "{CONTEXT} が引数以下かを戻します。", @@ -5676,7 +5634,6 @@ "xpack.canvas.functions.mapCenterHelpText": "マップの中央座標とズームレベルのオブジェクトに戻ります。", "xpack.canvas.functions.mapColumn.args.expressionHelpText": "単一行 {DATATABLE} として各行に渡される {CANVAS} 表現です。", "xpack.canvas.functions.mapColumn.args.nameHelpText": "結果の列の名前です。", - "xpack.canvas.functions.mapColumnHelpText": "他の列の結果として計算された列を追加します。引数が提供された場合のみ変更が加えられます。{mapColumnFn} と {staticColumnFn} もご参照ください。", "xpack.canvas.functions.markdown.args.contentHelpText": "{MARKDOWN} を含むテキストの文字列です。連結させるには、{stringFn} 関数を複数回渡します。", "xpack.canvas.functions.markdown.args.fontHelpText": "コンテンツの {CSS} フォントプロパティです。例: {fontFamily} または {fontWeight}。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "新しいタブでリンクを開くための true/false 値。デフォルト値は false です。true に設定するとすべてのリンクが新しいタブで開くようになります。", @@ -5686,7 +5643,6 @@ "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空の表現", "xpack.canvas.functions.math.executionFailedErrorMessage": "数式の実行に失敗しました。列名を確認してください", "xpack.canvas.functions.math.tooManyResultsErrorMessage": "表現は 1 つの数字を返す必要があります。表現を {mean} または {sum} で囲んでみてください", - "xpack.canvas.functions.mathHelpText": "数字または {DATATABLE} を {CONTEXT} として使用して {TINYMATH} 数式を解釈します。{DATATABLE} 列は列名で表示されます。{CONTEXT} が数字の場合は、{value} と表示されます。", "xpack.canvas.functions.metric.args.labelFontHelpText": "ラベルの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "メトリックを説明するテキストです。", "xpack.canvas.functions.metric.args.metricFontHelpText": "メトリックの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", @@ -5702,16 +5658,12 @@ "xpack.canvas.functions.pie.args.holeHelpText": "円グラフに穴をあけます、0~100 で円グラフの半径のパーセンテージを指定します。", "xpack.canvas.functions.pie.args.labelRadiusHelpText": "ラベルの円の半径として使用する、コンテナーの面積のパーセンテージです。", "xpack.canvas.functions.pie.args.labelsHelpText": "円グラフのラベルを表示しますか?", - "xpack.canvas.functions.pie.args.legendHelpText": "凡例の配置です。例: {positions}、または {BOOLEAN_FALSE}。{BOOLEAN_FALSE} の場合、凡例は非表示になります。", - "xpack.canvas.functions.pie.args.paletteHelpText": "この円グラフに使用されている色を説明する {palette} オブジェクトです。{paletteFn} をご覧ください。", "xpack.canvas.functions.pie.args.radiusHelpText": "利用可能なスペースのパーセンテージで示された円グラフの半径です (0 から 1 の間)。半径を自動的に設定するには {auto} を使用します。", "xpack.canvas.functions.pie.args.seriesStyleHelpText": "特定の数列のスタイルです", "xpack.canvas.functions.pie.args.tiltHelpText": "「1」 が完全に垂直、「0」が完全に水平を表す傾きのパーセンテージです。", "xpack.canvas.functions.pieHelpText": "円グラフのエレメントを構成します。", "xpack.canvas.functions.plot.args.defaultStyleHelpText": "すべての数列に使用するデフォルトのスタイルです。", "xpack.canvas.functions.plot.args.fontHelpText": "表の {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", - "xpack.canvas.functions.plot.args.legendHelpText": "凡例の配置です。例: {positions}、または {BOOLEAN_FALSE}。{BOOLEAN_FALSE} の場合、凡例は非表示になります。", - "xpack.canvas.functions.plot.args.paletteHelpText": "このチャートに使用される色を説明する {palette} オブジェクトです。{paletteFn} をご覧ください。", "xpack.canvas.functions.plot.args.seriesStyleHelpText": "特定の数列のスタイルです", "xpack.canvas.functions.plot.args.xaxisHelpText": "軸の構成です。{BOOLEAN_FALSE} の場合、軸は非表示になります。", "xpack.canvas.functions.plot.args.yaxisHelpText": "軸の構成です。{BOOLEAN_FALSE} の場合、軸は非表示になります。", @@ -5795,7 +5747,6 @@ "xpack.canvas.functions.shapeHelpText": "図形を作成します。", "xpack.canvas.functions.sort.args.byHelpText": "並べ替えの基準となる列です。指定されていない場合、「{DATATABLE}」は初めの列で並べられます。", "xpack.canvas.functions.sort.args.reverseHelpText": "並び順を反転させます。指定されていない場合、「{DATATABLE}」は昇順で並べられます。", - "xpack.canvas.functions.sortHelpText": "データ表を指定された列で並べ替えます。", "xpack.canvas.functions.staticColumn.args.nameHelpText": "新しい列の名前です。", "xpack.canvas.functions.staticColumn.args.valueHelpText": "新しい列の各行に挿入する値です。ヒント: 部分式を使用して他の列を静的値にロールアップします。", "xpack.canvas.functions.staticColumnHelpText": "すべての行に同じ静的値の列を追加します。{alterColumnFn} および {mapColumnFn} もご参照ください。", @@ -13909,7 +13860,6 @@ "xpack.monitoring.metrics.logstashInstance.systemLoad.last1MinuteLabel": "1m", "xpack.monitoring.metrics.logstashInstance.systemLoad.last5MinutesDescription": "過去 5 分間の平均負荷です。", "xpack.monitoring.metrics.logstashInstance.systemLoad.last5MinutesLabel": "5m", - "xpack.monitoring.monitoringDescription": "Elastic Stack のリアルタイムのヘルスとパフォーマンスをトラッキングします。", "xpack.monitoring.monLabel": "月", "xpack.monitoring.noData.blurbs.changesNeededDescription": "監視を実行するには、次の手順に従います", "xpack.monitoring.noData.blurbs.changesNeededTitle": "調整が必要です", @@ -16488,8 +16438,6 @@ "xpack.securitySolution.exceptions.viewer.fetchingListError": "例外の取得エラー", "xpack.securitySolution.exceptions.viewer.searchDefaultPlaceholder": "検索フィールド(例:host.name)", "xpack.securitySolution.exitFullScreenButton": "全画面を終了", - "xpack.securitySolution.featureCatalogue.description": "セキュリティメトリクスとログのイベントとアラートを確認します", - "xpack.securitySolution.featureCatalogue.title": "セキュリティ", "xpack.securitySolution.featureRegistry.linkSecuritySolutionTitle": "セキュリティ", "xpack.securitySolution.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, =1 {カテゴリ} other {カテゴリ}}", "xpack.securitySolution.fieldBrowser.categoriesTitle": "カテゴリー", @@ -17913,16 +17861,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "スペースを削除すると、スペースと {allContents} が永久に削除されます。この操作は元に戻すことができません。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "現在のスペース {name} を削除しようとしています。続行すると、別のスペースを選択する画面に移動します。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "スペース名が一致していません。", - "xpack.spaces.management.copyToSpace.actionDescription": "この保存されたオブジェクトを1つまたは複数のスペースにコピーします。", - "xpack.spaces.management.copyToSpace.actionTitle": "スペースにコピー", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "すべての保存されたオブジェクトを自動的に上書き", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "上書き", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "スキップ", "xpack.spaces.management.copyToSpace.copyErrorTitle": "保存されたオブジェクトのコピー中にエラーが発生", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "コピー結果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "このスペースには同じID({id})の保存されたオブジェクトが既に存在します。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "「上書き」をクリックしてこのバージョンをコピーされたバージョンに置き換えます。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "保存されたオブジェクトは上書きされます。「スキップ」をクリックしてこの操作をキャンセルします。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "保存されたオブジェクトがコピーされました。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "この保存されたオブジェクトのコピー中にエラーが発生しました。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "{space}スペースに1つまたは複数の矛盾が検出されました。解決するにはこのセクションを拡張してください。", @@ -17930,26 +17870,17 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "{space}スペースにコピーされました。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "{spaceCount} {spaceCount, plural, one {スペース} other {スペース}}にコピー", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "コピー", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "関連性のある保存されたオブジェクトを含みません", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "保存されたオブジェクトを上書きしません", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "終了", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "コピーが完了しました。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "{overwriteCount}件のオブジェクトを上書き", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "関連性のある保存されたオブジェクトを含みます", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "関連性のある保存されたオブジェクトを含みます", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "コピーが進行中です。お待ちください。", "xpack.spaces.management.copyToSpace.noSpacesBody": "コピーできるスペースがありません。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "スペースがありません", "xpack.spaces.management.copyToSpace.overwriteLabel": "保存されたオブジェクトを自動的に上書きしています", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "保存されたオブジェクトの矛盾の解決中にエラーが発生", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "上書き成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "コピー先のスペースを選択してください", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "スキップ", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "エラー", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "保留中", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "コピー完了", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "保存されたオブジェクトのスペースへのコピー", "xpack.spaces.management.createSpaceBreadcrumb": "作成", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "カスタム画像", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e892ff228cd49..54c69d849e3a9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1652,26 +1652,6 @@ "expressions.functions.varset.name.help": "指定变量的名称", "expressions.functions.varset.val.help": "为变量指定值。如果未提供,将使用输入上下文", "expressions.types.number.fromStringConversionErrorMessage": "无法将“{string}”字符串的类型转换为数字", - "home.addData.apm.addApmButtonLabel": "添加 APM", - "home.addData.apm.nameDescription": "APM 自动从您的应用程序内收集深入全面的性能指标和错误。", - "home.addData.apm.nameTitle": "APM", - "home.addData.logging.addLogDataButtonLabel": "添加日志数据", - "home.addData.logging.nameDescription": "从常见的数据源采集日志,并在预配置的仪表板中轻松实现可视化。", - "home.addData.logging.nameTitle": "日志", - "home.addData.metrics.addMetricsDataButtonLabel": "添加指标数据", - "home.addData.metrics.nameDescription": "从您的服务器上运行的操作系统和服务收集指标。", - "home.addData.metrics.nameTitle": "指标", - "home.addData.sampleDataLink": "加载数据集和 Kibana 仪表板", - "home.addData.sampleDataTitle": "添加样例数据", - "home.addData.securitySolution.addSecurityEventsButtonLabel": "添加事件", - "home.addData.securitySolution.nameDescription": "集中安全事件,以通过即用型可视化实现交互式调查。", - "home.addData.securitySolution.nameTitle": "安全", - "home.addData.title.observability": "可观测性", - "home.addData.title.security": "安全", - "home.addData.uploadFileLink": "导入 CSV、NDJSON 或日志文件", - "home.addData.uploadFileTitle": "从日志文件上传数据", - "home.addData.yourDataLink": "连接到您的 Elasticsearch 索引", - "home.addData.yourDataTitle": "使用 Elasticsearch 数据", "home.breadcrumbs.addDataTitle": "添加数据", "home.breadcrumbs.homeTitle": "主页", "home.dataManagementDisableCollection": " 要停止收集, ", @@ -1680,10 +1660,6 @@ "home.dataManagementDisclaimerPrivacyLink": "隐私声明。", "home.dataManagementEnableCollection": " 要启动收集, ", "home.dataManagementEnableCollectionLink": "请在此处启用使用情况数据。", - "home.directories.manage.nameTitle": "管理 Elastic Stack", - "home.directories.notFound.description": "未找到要寻找的内容?", - "home.directories.notFound.viewFullButtonLabel": "查看 Kibana 插件的完整目录", - "home.directories.visualize.nameTitle": "可视化和浏览数据", "home.directory.directoryTitle": "目录", "home.directory.tabs.administrativeTitle": "管理", "home.directory.tabs.allTitle": "全部", @@ -2406,7 +2382,6 @@ "home.tutorials.zookeeperMetrics.longDescription": "Metricbeat 模块 `{moduleName}` 从 Zookeeper 服务器提取内部指标。[了解详情]({learnMoreLink})。", "home.tutorials.zookeeperMetrics.nameTitle": "Zookeeper 指标", "home.tutorials.zookeeperMetrics.shortDescription": "从 Zookeeper 服务器提取内部指标。", - "home.welcomeHomePageHeader": "Kibana 主页", "home.welcomeTitle": "欢迎使用 Elastic", "indexPatternManagement.actions.cancelButton": "取消", "indexPatternManagement.actions.createButton": "创建字段", @@ -2776,14 +2751,6 @@ "inspector.requests.statisticsTabLabel": "统计信息", "inspector.title": "检查器", "inspector.view": "视图:{viewName}", - "kbn.advancedSettings.visualization.showRegionMapWarningsText": "词无法联接到地图上的形状时,区域地图是否显示警告。", - "kbn.advancedSettings.visualization.showRegionMapWarningsTitle": "显示区域地图警告", - "kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText": "单元格维度的解释", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionText": "在磁贴地图上显示的最大 geoHash 精确度:7 为高,10 为很高,12 为最大值。{cellDimensionsLink}", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle": "最大磁贴地图精确度", - "kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText": "属性", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsText": "坐标地图中 WMS 地图服务器支持的默认{propertiesLink}", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle": "默认 WMS 属性", "kibana_legacy.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", "kibana_legacy.notify.toaster.errorMessage": "错误:{errorMessage}\n {errorStack}", @@ -2914,8 +2881,6 @@ "savedObjects.saveDuplicateRejectedDescription": "已拒绝使用重复标题保存确认", "savedObjects.saveModal.cancelButtonLabel": "取消", "savedObjects.saveModal.descriptionLabel": "描述", - "savedObjects.saveModal.duplicateTitleDescription": "单击“{confirmSaveLabel}”将会使用此重复标题保存 {objectType}。", - "savedObjects.saveModal.duplicateTitleLabel": "具有标题“{title}”的 {objectType} 已存在", "savedObjects.saveModal.saveAsNewLabel": "另存为新的 {objectType}", "savedObjects.saveModal.saveButtonLabel": "保存", "savedObjects.saveModal.saveTitle": "保存 {objectType}", @@ -2958,10 +2923,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "正在重试失败的对象……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "确保已保存搜索已正确链接……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "正在保存冲突……", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "确定要覆盖“{title}”?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "取消", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "覆盖", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "覆盖“{type}”?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "抱歉,有错误", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "取消", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "导入", @@ -2984,7 +2945,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "已保存对象文件格式无效,无法导入。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "只需使用更新的导出功能生成 NDJSON 文件,便万事俱备。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "将不再支持 JSON 文件", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "自动覆盖所有已保存对象?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", @@ -4600,7 +4560,7 @@ "xpack.actions.serverSideErrors.predefinedActionUpdateDisabled": "不允许更新预配置的操作 {id}。", "xpack.actions.serverSideErrors.unavailableLicenseErrorMessage": "操作类型 {actionTypeId} 已禁用,因为许可证信息当前不可用。", "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "操作不可用 - 许可信息当前不可用。", - "xpack.actions.urlWhitelistConfigurationError": "目标 {field}“{value}”在 Kibana 配置 xpack.actions.whitelistedHosts 中未列入白名单", + "xpack.actions.urlAllowedHostsConfigurationError": "目标 {field}“{value}”在 Kibana 配置 xpack.actions.allowedHosts 中未列入白名单", "xpack.alertingBuiltins.indexThreshold.actionGroupThresholdMetTitle": "阈值已达到", "xpack.alertingBuiltins.indexThreshold.actionVariableContextDateLabel": "告警超过阈值的日期。", "xpack.alertingBuiltins.indexThreshold.actionVariableContextGroupLabel": "超过阈值的组。", @@ -4911,7 +4871,6 @@ "xpack.apm.registerTransactionDurationAlertType.variables.serviceName": "服务名称", "xpack.apm.registerTransactionDurationAlertType.variables.transactionType": "事务类型", "xpack.apm.rum.dashboard.backend": "后端", - "xpack.apm.rum.dashboard.dateTime.label": "日期 / 时间", "xpack.apm.rum.dashboard.frontend": "前端", "xpack.apm.rum.dashboard.overall.label": "总体", "xpack.apm.rum.dashboard.pageLoadDistribution.label": "页面加载分布", @@ -5669,7 +5628,6 @@ "xpack.canvas.functions.joinRows.args.separatorHelpText": "用于分隔行值的分隔符", "xpack.canvas.functions.joinRows.columnNotFoundErrorMessage": "找不到列:“{column}”", "xpack.canvas.functions.joinRowsHelpText": "将数据库中的行的值联接成字符串", - "xpack.canvas.functions.locationHelpText": "使用浏览器的 {geolocationAPI} 查找您的当前位置。性能可能会因浏览器而异,但相当准确。请参见 {url}。", "xpack.canvas.functions.lt.args.valueHelpText": "与 {CONTEXT} 比较的值。", "xpack.canvas.functions.lte.args.valueHelpText": "与 {CONTEXT} 比较的值。", "xpack.canvas.functions.lteHelpText": "返回 {CONTEXT} 是否小于或等于参数。", @@ -5678,7 +5636,6 @@ "xpack.canvas.functions.mapCenterHelpText": "返回具有地图中心坐标和缩放级别的对象", "xpack.canvas.functions.mapColumn.args.expressionHelpText": "作为单行 {DATATABLE} 传递到每一行的 {CANVAS} 表达式。", "xpack.canvas.functions.mapColumn.args.nameHelpText": "结果列的名称。", - "xpack.canvas.functions.mapColumnHelpText": "添加计算为其他列的结果的列。只有提供参数时,才会进行更改。另请参见 {mapColumnFn} 和 {staticColumnFn}。", "xpack.canvas.functions.markdown.args.contentHelpText": "包含 {MARKDOWN} 的文本字符串。要进行串联,请传递 {stringFn} 函数多次。", "xpack.canvas.functions.markdown.args.fontHelpText": "内容的 {CSS} 字体属性。例如:{fontFamily} 或 {fontWeight}。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "表示是否在新选项卡中打开链接的 true/false 值。默认值为 false。设置为 true 将在新选项卡中打开所有链接。", @@ -5688,7 +5645,6 @@ "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空表达式", "xpack.canvas.functions.math.executionFailedErrorMessage": "无法执行数学表达式。检查您的列名称", "xpack.canvas.functions.math.tooManyResultsErrorMessage": "表达式必须返回单个数字。尝试将您的表达式包装在 {mean} 或 {sum} 中", - "xpack.canvas.functions.mathHelpText": "通过将数字或 {DATATABLE} 用作 {CONTEXT} 来解析 {TINYMATH} 数学表达式。{DATATABLE} 列可通过列名来使用。如果 {CONTEXT} 是数字,其可用作 {value}。", "xpack.canvas.functions.metric.args.labelFontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "描述指标的文本。", "xpack.canvas.functions.metric.args.metricFontHelpText": "指标的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", @@ -5704,16 +5660,12 @@ "xpack.canvas.functions.pie.args.holeHelpText": "在饼图中绘制介于 `0` and `100`(饼图半径的百分比)之间的孔洞。", "xpack.canvas.functions.pie.args.labelRadiusHelpText": "要用作标签圆形半径的容器面积百分比。", "xpack.canvas.functions.pie.args.labelsHelpText": "显示饼图标签?", - "xpack.canvas.functions.pie.args.legendHelpText": "图例位置。例如 {positions} 或 {BOOLEAN_FALSE}。为 {BOOLEAN_FALSE} 时,图例隐藏。", - "xpack.canvas.functions.pie.args.paletteHelpText": "用于描述要在饼图上使用的颜色的 {palette} 对象。请参见 {paletteFn}。", "xpack.canvas.functions.pie.args.radiusHelpText": "饼图的半径,表示为可用空间的百分比(介于 `0` 和 `1` 之间)。要自动设置半径,请使用 {auto}。", "xpack.canvas.functions.pie.args.seriesStyleHelpText": "特定序列的样式", "xpack.canvas.functions.pie.args.tiltHelpText": "倾斜百分比,其中 `1` 为完全垂直,`0` 为完全水平。", "xpack.canvas.functions.pieHelpText": "配置饼图元素。", "xpack.canvas.functions.plot.args.defaultStyleHelpText": "要用于每个序列的默认样式。", "xpack.canvas.functions.plot.args.fontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", - "xpack.canvas.functions.plot.args.legendHelpText": "图例位置。例如 {positions} 或 {BOOLEAN_FALSE}。为 {BOOLEAN_FALSE} 时,图例隐藏。", - "xpack.canvas.functions.plot.args.paletteHelpText": "用于描述要在此图表上使用的颜色的 {palette} 对象。请参见 {paletteFn}。", "xpack.canvas.functions.plot.args.seriesStyleHelpText": "特定序列的样式", "xpack.canvas.functions.plot.args.xaxisHelpText": "轴配置。为 {BOOLEAN_FALSE} 时,轴隐藏。", "xpack.canvas.functions.plot.args.yaxisHelpText": "轴配置。为 {BOOLEAN_FALSE} 时,轴隐藏。", @@ -5797,7 +5749,6 @@ "xpack.canvas.functions.shapeHelpText": "创建形状。", "xpack.canvas.functions.sort.args.byHelpText": "排序要依据的列。未指定时,将按第一列排序 `{DATATABLE}`。", "xpack.canvas.functions.sort.args.reverseHelpText": "反转排序顺序。未指定时,将升序排序 `{DATATABLE}`。", - "xpack.canvas.functions.sortHelpText": "按指定列排序数据库。", "xpack.canvas.functions.staticColumn.args.nameHelpText": "新列的名称。", "xpack.canvas.functions.staticColumn.args.valueHelpText": "在每一行新列中要插入的值。提示:使用子表达式将其他列汇总为静态值。", "xpack.canvas.functions.staticColumnHelpText": "在每一行添加具有相同静态值的列。另见 {alterColumnFn} 和 {mapColumnFn}。", @@ -13915,7 +13866,6 @@ "xpack.monitoring.metrics.logstashInstance.systemLoad.last1MinuteLabel": "1 分钟", "xpack.monitoring.metrics.logstashInstance.systemLoad.last5MinutesDescription": "过去 5 分钟的负载平均值。", "xpack.monitoring.metrics.logstashInstance.systemLoad.last5MinutesLabel": "5 分钟", - "xpack.monitoring.monitoringDescription": "跟踪 Elastic Stack 的实时运行状况和性能。", "xpack.monitoring.monLabel": "周一", "xpack.monitoring.noData.blurbs.changesNeededDescription": "要运行 Monitoring,请执行以下步骤", "xpack.monitoring.noData.blurbs.changesNeededTitle": "您需要做些调整", @@ -16495,8 +16445,6 @@ "xpack.securitySolution.exceptions.viewer.fetchingListError": "提取例外时出错", "xpack.securitySolution.exceptions.viewer.searchDefaultPlaceholder": "搜索字段(例如:host.name)", "xpack.securitySolution.exitFullScreenButton": "退出全屏", - "xpack.securitySolution.featureCatalogue.description": "浏览安全指标和日志以了解事件和告警", - "xpack.securitySolution.featureCatalogue.title": "安全", "xpack.securitySolution.featureRegistry.linkSecuritySolutionTitle": "安全", "xpack.securitySolution.fieldBrowser.categoriesCountTitle": "{totalCount} 个{totalCount, plural, =1 {类别} other {类别}}", "xpack.securitySolution.fieldBrowser.categoriesTitle": "类别", @@ -17920,16 +17868,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "删除空间会永久删除空间及其 {allContents}。此操作无法撤消。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "您即将删除当前空间 {name}。如果继续,系统会将您重定向到选择其他空间的位置。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "空间名称不匹配。", - "xpack.spaces.management.copyToSpace.actionDescription": "将此已保存对象复制到一个或多个工作区", - "xpack.spaces.management.copyToSpace.actionTitle": "复制到工作区", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "自动覆盖所有已保存对象", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "覆盖", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "跳过", "xpack.spaces.management.copyToSpace.copyErrorTitle": "复制已保存对象时出错", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "复制结果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "具有匹配 ID ({id}) 的已保存对象在此工作区中已存在。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "单击“覆盖”可将此版本替换为复制的版本。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "已保存对象将被覆盖。单击“跳过”可取消此操作。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "已保存对象成功复制。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "复制此已保存对象时出错。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "在 {space} 工作区中检测到一个或多个冲突。展开此部分以进行解决。", @@ -17937,26 +17877,17 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "已成功复制到 {space} 工作区。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "复制到 {spaceCount} {spaceCount, plural, one {个工作区} other {个工作区}}", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "复制", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "不包括相关已保存对象", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "未覆盖已保存对象", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "完成", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "复制已完成。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "覆盖 {overwriteCount} 个对象", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "包括相关已保存对象", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "包括相关已保存对象", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "复制正在进行中。请稍候。", "xpack.spaces.management.copyToSpace.noSpacesBody": "没有可向其中进行复制的合格工作区。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "没有可用的工作区", "xpack.spaces.management.copyToSpace.overwriteLabel": "正在自动覆盖已保存对象", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "解决已保存对象冲突时出错", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "覆盖成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "选择要向其中进行复制的工作区", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "已跳过", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "错误", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "待处理", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "已复制", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "将已保存对象复制到工作区", "xpack.spaces.management.createSpaceBreadcrumb": "创建", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "颜色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "定制图像", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 1f9352d8405d2..c53dc0c105084 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -26,8 +26,8 @@ import { PluginStartContract as AlertingStart } from '../../../alerts/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; const TriggersActionsUIHome = lazy(async () => import('./home')); -const AlertDetailsRoute = lazy(() => - import('./sections/alert_details/components/alert_details_route') +const AlertDetailsRoute = lazy( + () => import('./sections/alert_details/components/alert_details_route') ); export interface AppDeps { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index ba7eb598c120d..0674e5b35c61f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -69,7 +69,7 @@ describe('pagerduty connector validation', () => { expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { - routingKey: ['A routing key is required.'], + routingKey: ['An integration key / routing key is required.'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index 5e29fca397180..90d8da346c71d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -38,7 +38,7 @@ export function getActionType(): ActionTypeModel { i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', { - defaultMessage: 'A routing key is required.', + defaultMessage: 'An integration key / routing key is required.', } ) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 7808e2a7f608d..f73fac2259067 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -21,6 +21,7 @@ export { AlertTypeParamsExpressionProps, ValidationResult, ActionVariable, + ActionConnector, } from './types'; export { ConnectorAddFlyout, diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts index 0acd3ea3e51a7..d79614e47ccd4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts @@ -24,8 +24,7 @@ export interface ActionFactoryDefinition< triggers: SupportedTriggers[]; }, ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] -> - extends Partial, 'getHref'>>, +> extends Partial, 'getHref'>>, Configurable { /** * Unique ID of the action factory. This ID is used to identify this action diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index f3f06f776260d..be1f498c2e75d 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -24,6 +24,9 @@ export enum API_URLS { ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, ML_CAPABILITIES = '/api/ml/ml_capabilities', ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, + + ALERT_ACTIONS = '/api/actions', + CREATE_ALERT = '/api/alerts/alert', ALERT = '/api/alerts/alert/', ALERTS_FIND = '/api/alerts/_find', } diff --git a/x-pack/plugins/uptime/common/constants/settings_defaults.ts b/x-pack/plugins/uptime/common/constants/settings_defaults.ts index b9e99a54b3b11..6eb2a1913b871 100644 --- a/x-pack/plugins/uptime/common/constants/settings_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/settings_defaults.ts @@ -10,4 +10,5 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = { heartbeatIndices: 'heartbeat-8*', certAgeThreshold: 730, certExpirationThreshold: 30, + defaultConnectors: [], }; diff --git a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts index 5a355dc576c0a..971a9f51bfae1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts +++ b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -25,6 +25,7 @@ export const AtomicStatusCheckParamsType = t.intersection([ search: t.string, filters: StatusCheckFiltersType, shouldCheckStatus: t.boolean, + isAutoGenerated: t.boolean, }), ]); @@ -34,6 +35,7 @@ export const StatusCheckParamsType = t.intersection([ t.partial({ filters: t.string, shouldCheckStatus: t.boolean, + isAutoGenerated: t.boolean, }), t.type({ locations: t.array(t.string), diff --git a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts index a0ec92f7d869b..3621827b294a6 100644 --- a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts +++ b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts @@ -10,6 +10,7 @@ export const DynamicSettingsType = t.type({ heartbeatIndices: t.string, certAgeThreshold: t.number, certExpirationThreshold: t.number, + defaultConnectors: t.array(t.string), }); export const DynamicSettingsSaveType = t.intersection([ diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts index bf81c91bae633..c622d4f19bade 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts @@ -18,7 +18,6 @@ export type MonitorError = t.TypeOf; export const MonitorDetailsType = t.intersection([ t.type({ monitorId: t.string }), - t.partial({ error: MonitorErrorType }), - t.partial({ timestamp: t.string }), + t.partial({ error: MonitorErrorType, timestamp: t.string, alerts: t.unknown }), ]); export type MonitorDetails = t.TypeOf; diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index cf750434ab324..9f7907ec39187 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,7 +12,6 @@ import { AppMountParameters, } from 'kibana/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; - import { FeatureCatalogueCategory, HomePublicPluginSetup, @@ -32,7 +31,7 @@ import { PLUGIN } from '../../common/constants/plugin'; export interface ClientPluginsSetup { data: DataPublicPluginSetup; - home: HomePublicPluginSetup; + home?: HomePublicPluginSetup; observability: ObservabilityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } @@ -61,7 +60,7 @@ export class UptimePlugin description: PLUGIN.DESCRIPTION, icon: 'uptimeApp', path: '/app/uptime#/', - showOnHomePage: true, + showOnHomePage: false, category: FeatureCatalogueCategory.DATA, }); } diff --git a/x-pack/plugins/uptime/public/components/certificates/__tests__/__snapshots__/fingerprint_col.test.tsx.snap b/x-pack/plugins/uptime/public/components/certificates/__tests__/__snapshots__/fingerprint_col.test.tsx.snap index b1da4aa929207..78c21e515c21e 100644 --- a/x-pack/plugins/uptime/public/components/certificates/__tests__/__snapshots__/fingerprint_col.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/certificates/__tests__/__snapshots__/fingerprint_col.test.tsx.snap @@ -1,8 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`FingerprintCol renders expected elements for valid props 1`] = ` -Array [ - .c1 .euiButtonEmpty__content { +.c1 .euiButtonEmpty__content { padding-right: 0px; } @@ -10,8 +9,9 @@ Array [ margin-right: 8px; } - + - , - .c1 .euiButtonEmpty__content { - padding-right: 0px; -} - -.c0 { - margin-right: 8px; -} - - + - , -] + + `; exports[`FingerprintCol shallow renders expected elements for valid props 1`] = ` diff --git a/x-pack/plugins/uptime/public/components/certificates/fingerprint_col.tsx b/x-pack/plugins/uptime/public/components/certificates/fingerprint_col.tsx index 3bab0a183a0b5..049e206a3fc3c 100644 --- a/x-pack/plugins/uptime/public/components/certificates/fingerprint_col.tsx +++ b/x-pack/plugins/uptime/public/components/certificates/fingerprint_col.tsx @@ -16,7 +16,7 @@ const EmptyButton = styled(EuiButtonEmpty)` } `; -const Span = styled.span` +const StyledSpan = styled.span` margin-right: 8px; `; @@ -27,7 +27,7 @@ interface Props { export const FingerprintCol: React.FC = ({ cert }) => { const ShaComponent = ({ text, val }: { text: string; val: string }) => { return ( - + {text} @@ -41,13 +41,13 @@ export const FingerprintCol: React.FC = ({ cert }) => { /> )} - + ); }; return ( - <> + - + ); }; diff --git a/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx b/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx index 4a681f6fa60bf..845b597a8ad18 100644 --- a/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx @@ -8,10 +8,10 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { EuiLink, EuiButton } from '@elastic/eui'; -import '../../../../lib/__mocks__/react_router_history.mock'; +import '../../../../lib/__mocks__/ut_router_history.mock'; import { ReactRouterEuiLink, ReactRouterEuiButton } from '../link_for_eui'; -import { mockHistory } from '../../../../lib/__mocks__'; +import { mockHistory } from '../../../../lib/__mocks__/ut_router_history.mock'; describe('EUI & React Router Component Helpers', () => { beforeEach(() => { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index f4382b37b3d30..7971c4eb58350 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -22,7 +22,7 @@ import { useMonitorId } from '../../../hooks'; import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions'; import { useAnomalyAlert } from './use_anomaly_alert'; import { ConfirmAlertDeletion } from './confirm_alert_delete'; -import { deleteAlertAction } from '../../../state/actions/alerts'; +import { deleteAnomalyAlertAction } from '../../../state/alerts/alerts'; interface Props { hasMLJob: boolean; @@ -52,7 +52,7 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false); const deleteAnomalyAlert = () => - dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string })); + dispatch(deleteAnomalyAlertAction.get({ alertId: anomalyAlert?.id as string })); const showLoading = isMLJobCreating || isMLJobLoading; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts index d204cdf10012a..949bbadfc9d26 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts +++ b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts @@ -6,10 +6,10 @@ import { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { getExistingAlertAction } from '../../../state/actions/alerts'; -import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; import { useMonitorId } from '../../../hooks'; +import { anomalyAlertSelector, getAnomalyAlertAction } from '../../../state/alerts/alerts'; export const useAnomalyAlert = () => { const { lastRefresh } = useContext(UptimeRefreshContext); @@ -18,12 +18,12 @@ export const useAnomalyAlert = () => { const monitorId = useMonitorId(); - const { data: anomalyAlert } = useSelector(alertSelector); + const { data: anomalyAlert } = useSelector(anomalyAlertSelector); const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); useEffect(() => { - dispatch(getExistingAlertAction.get({ monitorId })); + dispatch(getAnomalyAlertAction.get({ monitorId })); }, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]); return anomalyAlert; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/poly_layer_mock.ts similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/poly_layer_mock.ts diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts index 18b43434da24b..582c60f048bed 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts @@ -5,7 +5,7 @@ */ import { getLayerList } from '../map_config'; -import { mockLayerList } from './__mocks__/mock'; +import { mockLayerList } from './__mocks__/poly_layer_mock'; import { LocationPoint } from '../embedded_map'; import { UptimeAppColors } from '../../../../../../apps/uptime_app'; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index bfe32acf29e39..e177f1cf01147 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -904,12 +904,10 @@ exports[`MonitorList component renders the monitor list 1`] = ` > - - Up - + Up @@ -1057,9 +1055,26 @@ exports[`MonitorList component renders the monitor list 1`] = `
+
+ +
+
+ + Status alert + +
+
+
+ Status alert +
+
+
+
+
+ +
+
+
+
+
+
+ Status alert +
+
+
+
+
+ +
+
+
+
+
- - Up - + Up diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/__snapshots__/enable_alert.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/__snapshots__/enable_alert.test.tsx.snap new file mode 100644 index 0000000000000..486414ab8a052 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/__snapshots__/enable_alert.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EnableAlertComponent renders without errors for valid props 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`EnableAlertComponent shallow renders without errors for valid props 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx new file mode 100644 index 0000000000000..4f41ea4c0b895 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EnableMonitorAlert } from '../enable_alert'; +import * as redux from 'react-redux'; +import { + mountWithRouterRedux, + renderWithRouterRedux, + shallowWithRouterRedux, +} from '../../../../../lib'; +import { EuiPopover, EuiText } from '@elastic/eui'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../../common/constants'; + +describe('EnableAlertComponent', () => { + let defaultConnectors: string[] = []; + let alerts: any = []; + + beforeEach(() => { + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); + + jest.spyOn(redux, 'useSelector').mockImplementation((fn, d) => { + if (fn.name === 'selectDynamicSettings') { + return { + settings: Object.assign(DYNAMIC_SETTINGS_DEFAULTS, { + defaultConnectors, + }), + }; + } + if (fn.name === 'alertsSelector') { + return { + data: { + data: alerts, + }, + loading: false, + }; + } + return {}; + }); + }); + + it('shallow renders without errors for valid props', () => { + const wrapper = shallowWithRouterRedux( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders without errors for valid props', () => { + const wrapper = renderWithRouterRedux( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('displays define connectors when there is none', () => { + defaultConnectors = []; + const wrapper = mountWithRouterRedux( + + ); + expect(wrapper.find(EuiPopover)).toHaveLength(1); + wrapper.find('button').simulate('click'); + expect(wrapper.find(EuiText).text()).toBe( + 'To start enabling alerts, please define a default alert action connector in Settings' + ); + }); + + it('does not displays define connectors when there is connector', () => { + defaultConnectors = ['infra-slack-connector-id']; + const wrapper = mountWithRouterRedux( + + ); + + expect(wrapper.find(EuiPopover)).toHaveLength(0); + }); + + it('displays disable when alert is there', () => { + alerts = [{ id: 'test-alert', params: { search: 'testMonitor' } }]; + defaultConnectors = ['infra-slack-connector-id']; + + const wrapper = mountWithRouterRedux( + + ); + + expect(wrapper.find('button').prop('aria-label')).toBe('Disable status alert'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx new file mode 100644 index 0000000000000..673588688db84 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiPopover, EuiSwitch, EuiText } from '@elastic/eui'; +import { useRouteMatch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ReactRouterEuiLink } from '../../../common/react_router_helpers'; +import { MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../../common/constants'; +import { ENABLE_STATUS_ALERT } from './translations'; +import { SETTINGS_LINK_TEXT } from '../../../../pages/page_header'; + +export const DefineAlertConnectors = () => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((val) => !val); + const closePopover = () => setIsPopoverOpen(false); + + const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + > + + {' '} + + {SETTINGS_LINK_TEXT} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx new file mode 100644 index 0000000000000..8a5a72891c3e7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { EuiLoadingSpinner, EuiToolTip, EuiSwitch } from '@elastic/eui'; +import { useRouteMatch } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectDynamicSettings } from '../../../../state/selectors'; +import { + alertsSelector, + connectorsSelector, + createAlertAction, + deleteAlertAction, + isAlertDeletedSelector, + newAlertSelector, +} from '../../../../state/alerts/alerts'; +import { MONITOR_ROUTE } from '../../../../../common/constants'; +import { DefineAlertConnectors } from './define_connectors'; +import { DISABLE_STATUS_ALERT, ENABLE_STATUS_ALERT } from './translations'; + +interface Props { + monitorId: string; + monitorName?: string; +} + +export const EnableMonitorAlert = ({ monitorId, monitorName }: Props) => { + const [isLoading, setIsLoading] = useState(false); + + const { settings } = useSelector(selectDynamicSettings); + + const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + + const dispatch = useDispatch(); + + const { data: actionConnectors } = useSelector(connectorsSelector); + + const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector); + + const { data: deletedAlertId } = useSelector(isAlertDeletedSelector); + + const { data: newAlert } = useSelector(newAlertSelector); + + const isNewAlert = newAlert?.params.search.includes(monitorId); + + let hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId)); + + if (isNewAlert) { + // if it's newly created alert, we assign that quickly without waiting for find alert result + hasAlert = newAlert!; + } + if (deletedAlertId === hasAlert?.id) { + // if it just got deleted, we assign that quickly without waiting for find alert result + hasAlert = undefined; + } + + const defaultActions = (actionConnectors ?? []).filter((act) => + settings?.defaultConnectors?.includes(act.id) + ); + + const enableAlert = () => { + dispatch( + createAlertAction.get({ + defaultActions, + monitorId, + monitorName, + }) + ); + setIsLoading(true); + }; + + const disableAlert = () => { + if (hasAlert) { + dispatch( + deleteAlertAction.get({ + alertId: hasAlert.id, + }) + ); + setIsLoading(true); + } + }; + + useEffect(() => { + setIsLoading(false); + }, [hasAlert, deletedAlertId]); + + const hasDefaultConnectors = (settings?.defaultConnectors ?? []).length > 0; + + const showSpinner = isLoading || (alertsLoading && !alerts); + + const onAlertClick = () => { + if (hasAlert) { + disableAlert(); + } else { + enableAlert(); + } + }; + const btnLabel = hasAlert ? DISABLE_STATUS_ALERT : ENABLE_STATUS_ALERT; + + return hasDefaultConnectors || hasAlert ? ( +
+ { + + <> + {' '} + {showSpinner && } + + + } +
+ ) : ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts new file mode 100644 index 0000000000000..421072ab603c2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ENABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.enableDownAlert', { + defaultMessage: 'Enable status alert', +}); + +export const DISABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.disableDownAlert', { + defaultMessage: 'Disable status alert', +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/filter_status_button.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/filter_status_button.tsx index 6e63c21d08ca9..19b09d50ced15 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/filter_status_button.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/filter_status_button.tsx @@ -15,7 +15,6 @@ export interface FilterStatusButtonProps { isActive: boolean; value: 'up' | 'down' | ''; withNext: boolean; - color?: string; } export const FilterStatusButton = ({ @@ -24,14 +23,12 @@ export const FilterStatusButton = ({ isDisabled, isActive, value, - color, withNext, }: FilterStatusButtonProps) => { const [getUrlParams, setUrlParams] = useUrlParams(); const { statusFilter: urlValue } = getUrlParams(); return ( { return !!filters ? labels.NO_MONITOR_ITEM_SELECTED : labels.NO_DATA_MESSAGE; }; -export const MonitorListComponent: React.FC = ({ +export const MonitorListComponent: ({ + filters, + monitorList: { list, error, loading }, + linkParameters, + pageSize, + setPageSize, +}: Props) => any = ({ filters, monitorList: { list, error, loading }, linkParameters, @@ -69,7 +77,7 @@ export const MonitorListComponent: React.FC = ({ ...map, [id]: ( monitorId === id)} + summary={items.find(({ monitor_id: monitorId }) => monitorId === id)!} /> ), }; @@ -135,6 +143,18 @@ export const MonitorListComponent: React.FC = ({ ), }, + { + align: 'center' as const, + field: '', + name: STATUS_ALERT_COLUMN, + width: '150px', + render: (item: MonitorSummary) => ( + + ), + }, { align: 'right' as const, field: 'monitor_id', diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap index 42c885dfaf515..e4450e67ae5b3 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap @@ -86,6 +86,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are } > Get https://expired.badssl.com: x509: certificate has expired or is not yet valid diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx index 4b981664cf9ad..4e8ffc64cfe92 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx @@ -52,7 +52,11 @@ describe('MonitorListDrawer component', () => { it('renders nothing when no summary data is present', () => { const component = shallowWithRouter( - + ); expect(component).toEqual({}); }); @@ -61,14 +65,22 @@ describe('MonitorListDrawer component', () => { // @ts-expect-error According to the code, the property is optional delete summary.state.summaryPings; const component = shallowWithRouter( - + ); expect(component).toEqual({}); }); it('renders a MonitorListDrawer when there is only one check', () => { const component = shallowWithRouter( - + ); expect(component).toMatchSnapshot(); }); @@ -89,7 +101,11 @@ describe('MonitorListDrawer component', () => { } const component = shallowWithRouter( - + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx new file mode 100644 index 0000000000000..d869c6d78ec11 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiCallOut, EuiListGroup, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item'; +import { i18n } from '@kbn/i18n'; +import { UptimeSettingsContext } from '../../../../contexts'; +import { Alert } from '../../../../../../triggers_actions_ui/public'; + +interface Props { + monitorAlerts: Alert[]; + loading: boolean; +} + +export const EnabledAlerts = ({ monitorAlerts, loading }: Props) => { + const { basePath } = useContext(UptimeSettingsContext); + + const listItems: EuiListGroupItemProps[] = []; + + (monitorAlerts ?? []).forEach((alert, ind) => { + listItems.push({ + label: alert.name, + href: basePath + '/app/management/insightsAndAlerting/triggersActions/alert/' + alert.id, + size: 's', + 'data-test-subj': 'uptimeMonitorListDrawerAlert' + ind, + }); + }); + + return ( + <> + + + +

+ {i18n.translate('xpack.uptime.monitorList.enabledAlerts.title', { + defaultMessage: 'Enabled alerts:', + description: 'Alerts enabled for this monitor', + })} +

+
+
+ {listItems.length === 0 && !loading && ( + + )} + {loading ? : } + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx index bec32ace27f2b..fd68a487a21e4 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx @@ -4,44 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; -import { connect } from 'react-redux'; +import React, { useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { AppState } from '../../../../state'; -import { monitorDetailsSelector } from '../../../../state/selectors'; -import { MonitorDetailsActionPayload } from '../../../../state/actions/types'; +import { monitorDetailsLoadingSelector, monitorDetailsSelector } from '../../../../state/selectors'; import { getMonitorDetailsAction } from '../../../../state/actions/monitor'; import { MonitorListDrawerComponent } from './monitor_list_drawer'; import { useGetUrlParams } from '../../../../hooks'; -import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types'; +import { MonitorSummary } from '../../../../../common/runtime_types'; +import { alertsSelector } from '../../../../state/alerts/alerts'; +import { UptimeRefreshContext } from '../../../../contexts'; interface ContainerProps { summary: MonitorSummary; - monitorDetails: MonitorDetails; - loadMonitorDetails: typeof getMonitorDetailsAction; } -const Container: React.FC = ({ summary, loadMonitorDetails, monitorDetails }) => { +export const MonitorListDrawer: React.FC = ({ summary }) => { + const { lastRefresh } = useContext(UptimeRefreshContext); + const monitorId = summary?.monitor_id; const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); - useEffect(() => { - loadMonitorDetails({ - dateStart, - dateEnd, - monitorId, - }); - }, [dateStart, dateEnd, monitorId, loadMonitorDetails]); - return ; -}; + const monitorDetails = useSelector((state: AppState) => monitorDetailsSelector(state, summary)); + + const isLoading = useSelector(monitorDetailsLoadingSelector); -const mapStateToProps = (state: AppState, { summary }: any) => ({ - monitorDetails: monitorDetailsSelector(state, summary), -}); + const dispatch = useDispatch(); -const mapDispatchToProps = (dispatch: any) => ({ - loadMonitorDetails: (actionPayload: MonitorDetailsActionPayload) => - dispatch(getMonitorDetailsAction(actionPayload)), -}); + const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector); -export const MonitorListDrawer = connect(mapStateToProps, mapDispatchToProps)(Container); + const hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId)); + + useEffect(() => { + dispatch( + getMonitorDetailsAction.get({ + dateStart, + dateEnd, + monitorId, + }) + ); + }, [dateStart, dateEnd, monitorId, dispatch, hasAlert, lastRefresh]); + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx index 305455c8ba573..4b359099bc58c 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx @@ -6,11 +6,13 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiLink, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; import { MostRecentError } from './most_recent_error'; import { MonitorStatusList } from './monitor_status_list'; import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types'; import { ActionsPopover } from './actions_popover/actions_popover_container'; +import { EnabledAlerts } from './enabled_alerts'; +import { Alert } from '../../../../../../triggers_actions_ui/public'; const ContainerDiv = styled.div` padding: 10px; @@ -27,13 +29,18 @@ interface MonitorListDrawerProps { * Monitor details to be fetched from rest api using monitorId */ monitorDetails: MonitorDetails; + loading: boolean; } /** * The elements shown when the user expands the monitor list rows. */ -export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorListDrawerProps) { +export function MonitorListDrawerComponent({ + summary, + monitorDetails, + loading, +}: MonitorListDrawerProps) { const monitorUrl = summary?.state?.url?.full || ''; return summary && summary.state.summaryPings ? ( @@ -51,8 +58,8 @@ export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorL - + {monitorDetails && monitorDetails.error && ( { isActive={statusFilter === ''} /> - {i18n.translate('xpack.uptime.filterBar.filterUpLabel', { - defaultMessage: 'Up', - })} - - } + content={i18n.translate('xpack.uptime.filterBar.filterUpLabel', { + defaultMessage: 'Up', + })} dataTestSubj="xpack.uptime.filterBar.filterStatusUp" value="up" withNext={true} @@ -47,7 +43,6 @@ export const StatusFilter: React.FC = () => { dataTestSubj="xpack.uptime.filterBar.filterStatusDown" value="down" withNext={false} - color={'danger'} isActive={statusFilter === 'down'} /> diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts b/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts index ee922a9ef803f..559d4bc6514df 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts @@ -76,3 +76,7 @@ export const RESPONSE_ANOMALY_SCORE = i18n.translate( defaultMessage: 'Response Anomaly Score', } ); + +export const STATUS_ALERT_COLUMN = i18n.translate('xpack.uptime.monitorList.statusAlert.label', { + defaultMessage: 'Status alert', +}); diff --git a/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap b/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap index 55c66183de946..52815be86cdcd 100644 --- a/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap @@ -91,6 +91,7 @@ exports[`CertificateForm shallow renders expected elements for valid props 1`] = Object { "certAgeThreshold": 36, "certExpirationThreshold": 7, + "defaultConnectors": Array [], "heartbeatIndices": "heartbeat-8*", } } diff --git a/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap b/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap index 2eb434ffbaf40..687865b32a46e 100644 --- a/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap @@ -91,6 +91,7 @@ exports[`CertificateForm shallow renders expected elements for valid props 1`] = Object { "certAgeThreshold": 36, "certExpirationThreshold": 7, + "defaultConnectors": Array [], "heartbeatIndices": "heartbeat-8*", } } diff --git a/x-pack/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx b/x-pack/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx index 2b587d23247e2..9a67bf28f33de 100644 --- a/x-pack/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx +++ b/x-pack/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx @@ -19,6 +19,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} @@ -37,6 +38,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} @@ -90,6 +92,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} diff --git a/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx b/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx index 68a0d96d491b6..01b66263d3e93 100644 --- a/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx +++ b/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx @@ -19,6 +19,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certAgeThreshold: 36, certExpirationThreshold: 7, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} diff --git a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx new file mode 100644 index 0000000000000..9dc64374f4d63 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useDispatch } from 'react-redux'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { HttpStart, DocLinksStart, NotificationsStart, ApplicationStart } from 'src/core/public'; +import { + ActionsConnectorsContextProvider, + ConnectorAddFlyout, + TriggersAndActionsUIPublicPluginStart, +} from '../../../../triggers_actions_ui/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { getConnectorsAction } from '../../state/alerts/alerts'; + +interface Props { + focusInput: () => void; +} +interface KibanaDeps { + triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + application: ApplicationStart; + docLinks: DocLinksStart; + http: HttpStart; + notifications: NotificationsStart; +} + +export const AddConnectorFlyout = ({ focusInput }: Props) => { + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + + const { + services: { + triggers_actions_ui: { actionTypeRegistry }, + application, + docLinks, + http, + notifications, + }, + } = useKibana(); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getConnectorsAction.get()); + focusInput(); + }, [addFlyoutVisible, dispatch, focusInput]); + + return ( + <> + setAddFlyoutVisibility(true)} + > + + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx b/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx new file mode 100644 index 0000000000000..7636d4ede4bab --- /dev/null +++ b/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiTitle, + EuiSpacer, + EuiComboBox, + EuiIcon, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { SettingsFormProps } from '../../pages/settings'; +import { connectorsSelector } from '../../state/alerts/alerts'; +import { AddConnectorFlyout } from './add_connector_flyout'; +import { useGetUrlParams, useUrlParams } from '../../hooks'; +import { alertFormI18n } from './translations'; +import { useInitApp } from '../../hooks/use_init_app'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public/'; + +type ConnectorOption = EuiComboBoxOptionOption; + +interface KibanaDeps { + triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +} + +const ConnectorSpan = styled.span` + .euiIcon { + margin-right: 5px; + } + > img { + width: 16px; + height: 20px; + } +`; + +export const AlertDefaultsForm: React.FC = ({ + onChange, + loading, + formFields, + fieldErrors, + isDisabled, +}) => { + const { + services: { + triggers_actions_ui: { actionTypeRegistry }, + }, + } = useKibana(); + const { focusConnectorField } = useGetUrlParams(); + + const updateUrlParams = useUrlParams()[1]; + + const inputRef = useRef(null); + + useInitApp(); + + useEffect(() => { + if (focusConnectorField && inputRef.current && !loading) { + inputRef.current.focus(); + } + }, [focusConnectorField, inputRef, loading]); + + const { data = [] } = useSelector(connectorsSelector); + + const [error, setError] = useState(undefined); + + const onBlur = () => { + if (inputRef.current) { + const { value } = inputRef.current; + setError(value.length === 0 ? undefined : `"${value}" is not a valid option`); + } + if (inputRef.current && !loading && focusConnectorField) { + updateUrlParams({ focusConnectorField: undefined }); + } + }; + + const onSearchChange = (value: string, hasMatchingOptions?: boolean) => { + setError( + value.length === 0 || hasMatchingOptions ? undefined : `"${value}" is not a valid option` + ); + }; + + const options = (data ?? []).map((connectorAction) => ({ + value: connectorAction.id, + label: connectorAction.name, + 'data-test-subj': connectorAction.name, + })); + + const renderOption = (option: ConnectorOption) => { + const { label, value } = option; + + const { actionTypeId: type } = data?.find((dt) => dt.id === value) ?? {}; + return ( + + + {label} + + ); + }; + + const onOptionChange = (selectedOptions: ConnectorOption[]) => { + onChange({ + defaultConnectors: selectedOptions.map((item) => { + const conOpt = data?.find((dt) => dt.id === item.value)!; + return conOpt.id; + }), + }); + }; + + return ( + <> + +

+ +

+
+ + + + + } + description={ + + } + > + + } + > + + formFields?.defaultConnectors?.includes(opt.value) + )} + inputRef={(input) => { + inputRef.current = input; + }} + onSearchChange={onSearchChange} + onBlur={onBlur} + isLoading={loading} + isDisabled={isDisabled} + onChange={onOptionChange} + data-test-subj={`default-connectors-input-${loading ? 'loading' : 'loaded'}`} + renderOption={renderOption} + /> + + + { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [])} + /> + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/settings/translations.ts b/x-pack/plugins/uptime/public/components/settings/translations.ts index 2de25a44165c6..f9f3b0b6af9a9 100644 --- a/x-pack/plugins/uptime/public/components/settings/translations.ts +++ b/x-pack/plugins/uptime/public/components/settings/translations.ts @@ -22,3 +22,12 @@ export const certificateFormTranslations = { } ), }; + +export const alertFormI18n = { + inputPlaceHolder: i18n.translate( + 'xpack.uptime.sourceConfiguration.alertDefaultForm.selectConnector', + { + defaultMessage: 'Please select one or more connectors', + } + ), +}; diff --git a/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap b/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap index 5d2565b7210da..5bbb606b6142f 100644 --- a/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap @@ -209,7 +209,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = ` } >
- {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo"} + {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo","focusConnectorField":false}