diff --git a/.buildkite/scripts/post_build_kibana.sh b/.buildkite/scripts/post_build_kibana.sh index 5f26c80ddb6b66..d8b297935471a5 100755 --- a/.buildkite/scripts/post_build_kibana.sh +++ b/.buildkite/scripts/post_build_kibana.sh @@ -13,5 +13,5 @@ echo "--- Upload Build Artifacts" # Moving to `target/` first will keep `buildkite-agent` from including directories in the artifact name cd "$KIBANA_DIR/target" cp kibana-*-linux-x86_64.tar.gz kibana-default.tar.gz -buildkite-agent artifact upload "./*.tar.gz;./*.zip" +buildkite-agent artifact upload "./*.tar.gz;./*.zip;./*.deb;./*.rpm" cd - diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e2a99bfc7e4836..ad992066f8503c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -409,15 +409,13 @@ /x-pack/plugins/security_solution/public/common/lib/endpoint*/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/public/common/components/endpoint/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/common/endpoint/ @elastic/security-onboarding-and-lifecycle-mgt -/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/ @elastic/security-onboarding-and-lifecycle-mgt -/x-pack/plugins/security_solution/server/endpoint/routes/actions/ @elastic/security-onboarding-and-lifecycle-mgt -/x-pack/plugins/security_solution/server/endpoint/routes/metadata/ @elastic/security-onboarding-and-lifecycle-mgt -/x-pack/plugins/security_solution/server/endpoint/lib/policy/ @elastic/security-onboarding-and-lifecycle-mgt +/x-pack/plugins/security_solution/server/endpoint/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/server/lib/license/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/server/fleet_integration/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/scripts/endpoint/event_filters/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/ @elastic/security-onboarding-and-lifecycle-mgt /x-pack/test/security_solution_endpoint/apps/endpoint/ @elastic/security-onboarding-and-lifecycle-mgt +/x-pack/test/security_solution_endpoint_api_int/ @elastic/security-onboarding-and-lifecycle-mgt ## Security Solution sub teams - security-telemetry (Data Engineering) x-pack/plugins/security_solution/server/usage/ @elastic/security-telemetry diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index fcb4b119c06c67..2185dfd4d21a45 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -6,7 +6,7 @@ Manage {kib} saved objects, including dashboards, visualizations, and more. WARNING: Do not write documents directly to the `.kibana` index. When you write directly to the `.kibana` index, the data becomes corrupted and permanently breaks future {kib} versions. -NOTE: For managing {kib} index patterns, use the <>. +NOTE: For managing {data-sources}, use the <>. The following saved objects APIs are available: diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 471e2973165783..dcb402ee1a1dc4 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -72,7 +72,7 @@ Saved objects that are unable to persist are replaced with an error object. [[saved-objects-api-bulk-create-example]] ==== Example -Create an index pattern with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID: +Create {a-data-source} with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID: [source,sh] -------------------------------------------------- @@ -122,7 +122,7 @@ The API returns the following: } -------------------------------------------------- -There is already a saved object with the `my-dashboard` ID, so only the index pattern is created. +There is already a saved object with the `my-dashboard` ID, so only the {data-source} is created. [[saved-objects-api-bulk-create-conflict-errors]] ==== Conflict errors diff --git a/docs/api/saved-objects/bulk_get.asciidoc b/docs/api/saved-objects/bulk_get.asciidoc index 65cd93fe212f80..bec503eb18fe65 100644 --- a/docs/api/saved-objects/bulk_get.asciidoc +++ b/docs/api/saved-objects/bulk_get.asciidoc @@ -59,7 +59,7 @@ Saved objects that are unable to persist are replaced with an error object. [[saved-objects-api-bulk-get-body-example]] ==== Example -Retrieve an index pattern with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID: +Retrieve a {data-source} with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID: [source,sh] -------------------------------------------------- @@ -103,4 +103,4 @@ The API returns the following: } -------------------------------------------------- -Only the index pattern exists. +Only the {data-source} exists. diff --git a/docs/api/saved-objects/delete.asciidoc b/docs/api/saved-objects/delete.asciidoc index ab50fd6e37eac1..eca46e202bedaf 100644 --- a/docs/api/saved-objects/delete.asciidoc +++ b/docs/api/saved-objects/delete.asciidoc @@ -43,7 +43,7 @@ TIP: Use this if you attempted to delete an object and received an HTTP 400 erro ==== Example -Delete an index pattern object with the `my-pattern` ID: +Delete {a-data-source} object with the `my-pattern` ID: [source,sh] -------------------------------------------------- diff --git a/docs/api/saved-objects/export.asciidoc b/docs/api/saved-objects/export.asciidoc index 667a9c57ca802c..53910835c49891 100644 --- a/docs/api/saved-objects/export.asciidoc +++ b/docs/api/saved-objects/export.asciidoc @@ -67,7 +67,7 @@ When `excludeExportDetails=false` (the default) we append an export result detai [[ssaved-objects-api-create-example]] ==== Examples -Export all index pattern saved objects: +Export all {data-source} saved objects: [source,sh] -------------------------------------------------- @@ -78,7 +78,7 @@ $ curl -X POST api/saved_objects/_export -H 'kbn-xsrf: true' -H 'Content-Type: a -------------------------------------------------- // KIBANA -Export all index pattern saved objects and exclude the export summary from the stream: +Export all {data-source} saved objects and exclude the export summary from the stream: [source,sh] -------------------------------------------------- diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index f04aeb84206207..43c7f4cde8fa87 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -73,7 +73,7 @@ change. Use the find API for traditional paginated results, but avoid using it t ==== Examples -Find index patterns with titles that start with `my`: +Find {data-sources} with titles that start with `my`: [source,sh] -------------------------------------------------- diff --git a/docs/api/saved-objects/get.asciidoc b/docs/api/saved-objects/get.asciidoc index cfc591d811227d..174587b400993e 100644 --- a/docs/api/saved-objects/get.asciidoc +++ b/docs/api/saved-objects/get.asciidoc @@ -35,7 +35,7 @@ experimental[] Retrieve a single {kib} saved object by ID. [[saved-objects-api-get-example]] ==== Example -Retrieve the index pattern object with the `my-pattern` ID: +Retrieve the {data-source} object with the `my-pattern` ID: [source,sh] -------------------------------------------------- diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index a214598af31afe..8a6ef5b26e4244 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -95,7 +95,7 @@ to resolve errors, refer to the <>. [[saved-objects-api-import-example-1]] ===== Successful import with `createNewCopies` enabled -Import an index pattern and dashboard: +Import {a-data-source} and dashboard: [source,sh] -------------------------------------------------- @@ -147,7 +147,7 @@ The result indicates a successful import, and both objects are created. Since th [[saved-objects-api-import-example-2]] ===== Successful import with `createNewCopies` disabled -Import an index pattern and dashboard: +Import {a-data-source} and dashboard: [source,sh] -------------------------------------------------- @@ -196,7 +196,7 @@ 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: +Import {a-data-source}, visualization, *Canvas* workpad, and dashboard that include saved objects: [source,sh] -------------------------------------------------- @@ -285,10 +285,10 @@ The API returns the following: } -------------------------------------------------- -The result indicates an unsuccessful import because the index pattern, visualization, *Canvas* workpad, and dashboard resulted in a conflict +The result indicates an unsuccessful import because the {data-source}, 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, +* A {data-source} 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 @@ -307,7 +307,7 @@ Objects are created when the error is resolved using the <>. This example builds upon the <>. -Resolve conflict errors for an index pattern, visualization, and *Canvas* workpad by overwriting the existing saved objects: +Resolve conflict errors for {a-data-source}, visualization, and *Canvas* workpad by overwriting the existing saved objects: [source,sh] -------------------------------------------------- @@ -173,7 +173,7 @@ that were returned in the `successResults` array. In this example, we retried im This example builds upon the <>. -Resolve a missing reference error for a visualization by replacing the index pattern with another, and resolve a missing reference error for +Resolve a missing reference error for a visualization by replacing the {data-source} with another, and resolve a missing reference error for a search by ignoring it: [source,sh] diff --git a/docs/api/saved-objects/update.asciidoc b/docs/api/saved-objects/update.asciidoc index fccc6112948a10..fb7decccabd4cd 100644 --- a/docs/api/saved-objects/update.asciidoc +++ b/docs/api/saved-objects/update.asciidoc @@ -54,7 +54,7 @@ WARNING: When you update, attributes are not validated, which allows you to pass [[saved-objects-api-update-example]] ==== Example -Update an existing index pattern object,`my-pattern`, with a different title: +Update an existing {data-source} object,`my-pattern`, with a different title: [source,sh] -------------------------------------------------- diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index cf18af9b28a34e..0aa6ac58c55cbe 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -8,7 +8,7 @@ experimental[] Copy saved objects between spaces. It also allows you to automatically copy related objects, so when you copy a `dashboard`, this can automatically copy over the -associated visualizations, index patterns, and saved searches, as required. +associated visualizations, {data-sources}, and saved searches, as required. You can request to overwrite any objects that already exist in the target space if they share an ID, or you can use the <> to do this on a per-object basis. @@ -125,7 +125,7 @@ refer to the <>. ===== 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: +the dashboard has a reference to a visualization, and that has a reference to {a-data-source}: [source,sh] ---- @@ -189,7 +189,7 @@ The result indicates a successful copy, and all three objects are created. Since ===== 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: +the dashboard has a reference to a visualization, and that has a reference to {a-data-source}: [source,sh] ---- @@ -379,10 +379,10 @@ The API returns the following: } ---- -The result indicates a successful copy for the `marketing` space, and an unsuccessful copy for the `sales` space because the index pattern, +The result indicates a successful copy for the `marketing` space, and an unsuccessful copy for the `sales` space because the {data-source}, 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, +* A {data-source} 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 @@ -403,7 +403,7 @@ API>>. ===== 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: +the dashboard has a reference to a visualization and a *Canvas* workpad, and the visualization has a reference to {a-data-source}: [source,sh] ---- 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 1a0017fe167ab4..d79df2c085b193 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -141,7 +141,7 @@ refer to the < This example builds upon the <>. -Resolve conflict errors for an index pattern, visualization, and *Canvas* workpad by overwriting the existing saved objects: +Resolve conflict errors for {a-data-source}, visualization, and *Canvas* workpad by overwriting the existing saved objects: [source,sh] ---- diff --git a/docs/apm/api.asciidoc b/docs/apm/api.asciidoc index a7e2a93e0944eb..43ba30cb7cf83c 100644 --- a/docs/apm/api.asciidoc +++ b/docs/apm/api.asciidoc @@ -11,6 +11,7 @@ Some APM app features are provided via a REST API: * <> * <> * <> +* <> [float] [[apm-api-example]] @@ -708,3 +709,105 @@ curl -X DELETE "http://localhost:5601/api/apm/sourcemaps/apm:foo-1.0.0-644fd5a9" -------------------------------------------------- {} -------------------------------------------------- + +//// +******************************************************* +******************************************************* +//// + +[role="xpack"] +[[agent-key-api]] +=== APM agent Key API + +The Agent Key API allows you to configure agent keys to authorize requests from APM agents to the APM Server. + +The following Agent key APIs are available: + +* <> to create an agent key + +[float] +[[use-agent-key-api]] +==== How to use APM APIs + +.Expand for required headers, privileges, and usage details +[%collapsible%closed] +====== +include::api.asciidoc[tag=using-the-APIs] +====== + +//// +******************************************************* +//// + +[[apm-create-agent-key]] +==== Create agent key + +Create an APM agent key. Specify privileges in the request body at creation time. + +[[apm-create-agent-key-privileges]] +===== Privileges + +Users must have at least the `manage_own_api_key` cluster privilege and the required application privileges. + +====== Example role: + +[source,js] +-------------------------------------------------- +POST /_security/role/apm_agent_key_user +{ + "cluster": ["manage_own_api_key"], + "applications": [{ + "application": "apm", + "privileges": ["event:write", "sourcemap:write", "config_agent:read"], + "resources": ["*"] + }] +} +-------------------------------------------------- + +[[apm-create-agent-key-req]] +===== Request + +`POST /api/apm/agent_keys` + +[role="child_attributes"] +[[apm-create-agent-key-req-body]] +===== Request body + +`name`:: +(required, string) Name of the agent key. + +`privileges`:: +(required, array) APM agent key privileges. It can take one or more of the following values: + + - `event:write`. Required for ingesting agent events. + - `config_agent:read`. Required for agents to read agent configuration remotely. + - `sourcemap:write`. Required for uploading sourcemaps. + +[[apm-agent-key-create-example]] +===== Example + +[source,curl] +-------------------------------------------------- +POST /api/apm/agent_keys +{ + "name": "apm-key", + "privileges": ["event:write", "config_agent:read", "sourcemap:write"] +} +-------------------------------------------------- + +[[apm-agent-key-create-body]] +===== Response body + +[source,js] +-------------------------------------------------- +{ + "agentKey": { + "id": "3DCLmn0B3ZMhLUa7WBG9", + "name": "apm-key", + "api_key": "PjGloCGOTzaZr8ilUPvkjA", + "encoded": "M0RDTG1uMEIzWk1oTFVhN1dCRzk6UGpHbG9DR09UemFacjhpbFVQdmtqQQ==" + } +} +-------------------------------------------------- + +Once created, you can copy the API key (Base64 encoded) and use it to to authorize requests from APM agents to the APM Server. \ No newline at end of file diff --git a/docs/concepts/data-views.asciidoc b/docs/concepts/data-views.asciidoc index 870b923f20cf47..98bbba5392b15b 100644 --- a/docs/concepts/data-views.asciidoc +++ b/docs/concepts/data-views.asciidoc @@ -12,10 +12,10 @@ or all indices that contain your data. [[data-views-read-only-access]] === Required permissions -* Access to *Data Views* requires the <> +* Access to *Data Views* requires the <> `Data View Management`. -* To create a data view, you must have the <> +* To create a data view, you must have the <> `view_index_metadata`. * If a read-only indicator appears in {kib}, you have insufficient privileges diff --git a/docs/management/connectors/action-types/index.asciidoc b/docs/management/connectors/action-types/index.asciidoc index 7868085ef9c96f..98f7dac4de81d1 100644 --- a/docs/management/connectors/action-types/index.asciidoc +++ b/docs/management/connectors/action-types/index.asciidoc @@ -74,7 +74,7 @@ Example of the index document for Index Threshold rule: "rule_name": "{{ruleName}}", "alert_id": "{{alertId}}", "context_message": "{{context.message}}" -} +} -------------------------------------------------- Example of creating a test index using the API. @@ -108,7 +108,7 @@ experimental[] {kib} offers a preconfigured index connector to facilitate indexi This functionality is experimental and may be changed or removed completely in a future release. ================================================== -To use this connector, set the <> configuration to `true`. +To use this connector, set the <> configuration to `true`. ```js xpack.actions.preconfiguredAlertHistoryEsIndex: true @@ -123,11 +123,11 @@ Documents are indexed using a preconfigured schema that captures the <> for more information. +To write documents to the preconfigured index, you must have `all` or `write` privileges to the `kibana-alert-history-*` indices. Refer to <> for more information. ============================================== [NOTE] ================================================== The `kibana-alert-history-*` indices are not configured to use ILM so they must be maintained manually. If the index size grows large, consider using the {ref}/docs-delete-by-query.html[delete by query] API to clean up older documents in the index. -================================================== \ No newline at end of file +================================================== diff --git a/docs/maps/import-geospatial-data.asciidoc b/docs/maps/import-geospatial-data.asciidoc index c7b12c4ac32f6c..58d9ca2255dd30 100644 --- a/docs/maps/import-geospatial-data.asciidoc +++ b/docs/maps/import-geospatial-data.asciidoc @@ -17,7 +17,7 @@ The {stack-security-features} provide roles and privileges that control which us You can manage your roles, privileges, and spaces in **{stack-manage-app}** in {kib}. For more information, see {ref}/security-privileges.html[Security privileges], -<>, and <>. +<>, and <>. To upload GeoJSON files in {kib} with *Maps*, you must have: diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 6fafe1ce2506d7..5d161711719ac7 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -416,27 +416,32 @@ This content has moved. Refer to <>. This content has moved. Refer to <>. -[role="exclude",id="index-patterns-runtime-field-api-delete] +[role="exclude",id="index-patterns-runtime-field-api-delete"] == Index patterns has been renamed to data views. This content has moved. Refer to <>. -[role="exclude",id="index-patterns-runtime-field-api-get] +[role="exclude",id="index-patterns-runtime-field-api-get"] == Index patterns has been renamed to data views. This content has moved. Refer to <>. -[role="exclude",id="index-patterns-runtime-field-api-update] +[role="exclude",id="index-patterns-runtime-field-api-update"] == Index patterns has been renamed to data views. This content has moved. Refer to <>. -[role="exclude",id="index-patterns-runtime-field-api-upsert] +[role="exclude",id="index-patterns-runtime-field-api-upsert"] == Index patterns has been renamed to data views. This content has moved. Refer to <>. -[role="exclude",id="index-patterns-api-update] +[role="exclude",id="index-patterns-api-update"] == Index patterns has been renamed to data views. This content has moved. Refer to <>. + +[role="exclude",id="xpack-kibana-role-management"] +== Kibana role management. + +This content has moved. Refer to <>. diff --git a/docs/user/dashboard/aggregation-based.asciidoc b/docs/user/dashboard/aggregation-based.asciidoc index 351e1f5d0825a3..c4f26e701bccf0 100644 --- a/docs/user/dashboard/aggregation-based.asciidoc +++ b/docs/user/dashboard/aggregation-based.asciidoc @@ -114,7 +114,7 @@ Choose the type of visualization you want to create, then use the editor to conf . Add the <> you want to visualize using the editor, then click *Update*. + -NOTE: For the *Date Histogram* to use an *auto interval*, the date field must match the primary time field of the index pattern. +NOTE: For the *Date Histogram* to use an *auto interval*, the date field must match the primary time field of the {data-source}. . To change the order, drag and drop the aggregations in the editor. + diff --git a/docs/user/dashboard/images/lens_dataViewDropDown_8.0.png b/docs/user/dashboard/images/lens_dataViewDropDown_8.0.png new file mode 100644 index 00000000000000..309e1be49b9db7 Binary files /dev/null and b/docs/user/dashboard/images/lens_dataViewDropDown_8.0.png differ diff --git a/docs/user/dashboard/images/lens_indexPatternDropDown_7.16.png b/docs/user/dashboard/images/lens_indexPatternDropDown_7.16.png deleted file mode 100644 index f8e797c7dd4b65..00000000000000 Binary files a/docs/user/dashboard/images/lens_indexPatternDropDown_7.16.png and /dev/null differ diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index 324676ecb0a8e6..eaa20147177141 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -34,7 +34,7 @@ Open the visualization editor, then make sure the correct fields appear. . On the dashboard, click *Create visualization*. -. Make sure the *kibana_sample_data_ecommerce* index appears, then set the <> to *Last 30 days*. +. Make sure the *kibana_sample_data_ecommerce* {data-source} appears, then set the <> to *Last 30 days*. [discrete] [[custom-time-interval]] diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 1b0bbf866b8526..1fcc3eb797b591 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -48,7 +48,7 @@ Choose the data you want to visualize. . If you want to learn more about the data a field contains, click the field. -. To visualize more than one index pattern, click *Add layer > Add visualization layer*, then select the index pattern. +. To visualize more than one {data-source}, click *Add layer > Add visualization layer*, then select the {data-source}. Edit and delete. @@ -60,18 +60,18 @@ Edit and delete. [[change-the-fields]] ==== Change the fields list -Change the fields list to display a different index pattern, different time range, or add your own fields. +Change the fields list to display a different {data-source}, different time range, or add your own fields. -* To create a visualization with fields in a different index pattern, open the *Index pattern* dropdown, then select the index pattern. +* To create a visualization with fields in a different {data-source}, open the *Data view* dropdown, then select the {data-source}. * If the fields list is empty, change the <>. -* To add fields, open the action menu (*...*) next to the *Index pattern* dropdown, then select *Add field to index pattern*. +* To add fields, open the action menu (*...*) next to the *Data view* dropdown, then select *Add field to {data-source}*. + [role="screenshot"] -image:images/runtime-field-menu.png[Dropdown menu located next to index pattern field with items for adding and managing fields, width=50%] +image:images/runtime-field-menu.png[Dropdown menu located next to {data-source} field with items for adding and managing fields, width=50%] + -For more information about adding fields to index patterns and examples, refer to <>. +For more information about adding fields to {data-sources} and examples, refer to <>. [float] [[create-custom-tables]] @@ -453,7 +453,7 @@ To configure the bounds, use the menus in the editor toolbar. Bar and area chart .*Is it possible to display icons in data tables?* [%collapsible] ==== -You can display icons with <> in data tables. +You can display icons with <> in data tables. ==== [discrete] diff --git a/docs/user/dashboard/timelion.asciidoc b/docs/user/dashboard/timelion.asciidoc index 8d89adc454d63e..19962d11f73355 100644 --- a/docs/user/dashboard/timelion.asciidoc +++ b/docs/user/dashboard/timelion.asciidoc @@ -34,7 +34,7 @@ The fist parameter of the .es function is the parameter q (for query), which is .es(*) .es(q=*) -Multiple parameters are separated by comma. The .es function has another parameter called index, that can be used to specify an index pattern for this series, so the query won't be executed again all indexes (or whatever you changed the above mentioned setting to). +Multiple parameters are separated by a comma. The .es function has another parameter called index, that can be used to specify {a-data-source} for this series, so the query won't be executed against all indexes (or whatever you changed the setting to). .es(q=*, index=logstash-*) diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index 1c90c28826f6ed..a1bad870dde46a 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -16,7 +16,7 @@ With *TSVB*, you can: image::images/tsvb-screenshot.png[TSVB overview] [float] -[[tsvb-index-pattern-mode]] +[[tsvb-data-view-mode]] ==== Open and set up TSVB Open *TSVB*, then configure the required settings. You can create *TSVB* visualizations with only {data-sources}, or {es} index strings. @@ -31,17 +31,19 @@ When you use only {data-sources}, you are able to: * Improve performance +[[tsvb-index-pattern-mode]] + IMPORTANT: Creating *TSVB* visualizations with an {es} index string is deprecated and will be removed in a future release. By default, you create *TSVB* visualizations with only {data-sources}. To use an {es} index string, contact your administrator, or go to <> and set `metrics:allowStringIndices` to `true`. . On the dashboard, click *All types*, then select *TSVB*. . In *TSVB*, click *Panel options*, then specify the *Data* settings. -. Open the *Index pattern selection mode* options next to the *Index pattern* dropdown. +. Open the *Data view mode* options next to the *Data view* dropdown. . Select *Use only {kib} {data-sources}*. -. From the *Index pattern* drodpown, select the {data-source}, then select the *Time field* and *Interval*. +. From the *Data view* drodpown, select the {data-source}, then select the *Time field* and *Interval*. . Select a *Drop last bucket* option. + @@ -258,9 +260,9 @@ Calculating the duration between the start and end of an event is unsupported in [%collapsible] ==== -To group with multiple fields, create runtime fields in the index pattern you are visualizing. +To group with multiple fields, create runtime fields in the {data-source} you are visualizing. -. Create a runtime field. Refer to <> for more information. +. Create a runtime field. Refer to <> for more information. + [role="screenshot"] image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields] diff --git a/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc b/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc index e270c16cf60f6b..4d36647c808b30 100644 --- a/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc +++ b/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc @@ -41,7 +41,7 @@ Open the visualization editor, then make sure the correct fields appear. . Make sure the *kibana_sample_data_logs* index appears. + [role="screenshot"] -image::images/lens_indexPatternDropDown_7.16.png[Index pattern dropdown] +image::images/lens_dataViewDropDown_8.0.png[Data view dropdown] To create the visualizations in this tutorial, you'll use the following fields: @@ -96,7 +96,7 @@ image::images/lens_metricUniqueVisitors_7.16.png[Metric visualization that displ There are two shortcuts you can use to view metrics over time. When you drag a numeric field to the workspace, the visualization editor adds the default -time field from the index pattern. When you use the *Date histogram* function, you can +time field from the {data-source}. When you use the *Date histogram* function, you can replace the time field by dragging the field to the workspace. To visualize the *bytes* field over time: diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 7a092b4686e2d2..99cbf6f8eb5332 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -202,7 +202,7 @@ Tip: Use in combination with <> helper to format date. | | context.panel.indexPatternId + context.panel.indexPatternIds -|Index pattern ids used by a panel. +|The {data-source} IDs used by a panel. | | context.panel.savedObjectId diff --git a/docs/user/dashboard/vega.asciidoc b/docs/user/dashboard/vega.asciidoc index cd893dfe8d944e..fd2055a085c5e4 100644 --- a/docs/user/dashboard/vega.asciidoc +++ b/docs/user/dashboard/vega.asciidoc @@ -10,7 +10,7 @@ URL, or static data, and support <> Add or delete users and assign roles that give users specific privileges. -| <> +| <> |View the roles that exist on your cluster. Customize the actions that a user with the role can perform, on a cluster, index, and space level. diff --git a/docs/user/security/authorization/index.asciidoc b/docs/user/security/authorization/index.asciidoc index df4ad4e2b89b08..b478e8b7c38d5d 100644 --- a/docs/user/security/authorization/index.asciidoc +++ b/docs/user/security/authorization/index.asciidoc @@ -23,7 +23,7 @@ Whichever approach you use, be careful when granting cluster privileges and inde cluster, and {kib} spaces do not prevent you from granting users of two different tenants access to the same index. [role="xpack"] -[[xpack-kibana-role-management]] +[[kibana-role-management]] === {kib} role management Roles are a collection of privileges that allow you to perform actions in {kib} and {es}. Users are not directly granted privileges, but are instead assigned one or more roles that describe the desired level of access. When you assign a user multiple roles, the user receives a union of the roles’ privileges. This means that you cannot reduce the privileges of a user by assigning them an additional role. You must instead remove or edit one of their existing roles. diff --git a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc index dd913a5bb28d85..9e457ee409f4b0 100644 --- a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc +++ b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc @@ -131,6 +131,6 @@ This guide is an introduction to {kib}'s security features. Check out these addi * View the <> to learn more about single-sign on and other login features. -* View the <> to learn more about authorizing access to {kib}'s features. +* View the <> to learn more about authorizing access to {kib}'s features. Still have questions? Ask on our https://discuss.elastic.co/c/kibana[Kibana discuss forum] and a fellow community member or Elastic engineer will help out. diff --git a/packages/kbn-es/src/artifact.test.js b/packages/kbn-es/src/artifact.test.js index 884804ed75a656..8888c8ca2f9890 100644 --- a/packages/kbn-es/src/artifact.test.js +++ b/packages/kbn-es/src/artifact.test.js @@ -69,6 +69,18 @@ beforeEach(() => { valid: { archives: [createArchive({ license: 'oss' }), createArchive({ license: 'default' })], }, + invalidArch: { + archives: [ + createArchive({ license: 'oss', architecture: 'invalid_arch' }), + createArchive({ license: 'default', architecture: 'invalid_arch' }), + ], + }, + differentVersion: { + archives: [ + createArchive({ license: 'oss', version: 'another-version' }), + createArchive({ license: 'default', version: 'another-version' }), + ], + }, multipleArch: { archives: [ createArchive({ architecture: 'fake_arch', license: 'oss' }), @@ -116,8 +128,14 @@ describe('Artifact', () => { artifactTest('INVALID_LICENSE', 'default') ); + it('should return an artifact even if the version does not match', async () => { + mockFetch(MOCKS.differentVersion); + artifactTest('default', 'default'); + }); + it('should throw when an artifact cannot be found in the manifest for the specified parameters', async () => { - await expect(Artifact.getSnapshot('default', 'INVALID_VERSION', log)).rejects.toThrow( + mockFetch(MOCKS.invalidArch); + await expect(Artifact.getSnapshot('default', MOCK_VERSION, log)).rejects.toThrow( "couldn't find an artifact" ); }); @@ -144,8 +162,14 @@ describe('Artifact', () => { artifactTest('INVALID_LICENSE', 'default', 2) ); + it('should return an artifact even if the version does not match', async () => { + mockFetch(MOCKS.differentVersion); + artifactTest('default', 'default', 2); + }); + it('should throw when an artifact cannot be found in the manifest for the specified parameters', async () => { - await expect(Artifact.getSnapshot('default', 'INVALID_VERSION', log)).rejects.toThrow( + mockFetch(MOCKS.invalidArch); + await expect(Artifact.getSnapshot('default', MOCK_VERSION, log)).rejects.toThrow( "couldn't find an artifact" ); }); diff --git a/packages/kbn-es/src/artifact.ts b/packages/kbn-es/src/artifact.ts index 9c5935c96e8cd2..9ec3b96801dda6 100644 --- a/packages/kbn-es/src/artifact.ts +++ b/packages/kbn-es/src/artifact.ts @@ -156,16 +156,18 @@ async function getArtifactSpecForSnapshot( const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const archive = manifest.archives.find( - (a) => - a.version === desiredVersion && - a.platform === platform && - a.license === desiredLicense && - a.architecture === arch + (a) => a.platform === platform && a.license === desiredLicense && a.architecture === arch ); if (!archive) { throw createCliError( - `Snapshots for ${desiredVersion} are available, but couldn't find an artifact in the manifest for [${desiredVersion}, ${desiredLicense}, ${platform}]` + `Snapshots are available, but couldn't find an artifact in the manifest for [${desiredLicense}, ${platform}, ${arch}]` + ); + } + + if (archive.version !== desiredVersion) { + log.warning( + `Snapshot found, but version does not match Kibana. Kibana: ${desiredVersion}, Snapshot: ${archive.version}` ); } diff --git a/renovate.json b/renovate.json index 362fd9381b617b..a6b54a3dae349c 100644 --- a/renovate.json +++ b/renovate.json @@ -72,6 +72,16 @@ "enabled": true, "stabilityDays": 7 }, + { + "groupName": "typescript", + "matchPackageNames": ["typescript", "prettier", "@types/jsdom"], + "matchPackagePatterns": ["^@typescript-eslint"], + "reviewers": ["team:kibana-operations"], + "matchBaseBranches": ["main"], + "labels": ["Team:Operations", "release_note:skip"], + "enabled": true, + "stabilityDays": 7 + }, { "groupName": "polyfills", "matchPackageNames": ["core-js"], diff --git a/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap b/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap index 2219e0d7609b80..cc4e27a6d6388a 100644 --- a/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap +++ b/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap @@ -4,17 +4,24 @@ exports[`MetricTile correct displays a byte metric 1`] = ` `; exports[`MetricTile correct displays a float metric 1`] = ` - `; @@ -22,7 +29,7 @@ exports[`MetricTile correct displays a time metric 1`] = ` `; @@ -31,7 +38,29 @@ exports[`MetricTile correct displays an untyped metric 1`] = ` `; + +exports[`MetricTile correctly displays a metric with metadata 1`] = ` + +`; diff --git a/src/core/public/core_app/status/components/metric_tiles.test.tsx b/src/core/public/core_app/status/components/metric_tiles.test.tsx index 76608718e8cd3d..8e6d1cf38cd012 100644 --- a/src/core/public/core_app/status/components/metric_tiles.test.tsx +++ b/src/core/public/core_app/status/components/metric_tiles.test.tsx @@ -35,6 +35,18 @@ const timeMetric: Metric = { value: 1234, }; +const metricWithMeta: Metric = { + name: 'Delay', + type: 'time', + value: 1, + meta: { + description: 'Percentiles', + title: '', + value: [1, 5, 10], + type: 'time', + }, +}; + describe('MetricTile', () => { it('correct displays an untyped metric', () => { const component = shallow(); @@ -55,4 +67,9 @@ describe('MetricTile', () => { const component = shallow(); expect(component).toMatchSnapshot(); }); + + it('correctly displays a metric with metadata', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/core/public/core_app/status/components/metric_tiles.tsx b/src/core/public/core_app/status/components/metric_tiles.tsx index 1eb5ee4c95dd8e..18fa9ae7382277 100644 --- a/src/core/public/core_app/status/components/metric_tiles.tsx +++ b/src/core/public/core_app/status/components/metric_tiles.tsx @@ -7,24 +7,105 @@ */ import React, { FunctionComponent } from 'react'; -import { EuiFlexGrid, EuiFlexItem, EuiCard } from '@elastic/eui'; -import { formatNumber, Metric } from '../lib'; +import { EuiFlexGrid, EuiFlexItem, EuiCard, EuiStat } from '@elastic/eui'; +import { DataType, formatNumber, Metric } from '../lib'; /* - * Displays a metric with the correct format. + * Displays metadata for a metric. */ -export const MetricTile: FunctionComponent<{ metric: Metric }> = ({ metric }) => { - const { name } = metric; +const MetricCardFooter: FunctionComponent<{ + title: string; + description: string; +}> = ({ title, description }) => { + return ( + + ); +}; + +const DelayMetricTile: FunctionComponent<{ metric: Metric }> = ({ metric }) => { + const { name, meta } = metric; return ( + ) + } + /> + ); +}; + +const LoadMetricTile: FunctionComponent<{ metric: Metric }> = ({ metric }) => { + const { name, meta } = metric; + return ( + } /> ); }; +const ResponseTimeMetric: FunctionComponent<{ metric: Metric }> = ({ metric }) => { + const { name, meta } = metric; + return ( + + ) + } + /> + ); +}; + +/* + * Displays a metric with the correct format. + */ +export const MetricTile: FunctionComponent<{ metric: Metric }> = ({ metric }) => { + const { name } = metric; + switch (name) { + case 'Delay': + return ; + case 'Load': + return ; + case 'Response time avg': + return ; + default: + return ( + + ); + } +}; + /* * Wrapper component that simply maps each metric to MetricTile inside a FlexGroup */ @@ -38,11 +119,20 @@ export const MetricTiles: FunctionComponent<{ metrics: Metric[] }> = ({ metrics ); +// formatting helper functions + const formatMetric = ({ value, type }: Metric) => { const metrics = Array.isArray(value) ? value : [value]; return metrics.map((metric) => formatNumber(metric, type)).join(', '); }; -const formatMetricId = ({ name }: Metric) => { +const formatMetricId = (name: Metric['name']) => { return name.toLowerCase().replace(/[ ]+/g, '-'); }; + +const formatDelayFooterTitle = (values: number[], type?: DataType) => { + return ` + 50: ${formatNumber(values[0], type)}; + 95: ${formatNumber(values[1], type)}; + 99: ${formatNumber(values[2], type)}`; +}; diff --git a/src/core/public/core_app/status/lib/load_status.test.ts b/src/core/public/core_app/status/lib/load_status.test.ts index 555e793b41aa5b..f044aa2daa2e9a 100644 --- a/src/core/public/core_app/status/lib/load_status.test.ts +++ b/src/core/public/core_app/status/lib/load_status.test.ts @@ -229,13 +229,23 @@ describe('response processing', () => { expect(names).toEqual([ 'Heap total', 'Heap used', + 'Requests per second', 'Load', + 'Delay', 'Response time avg', - 'Response time max', - 'Requests per second', ]); - const values = data.metrics.map((m) => m.value); - expect(values).toEqual([1000000, 100, [4.1, 2.1, 0.1], 4000, 8000, 400]); + expect(values).toEqual([1000000, 100, 400, [4.1, 2.1, 0.1], 1, 4000]); + }); + + test('adds meta details to Load, Delay and Response time', async () => { + const data = await loadStatus({ http, notifications }); + const metricNames = data.metrics.filter((met) => met.meta); + expect(metricNames.map((item) => item.name)).toEqual(['Load', 'Delay', 'Response time avg']); + expect(metricNames.map((item) => item.meta!.description)).toEqual([ + 'Load interval', + 'Percentiles', + 'Response time max', + ]); }); }); diff --git a/src/core/public/core_app/status/lib/load_status.ts b/src/core/public/core_app/status/lib/load_status.ts index 31f20bf5c4edf1..2d81d51128926b 100644 --- a/src/core/public/core_app/status/lib/load_status.ts +++ b/src/core/public/core_app/status/lib/load_status.ts @@ -13,10 +13,17 @@ import type { HttpSetup } from '../../../http'; import type { NotificationsSetup } from '../../../notifications'; import type { DataType } from '../lib'; +interface MetricMeta { + title: string; + description: string; + value?: number[]; + type?: DataType; +} export interface Metric { name: string; value: number | number[]; type?: DataType; + meta?: MetricMeta; } export interface FormattedStatus { @@ -60,33 +67,62 @@ function formatMetrics({ metrics }: StatusResponse): Metric[] { value: metrics.process.memory.heap.used_in_bytes, type: 'byte', }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.requestsPerSecHeader', { + defaultMessage: 'Requests per second', + }), + value: (metrics.requests.total * 1000) / metrics.collection_interval_in_millis, + type: 'float', + }, { name: i18n.translate('core.statusPage.metricsTiles.columns.loadHeader', { defaultMessage: 'Load', }), value: [metrics.os.load['1m'], metrics.os.load['5m'], metrics.os.load['15m']], type: 'float', + meta: { + description: i18n.translate('core.statusPage.metricsTiles.columns.load.metaHeader', { + defaultMessage: 'Load interval', + }), + title: Object.keys(metrics.os.load).join('; '), + }, }, { - name: i18n.translate('core.statusPage.metricsTiles.columns.resTimeAvgHeader', { - defaultMessage: 'Response time avg', + name: i18n.translate('core.statusPage.metricsTiles.columns.processDelayHeader', { + defaultMessage: 'Delay', }), - value: metrics.response_times.avg_in_millis, + value: metrics.process.event_loop_delay, type: 'time', + meta: { + description: i18n.translate( + 'core.statusPage.metricsTiles.columns.processDelayDetailsHeader', + { + defaultMessage: 'Percentiles', + } + ), + title: '', + value: [ + metrics.process.event_loop_delay_histogram?.percentiles['50'], + metrics.process.event_loop_delay_histogram?.percentiles['95'], + metrics.process.event_loop_delay_histogram?.percentiles['99'], + ], + type: 'time', + }, }, { - name: i18n.translate('core.statusPage.metricsTiles.columns.resTimeMaxHeader', { - defaultMessage: 'Response time max', + name: i18n.translate('core.statusPage.metricsTiles.columns.resTimeAvgHeader', { + defaultMessage: 'Response time avg', }), - value: metrics.response_times.max_in_millis, + value: metrics.response_times.avg_in_millis, type: 'time', - }, - { - name: i18n.translate('core.statusPage.metricsTiles.columns.requestsPerSecHeader', { - defaultMessage: 'Requests per second', - }), - value: (metrics.requests.total * 1000) / metrics.collection_interval_in_millis, - type: 'float', + meta: { + description: i18n.translate('core.statusPage.metricsTiles.columns.resTimeMaxHeader', { + defaultMessage: 'Response time max', + }), + title: '', + value: [metrics.response_times.max_in_millis], + type: 'time', + }, }, ]; } diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index fed3aa30931669..bb91d9732fdd44 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -562,7 +562,7 @@ export class DocLinksService { upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: `${KIBANA_DOCS}api-keys.html`, - onPremRegistry: `${ELASTIC_WEBSITE_URL}guide/en/integrations-developer/${DOC_LINK_VERSION}/air-gapped.html`, + onPremRegistry: `${FLEET_DOCS}air-gapped.html`, }, ecs: { guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`, diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index a7d8fe684ef95f..c46a41eb5c3a09 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -254,6 +254,7 @@ kibana_vars=( xpack.fleet.agents.kibana.host xpack.fleet.agents.tlsCheckDisabled xpack.fleet.packages + xpack.fleet.registryProxyUrl xpack.fleet.registryUrl xpack.graph.canEditDrillDownUrls xpack.graph.savePolicy diff --git a/src/plugins/telemetry/public/services/telemetry_service.test.ts b/src/plugins/telemetry/public/services/telemetry_service.test.ts index 8987dde232e869..778dd2fbfe0b0f 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.test.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.test.ts @@ -18,17 +18,20 @@ describe('TelemetryService', () => { await telemetryService.fetchTelemetry(); expect(telemetryService['http'].post).toBeCalledWith('/api/telemetry/v2/clusters/_stats', { - body: JSON.stringify({ unencrypted: false }), + body: JSON.stringify({ unencrypted: false, refreshCache: false }), }); }); }); describe('fetchExample', () => { - it('calls fetchTelemetry with unencrupted: true', async () => { + it('calls fetchTelemetry with unencrypted: true, refreshCache: true', async () => { const telemetryService = mockTelemetryService(); telemetryService.fetchTelemetry = jest.fn(); await telemetryService.fetchExample(); - expect(telemetryService.fetchTelemetry).toBeCalledWith({ unencrypted: true }); + expect(telemetryService.fetchTelemetry).toBeCalledWith({ + unencrypted: true, + refreshCache: true, + }); }); }); diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index 63e9b66a49a92c..d8732b3d4bba9e 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -140,7 +140,7 @@ export class TelemetryService { /** Fetches an unencrypted telemetry payload so we can show it to the user **/ public fetchExample = async (): Promise => { - return await this.fetchTelemetry({ unencrypted: true }); + return await this.fetchTelemetry({ unencrypted: true, refreshCache: true }); }; /** @@ -149,9 +149,10 @@ export class TelemetryService { */ public fetchTelemetry = async ({ unencrypted = false, + refreshCache = false, } = {}): Promise => { return this.http.post('/api/telemetry/v2/clusters/_stats', { - body: JSON.stringify({ unencrypted }), + body: JSON.stringify({ unencrypted, refreshCache }), }); }; diff --git a/src/plugins/telemetry/schema/oss_root.json b/src/plugins/telemetry/schema/oss_root.json index 3748485465cc0b..e526dc6413916b 100644 --- a/src/plugins/telemetry/schema/oss_root.json +++ b/src/plugins/telemetry/schema/oss_root.json @@ -18,6 +18,22 @@ "collectionSource": { "type": "keyword" }, + "cacheDetails": { + "properties": { + "updatedAt": { + "type": "date", + "_meta": { + "description": "The timestamp the payload was last cached." + } + }, + "fetchedAt": { + "type": "date", + "_meta": { + "description": "The timestamp the payload was grabbed from cache." + } + } + } + }, "stack_stats": { "properties": { "data": { diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts index e07a6cb8f724d9..e3ce8cbc5190a9 100644 --- a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts @@ -24,16 +24,18 @@ export function registerTelemetryUsageStatsRoutes( validate: { body: schema.object({ unencrypted: schema.boolean({ defaultValue: false }), + refreshCache: schema.boolean({ defaultValue: false }), }), }, }, async (context, req, res) => { - const { unencrypted } = req.body; + const { unencrypted, refreshCache } = req.body; try { const statsConfig: StatsGetterConfig = { request: req, unencrypted, + refreshCache, }; const stats = await telemetryCollectionManager.getStats(statsConfig); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index 191e857c777d24..aacc91793a03fe 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -80,6 +80,7 @@ function mockStatsCollectionConfig( esClient: mockGetLocalStats(clusterInfo, clusterStats), usageCollection: mockUsageCollection(kibana), kibanaRequest: httpServerMock.createKibanaRequest(), + refreshCache: false, }; } diff --git a/src/plugins/telemetry_collection_manager/common/index.ts b/src/plugins/telemetry_collection_manager/common/index.ts index 208c9e0ce3cb36..4f9c8a851bd4c0 100644 --- a/src/plugins/telemetry_collection_manager/common/index.ts +++ b/src/plugins/telemetry_collection_manager/common/index.ts @@ -8,3 +8,10 @@ export const PLUGIN_ID = 'telemetryCollectionManager'; export const PLUGIN_NAME = 'telemetry_collection_manager'; + +/** + * The duration, in milliseconds, to cache stats + * Currently 4 hours. + */ +const hour = 1000 * 60 * 60; +export const CACHE_DURATION_MS = 4 * hour; diff --git a/src/plugins/telemetry_collection_manager/server/cache/cache_manager.test.ts b/src/plugins/telemetry_collection_manager/server/cache/cache_manager.test.ts new file mode 100644 index 00000000000000..01db6e2422003d --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/cache/cache_manager.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CacheManager } from './cache_manager'; + +describe('CacheManager', () => { + const mockCacheKey = 'mock_key'; + const mockCacheItem = 'cache_item'; + const cacheDurationMs = 10000; + let mockNow: number; + + beforeEach(() => { + jest.useFakeTimers('modern'); + mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + }); + afterEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + it('caches object for the cache duration only', () => { + const cacheManager = new CacheManager({ cacheDurationMs }); + cacheManager.setCache(mockCacheKey, mockCacheItem); + expect(cacheManager.getFromCache(mockCacheKey)).toEqual(mockCacheItem); + jest.advanceTimersByTime(cacheDurationMs + 100); + expect(cacheManager.getFromCache(mockCacheKey)).toEqual(undefined); + }); + + it('#resetCache removes cached objects', () => { + const cacheManager = new CacheManager({ cacheDurationMs }); + cacheManager.setCache(mockCacheKey, mockCacheItem); + expect(cacheManager.getFromCache(mockCacheKey)).toEqual(mockCacheItem); + cacheManager.resetCache(); + expect(cacheManager.getFromCache(mockCacheKey)).toEqual(undefined); + }); +}); diff --git a/src/plugins/telemetry_collection_manager/server/cache/cache_manager.ts b/src/plugins/telemetry_collection_manager/server/cache/cache_manager.ts new file mode 100644 index 00000000000000..6fa2482d6f942c --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/cache/cache_manager.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import LRUCache from 'lru-cache'; + +export interface CacheManagerConfig { + // cache duration of objects in ms + cacheDurationMs: number; +} + +export class CacheManager { + private readonly cache: LRUCache; + + constructor({ cacheDurationMs }: CacheManagerConfig) { + this.cache = new LRUCache({ + max: 1, + maxAge: cacheDurationMs, + }); + } + + /** + * Cache an object by key + */ + public setCache = (cacheKey: string, data: unknown): void => { + this.cache.set(cacheKey, data); + }; + + /** + * returns cached object. If the key is not found will return undefined. + */ + public getFromCache = (cacheKey: string): T | undefined => { + return this.cache.get(cacheKey) as T; + }; + + /** + * Removes all cached objects + */ + public resetCache(): void { + this.cache.reset(); + } +} diff --git a/src/plugins/telemetry_collection_manager/server/cache/index.ts b/src/plugins/telemetry_collection_manager/server/cache/index.ts new file mode 100644 index 00000000000000..8a2ad483f6c6d5 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/cache/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CacheManager } from './cache_manager'; +export type { CacheManagerConfig } from './cache_manager'; diff --git a/src/plugins/telemetry_collection_manager/server/plugin.test.ts b/src/plugins/telemetry_collection_manager/server/plugin.test.ts index 6e37ef5ffd4f57..97cb4618d5d136 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.test.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.test.ts @@ -45,6 +45,12 @@ describe('Telemetry Collection Manager', () => { const telemetryCollectionManager = new TelemetryCollectionManagerPlugin(initializerContext); const setupApi = telemetryCollectionManager.setup(coreMock.createSetup(), { usageCollection }); const collectionStrategy = createCollectionStrategy(1); + beforeEach(() => { + // Reset cache on every request. + // 10s cache to avoid misatekly invalidating cache during test runs + // eslint-disable-next-line dot-notation + telemetryCollectionManager['cacheManager'].resetCache(); + }); describe('before start', () => { test('registers a collection strategy', () => { @@ -196,13 +202,37 @@ describe('Telemetry Collection Manager', () => { await expect(setupApi.getStats(config)).resolves.toStrictEqual([ { clusterUuid: 'clusterUuid', - stats: { ...basicStats, collectionSource: 'test_collection' }, + stats: { + ...basicStats, + cacheDetails: { updatedAt: expect.any(String), fetchedAt: expect.any(String) }, + collectionSource: 'test_collection', + }, }, ]); + expect( collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient ).not.toBeInstanceOf(TelemetrySavedObjectsClient); }); + + test('returns cached object on multiple calls', async () => { + collectionStrategy.clusterDetailsGetter.mockResolvedValue([ + { clusterUuid: 'clusterUuid' }, + ]); + collectionStrategy.statsGetter.mockResolvedValue([basicStats]); + await setupApi.getStats(config); + + await expect(setupApi.getStats(config)).resolves.toStrictEqual([ + { + clusterUuid: 'clusterUuid', + stats: { + ...basicStats, + cacheDetails: { updatedAt: expect.any(String), fetchedAt: expect.any(String) }, + collectionSource: 'test_collection', + }, + }, + ]); + }); }); describe('getOptInStats', () => { diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index 6dd1de65a8bdc8..2f4576a6a6f4f0 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -32,9 +32,12 @@ import type { StatsCollectionContext, UnencryptedStatsGetterConfig, EncryptedStatsGetterConfig, + ClusterDetails, } from './types'; import { encryptTelemetry } from './encryption'; import { TelemetrySavedObjectsClient } from './telemetry_saved_objects_client'; +import { CacheManager } from './cache'; +import { CACHE_DURATION_MS } from '../common'; interface TelemetryCollectionPluginsDepsSetup { usageCollection: UsageCollectionSetup; @@ -51,6 +54,7 @@ export class TelemetryCollectionManagerPlugin private savedObjectsService?: SavedObjectsServiceStart; private readonly isDistributable: boolean; private readonly version: string; + private cacheManager = new CacheManager({ cacheDurationMs: CACHE_DURATION_MS }); constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -125,9 +129,10 @@ export class TelemetryCollectionManagerPlugin const soClient = this.getSavedObjectsClient(config); // Provide the kibanaRequest so opted-in plugins can scope their custom clients only if the request is not encrypted const kibanaRequest = config.unencrypted ? config.request : void 0; + const refreshCache = !!config.refreshCache; if (esClient && soClient) { - return { usageCollection, esClient, soClient, kibanaRequest }; + return { usageCollection, esClient, soClient, kibanaRequest, refreshCache }; } } @@ -284,6 +289,25 @@ export class TelemetryCollectionManagerPlugin return []; } + private createCacheKey(collectionSource: string, clustersDetails: ClusterDetails[]) { + const clusterUUids = clustersDetails + .map(({ clusterUuid }) => clusterUuid) + .sort() + .join('_'); + + return `${collectionSource}::${clusterUUids}`; + } + + private updateFetchedAt(statsPayload: UsageStatsPayload[]): UsageStatsPayload[] { + return statsPayload.map((stat) => ({ + ...stat, + cacheDetails: { + ...stat.cacheDetails, + fetchedAt: new Date().toISOString(), + }, + })); + } + private async getUsageForCollection( collection: CollectionStrategy, statsCollectionConfig: StatsCollectionConfig @@ -292,17 +316,34 @@ export class TelemetryCollectionManagerPlugin logger: this.logger.get(collection.title), version: this.version, }; - const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig, context); + const { refreshCache } = statsCollectionConfig; + const { title: collectionSource } = collection; + + // on `refreshCache: true` clear all cache to store a fresh copy + if (refreshCache) { + this.cacheManager.resetCache(); + } if (clustersDetails.length === 0) { - // don't bother doing a further lookup. return []; } + const cacheKey = this.createCacheKey(collectionSource, clustersDetails); + const cachedUsageStatsPayload = this.cacheManager.getFromCache(cacheKey); + if (cachedUsageStatsPayload) { + return this.updateFetchedAt(cachedUsageStatsPayload); + } + + const now = new Date().toISOString(); const stats = await collection.statsGetter(clustersDetails, statsCollectionConfig, context); + const usageStatsPayload = stats.map((stat) => ({ + collectionSource, + cacheDetails: { updatedAt: now, fetchedAt: now }, + ...stat, + })); + this.cacheManager.setCache(cacheKey, usageStatsPayload); - // Add the `collectionSource` to the resulting payload - return stats.map((stat) => ({ collectionSource: collection.title, ...stat })); + return this.updateFetchedAt(usageStatsPayload); } } diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 648e457f9a2381..b9b570e3f287b8 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -37,6 +37,7 @@ export interface TelemetryOptInStats { export interface BaseStatsGetterConfig { unencrypted: boolean; + refreshCache?: boolean; request?: KibanaRequest; } @@ -58,6 +59,12 @@ export interface StatsCollectionConfig { esClient: ElasticsearchClient; soClient: SavedObjectsClientContract; kibanaRequest: KibanaRequest | undefined; // intentionally `| undefined` to enforce providing the parameter + refreshCache: boolean; +} + +export interface CacheDetails { + updatedAt: string; + fetchedAt: string; } export interface BasicStatsPayload { @@ -71,6 +78,7 @@ export interface BasicStatsPayload { } export interface UsageStatsPayload extends BasicStatsPayload { + cacheDetails: CacheDetails; collectionSource: string; } diff --git a/test/functional/apps/dashboard/dashboard_options.ts b/test/functional/apps/dashboard/dashboard_options.ts index 5702286bba6b68..8080c6cb4cc7ff 100644 --- a/test/functional/apps/dashboard/dashboard_options.ts +++ b/test/functional/apps/dashboard/dashboard_options.ts @@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard']); - describe('dashboard data-shared attributes', () => { + describe('dashboard options', () => { let originalTitles: string[] = []; before(async () => { diff --git a/test/functional/apps/dashboard/embeddable_rendering.ts b/test/functional/apps/dashboard/embeddable_rendering.ts index e045b1a1e3f5eb..0b36bc480cc55d 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.ts +++ b/test/functional/apps/dashboard/embeddable_rendering.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); const security = getService('security'); const dashboardExpect = getService('dashboardExpect'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -32,6 +33,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'dashboard', 'header', 'visualize', + 'visChart', 'discover', 'timePicker', ]); @@ -40,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const expectAllDataRenders = async () => { await pieChart.expectPieSliceCount(16); await dashboardExpect.metricValuesExist(['7,544']); - await dashboardExpect.seriesElementCount(19); + await dashboardExpect.seriesElementCount(14); const tsvbGuageExists = await find.existsByCssSelector('.tvbVisHalfGauge'); expect(tsvbGuageExists).to.be(true); await dashboardExpect.timelionLegendCount(0); @@ -49,7 +51,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardExpect.goalAndGuageLabelsExist(['62.925%', '55.625%', '11.915 GB']); await dashboardExpect.dataTableRowCount(5); await dashboardExpect.tagCloudWithValuesFound(['CN', 'IN', 'US', 'BR', 'ID']); - // TODO add test for 'region map viz' // TODO add test for 'tsvb gauge' viz // TODO add test for 'geo map' viz // This tests the presence of the two input control embeddables @@ -59,8 +60,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardExpect.tsvbTopNValuesExist(['5,734.79', '6,286.675']); await dashboardExpect.tsvbMetricValuesExist(['210,007,889,606']); // TODO add test for 'animal sound pie' viz - // This tests the area chart and non timebased line chart points - await dashboardExpect.lineChartPointsCount(5); + + // This tests line charts that do not use timeseries data + const dogData = await elasticChart.getChartDebugData('visTypeXyChart', 2); + const pointCount = dogData?.areas?.reduce((acc, a) => { + return acc + a.lines.y1.points.length; + }, 0); + expect(pointCount).to.equal(6); + // TODO add test for 'scripted filter and query' viz // TODO add test for 'animal weight linked to search' viz // TODO add test for the last vega viz @@ -71,27 +78,30 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await pieChart.expectPieSliceCount(0); await dashboardExpect.seriesElementCount(0); await dashboardExpect.dataTableNoResult(); - await dashboardExpect.savedSearchRowCount(0); + await dashboardExpect.savedSearchNoResult(); await dashboardExpect.inputControlItemCount(5); await dashboardExpect.metricValuesExist(['0']); await dashboardExpect.markdownWithValuesExists(["I'm a markdown!"]); // Three instead of 0 because there is a visualization based off a non time based index that // should still show data. - await dashboardExpect.lineChartPointsCount(3); + const dogData = await elasticChart.getChartDebugData('visTypeXyChart'); + const pointCount = dogData?.areas?.reduce((acc, a) => { + return acc + a.lines.y1.points.length; + }, 0); + expect(pointCount).to.equal(6); await dashboardExpect.timelionLegendCount(0); const tsvbGuageExists = await find.existsByCssSelector('.tvbVisHalfGauge'); expect(tsvbGuageExists).to.be(true); - await dashboardExpect.tsvbMetricValuesExist(['0']); - await dashboardExpect.tsvbMarkdownWithValuesExists(['Hi Avg last bytes: 0']); + await dashboardExpect.tsvbMetricValuesExist(['-']); + await dashboardExpect.tsvbMarkdownWithValuesExists(['Hi Avg last bytes:']); await dashboardExpect.tsvbTableCellCount(0); - await dashboardExpect.tsvbTopNValuesExist(['0']); + await dashboardExpect.tsvbTopNValuesExist(['-']); await dashboardExpect.vegaTextsDoNotExist(['5,000']); }; - // Failing: See https://github.com/elastic/kibana/issues/76245 - describe.skip('dashboard embeddable rendering', function describeIndexTests() { + describe('dashboard embeddable rendering', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'animals', 'test_logstash_reader']); await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); @@ -116,6 +126,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('adding visualizations', async () => { + await elasticChart.setNewChartUiDebugFlag(true); + visNames = await dashboardAddPanel.addEveryVisualization('"Rendering Test"'); await dashboardExpect.visualizationsArePresent(visNames); @@ -123,7 +135,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { visNames.push(await dashboardAddPanel.addVisualization('Filter Bytes Test: vega')); await PageObjects.header.waitUntilLoadingHasFinished(); await dashboardExpect.visualizationsArePresent(visNames); - expect(visNames.length).to.be.equal(27); + expect(visNames.length).to.be.equal(26); await PageObjects.dashboard.waitForRenderComplete(); }); @@ -134,7 +146,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.closeAddPanel(); await PageObjects.header.waitUntilLoadingHasFinished(); await dashboardExpect.visualizationsArePresent(visAndSearchNames); - expect(visAndSearchNames.length).to.be.equal(28); + expect(visAndSearchNames.length).to.be.equal(27); await PageObjects.dashboard.waitForRenderComplete(); await PageObjects.dashboard.saveDashboard('embeddable rendering test', { @@ -159,8 +171,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('data rendered correctly when dashboard is hard refreshed', async () => { - const currentUrl = await browser.getCurrentUrl(); - await browser.get(currentUrl, true); + await browser.refresh(); + const alert = await browser.getAlert(); + await alert?.accept(); + + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); await expectAllDataRenders(); diff --git a/test/functional/apps/status_page/index.ts b/test/functional/apps/status_page/index.ts index 99f32fa5da4c73..509abeb4f03466 100644 --- a/test/functional/apps/status_page/index.ts +++ b/test/functional/apps/status_page/index.ts @@ -40,6 +40,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(metrics).to.have.length(6); }); + it('should display the server metrics meta', async () => { + const metricsMetas = await testSubjects.findAll('serverMetricMeta'); + expect(metricsMetas).to.have.length(3); + }); + it('should display the server status', async () => { const titleText = await testSubjects.getVisibleText('serverStatusTitle'); expect(titleText).to.contain('Kibana status is'); diff --git a/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json b/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json index 45e26f1599b6c0..376ce3e7460ef4 100644 --- a/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json +++ b/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json @@ -961,7 +961,7 @@ "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":15,\"w\":24,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":24,\"h\":15,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":30,\"w\":24,\"h\":15,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":45,\"w\":24,\"h\":15,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":45,\"w\":24,\"h\":15,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":60,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":60,\"w\":24,\"h\":15,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":75,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":75,\"w\":24,\"h\":15,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":90,\"w\":24,\"h\":15,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":90,\"w\":24,\"h\":15,\"i\":\"14\"},\"panelIndex\":\"14\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_14\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":105,\"w\":24,\"h\":15,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":105,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":120,\"w\":24,\"h\":15,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":120,\"w\":24,\"h\":15,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":135,\"w\":24,\"h\":15,\"i\":\"19\"},\"panelIndex\":\"19\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_19\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":135,\"w\":24,\"h\":15,\"i\":\"20\"},\"panelIndex\":\"20\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_20\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":150,\"w\":24,\"h\":15,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":150,\"w\":24,\"h\":15,\"i\":\"22\"},\"panelIndex\":\"22\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_22\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":165,\"w\":24,\"h\":15,\"i\":\"23\"},\"panelIndex\":\"23\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_23\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":165,\"w\":24,\"h\":15,\"i\":\"24\"},\"panelIndex\":\"24\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_24\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":180,\"w\":24,\"h\":15,\"i\":\"25\"},\"panelIndex\":\"25\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_25\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":180,\"w\":24,\"h\":15,\"i\":\"26\"},\"panelIndex\":\"26\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_26\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":195,\"w\":24,\"h\":15,\"i\":\"27\"},\"panelIndex\":\"27\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_27\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":195,\"w\":24,\"h\":15,\"i\":\"28\"},\"panelIndex\":\"28\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_28\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":210,\"w\":24,\"h\":15,\"i\":\"29\"},\"panelIndex\":\"29\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_29\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":210,\"i\":\"30\"},\"panelIndex\":\"30\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_30\"}]", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"2\"},\"panelIndex\":\"2\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":15,\"w\":24,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":30,\"w\":24,\"h\":15,\"i\":\"5\"},\"panelIndex\":\"5\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":30,\"w\":24,\"h\":15,\"i\":\"6\"},\"panelIndex\":\"6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":45,\"w\":24,\"h\":15,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":45,\"w\":24,\"h\":15,\"i\":\"8\"},\"panelIndex\":\"8\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_8\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":60,\"w\":24,\"h\":15,\"i\":\"9\"},\"panelIndex\":\"9\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_9\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":60,\"w\":24,\"h\":15,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":75,\"w\":24,\"h\":15,\"i\":\"11\"},\"panelIndex\":\"11\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_11\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":75,\"w\":24,\"h\":15,\"i\":\"12\"},\"panelIndex\":\"12\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":90,\"w\":24,\"h\":15,\"i\":\"13\"},\"panelIndex\":\"13\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_13\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":105,\"w\":24,\"h\":15,\"i\":\"15\"},\"panelIndex\":\"15\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_15\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":105,\"w\":24,\"h\":15,\"i\":\"16\"},\"panelIndex\":\"16\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_16\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":120,\"w\":24,\"h\":15,\"i\":\"17\"},\"panelIndex\":\"17\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_17\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":120,\"w\":24,\"h\":15,\"i\":\"18\"},\"panelIndex\":\"18\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_18\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":135,\"w\":24,\"h\":15,\"i\":\"19\"},\"panelIndex\":\"19\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_19\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":135,\"w\":24,\"h\":15,\"i\":\"20\"},\"panelIndex\":\"20\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_20\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":150,\"w\":24,\"h\":15,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":150,\"w\":24,\"h\":15,\"i\":\"22\"},\"panelIndex\":\"22\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_22\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":165,\"w\":24,\"h\":15,\"i\":\"23\"},\"panelIndex\":\"23\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_23\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":165,\"w\":24,\"h\":15,\"i\":\"24\"},\"panelIndex\":\"24\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_24\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":180,\"w\":24,\"h\":15,\"i\":\"25\"},\"panelIndex\":\"25\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_25\"},{\"version\":\"7.3.0\",\"type\":\"search\",\"gridData\":{\"x\":24,\"y\":180,\"w\":24,\"h\":15,\"i\":\"26\"},\"panelIndex\":\"26\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_26\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":195,\"w\":24,\"h\":15,\"i\":\"27\"},\"panelIndex\":\"27\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_27\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":195,\"w\":24,\"h\":15,\"i\":\"28\"},\"panelIndex\":\"28\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_28\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":210,\"w\":24,\"h\":15,\"i\":\"29\"},\"panelIndex\":\"29\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_29\"},{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":210,\"i\":\"30\"},\"panelIndex\":\"30\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_30\"}]", "refreshInterval": { "display": "Off", "pause": false, @@ -1042,11 +1042,6 @@ "name": "13:panel_13", "type": "visualization" }, - { - "id": "4ca00ba0-3dcc-11e8-8660-4d65aa086b3c", - "name": "14:panel_14", - "type": "visualization" - }, { "id": "78803be0-3dcd-11e8-8660-4d65aa086b3c", "name": "15:panel_15", @@ -1487,40 +1482,6 @@ } } -{ - "type": "doc", - "value": { - "id": "visualization:4ca00ba0-3dcc-11e8-8660-4d65aa086b3c", - "index": ".kibana", - "source": { - "coreMigrationVersion": "7.14.0", - "migrationVersion": { - "visualization": "7.14.0" - }, - "references": [ - { - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern" - } - ], - "type": "visualization", - "updated_at": "2018-04-17T15:06:32.131Z", - "visualization": { - "description": "", - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" - }, - "title": "Rendering Test: region map", - "uiStateJSON": "{\"mapZoom\":2,\"mapCenter\":[8.754794702435618,-9.140625000000002]}", - "version": 1, - "visState": "{\"title\":\"Rendering Test: region map\",\"type\":\"region_map\",\"params\":{\"legendPosition\":\"bottomright\",\"addTooltip\":true,\"colorSchema\":\"Yellow to Red\",\"selectedLayer\":{\"attribution\":\"

Made with NaturalEarth | Elastic Maps Service

\",\"name\":\"World Countries\",\"weight\":1,\"format\":{\"type\":\"geojson\"},\"url\":\"https://staging-dot-elastic-layer.appspot.com/blob/5715999101812736?elastic_tile_service_tos=agree&my_app_version=6.3.0\",\"fields\":[{\"name\":\"iso2\",\"description\":\"Two letter abbreviation\"},{\"name\":\"iso3\",\"description\":\"Three letter abbreviation\"},{\"name\":\"name\",\"description\":\"Country name\"}],\"created_at\":\"2017-07-31T16:00:19.996450\",\"tags\":[],\"id\":5715999101812736,\"layerId\":\"elastic_maps_service.World Countries\"},\"selectedJoinField\":{\"name\":\"iso2\",\"description\":\"Two letter abbreviation\"},\"isDisplayWarning\":true,\"wms\":{\"enabled\":false,\"options\":{\"format\":\"image/png\",\"transparent\":true},\"baseLayersAreLoaded\":{},\"tmsLayers\":[{\"id\":\"road_map\",\"url\":\"https://tiles-stage.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=6.3.0\",\"minZoom\":0,\"maxZoom\":10,\"attribution\":\"

© OpenStreetMap contributors | Elastic Maps Service

\",\"subdomains\":[]}],\"selectedTmsLayer\":{\"id\":\"road_map\",\"url\":\"https://tiles-stage.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=6.3.0\",\"minZoom\":0,\"maxZoom\":10,\"attribution\":\"

© OpenStreetMap contributors | Elastic Maps Service

\",\"subdomains\":[]}},\"mapZoom\":2,\"mapCenter\":[0,0],\"outlineWeight\":1,\"showAllShapes\":true},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}" - } - }, - "type": "_doc" - } -} - { "type": "doc", "value": { @@ -1674,7 +1635,7 @@ "title": "Rendering Test: tsvb-guage", "uiStateJSON": "{}", "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-guage\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"gauge\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true},\"aggs\":[]}" + "visState": "{\"title\":\"Rendering Test: tsvb-guage\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"gauge\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" } }, "type": "_doc" @@ -1883,7 +1844,7 @@ "title": "Rendering Test: tsvb-table", "uiStateJSON": "{}", "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-table\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"table\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"pivot_id\":\"bytes\",\"pivot_label\":\"Hello\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true},\"aggs\":[]}" + "visState": "{\"title\":\"Rendering Test: tsvb-table\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"table\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"pivot_id\":\"bytes\",\"pivot_label\":\"Hello\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" } }, "type": "_doc" @@ -1912,7 +1873,7 @@ "title": "Rendering Test: tsvb-markdown", "uiStateJSON": "{}", "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-markdown\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true},\"aggs\":[]}" + "visState": "{\"title\":\"Rendering Test: tsvb-markdown\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"markdown\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_color_rules\":[{\"id\":\"e0be22e0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"gauge_width\":10,\"gauge_inner_width\":10,\"gauge_style\":\"half\",\"markdown\":\"\\nHi Avg last bytes: {{ average_of_bytes.last.raw }}\",\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" } }, "type": "_doc" @@ -1941,7 +1902,7 @@ "title": "Rendering Test: tsvb-topn", "uiStateJSON": "{}", "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-topn\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true},\"aggs\":[]}" + "visState": "{\"title\":\"Rendering Test: tsvb-topn\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"top_n\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"},{\"time_range_mode\":\"entire_time_range\",\"id\":\"d18e5970-3dcc-11e8-a2f6-c162ca6cf6ea\",\"color\":\"rgba(160,70,216,1)\",\"split_mode\":\"filter\",\"metrics\":[{\"id\":\"d18e5971-3dcc-11e8-a2f6-c162ca6cf6ea\",\"type\":\"avg\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"filter\":{\"query\":\"bytes:>1000\",\"language\":\"lucene\"},\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"bar_color_rules\":[{\"id\":\"cd25a820-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" } }, "type": "_doc" @@ -1970,7 +1931,7 @@ "title": "Rendering Test: tsvb-metric", "uiStateJSON": "{}", "version": 1, - "visState": "{\"title\":\"Rendering Test: tsvb-metric\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum_of_squares\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true},\"aggs\":[]}" + "visState": "{\"title\":\"Rendering Test: tsvb-metric\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"last_value\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"metric\",\"series\":[{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"sum_of_squares\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"logstash-*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"background_color_rules\":[{\"id\":\"c50bd5b0-3dcc-11e8-a2f6-c162ca6cf6ea\"}],\"use_kibana_indexes\":false,\"hide_last_value_indicator\":true,\"axis_scale\":\"normal\",\"truncate_legend\":1,\"max_lines_legend\":1,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":1,\"isModelInvalid\":false}}" } }, "type": "_doc" diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index d4b462d2a68f47..ab3028d3392121 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -236,6 +236,12 @@ export class DashboardExpectService extends FtrService { }); } + async savedSearchNoResult() { + const savedSearchTable = await this.testSubjects.find('embeddedSavedSearchDocTable'); + const resultStr = await savedSearchTable.getVisibleText(); + expect(resultStr).to.be('No results found'); + } + async savedSearchRowsExist() { this.testSubjects.existOrFail('docTableExpandToggleColumn'); } diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx index b84e4faf2925e7..46a346e4b9d194 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx @@ -180,9 +180,11 @@ export const WorkpadHeader: FC = ({ - - - + {isWriteable && ( + + + + )} diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 4647be5a91747c..2d5a7d1c336748 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -7,6 +7,7 @@ import { CASE_DETAILS_URL, + CASE_METRICS_DETAILS_URL, CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, @@ -22,6 +23,10 @@ export const getCaseDetailsUrl = (id: string): string => { return CASE_DETAILS_URL.replace('{case_id}', id); }; +export const getCaseDetailsMetricsUrl = (id: string): string => { + return CASE_METRICS_DETAILS_URL.replace('{case_id}', id); +}; + export const getSubCasesUrl = (caseID: string): string => { return SUB_CASES_URL.replace('{case_id}', caseID); }; diff --git a/x-pack/plugins/cases/common/api/metrics/case.ts b/x-pack/plugins/cases/common/api/metrics/case.ts index 5ed1eab2a4cdd7..0491eea61e070f 100644 --- a/x-pack/plugins/cases/common/api/metrics/case.ts +++ b/x-pack/plugins/cases/common/api/metrics/case.ts @@ -64,13 +64,12 @@ export const CaseMetricsResponseRt = rt.partial( /** * External connectors associated with the case */ - connectors: rt.array( - rt.type({ - id: rt.string, - name: rt.string, - pushCount: rt.number, - }) - ), + connectors: rt.type({ + /** + * Total number of connectors in the case + */ + total: rt.number, + }), /** * The case's open,close,in-progress details */ diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 70ccb1ddebd105..a5ae6f6b28ffbc 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ import { ConnectorTypes } from './api'; -import { CasesContextValue } from './ui/types'; +import { CasesContextFeatures } from './ui/types'; export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; @@ -110,6 +110,7 @@ export const MAX_TITLE_LENGTH = 64; * Cases features */ -export const DEFAULT_FEATURES: CasesContextValue['features'] = Object.freeze({ +export const DEFAULT_FEATURES: CasesContextFeatures = Object.freeze({ alerts: { sync: true }, + metrics: [], }); diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts index 41bba2ee2194d1..31f0d9daed4f0f 100644 --- a/x-pack/plugins/cases/common/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -19,13 +19,7 @@ export { CASES_URL, SECURITY_SOLUTION_OWNER, ENABLE_CASE_CONNECTOR } from './con export { CommentType, CaseStatuses, getCasesFromAlertsUrl, throwErrors } from './api'; -export type { - SubCase, - Case, - Ecs, - CasesContextValue, - CaseViewRefreshPropInterface, -} from './ui/types'; +export type { SubCase, Case, Ecs, CasesFeatures, CaseViewRefreshPropInterface } from './ui/types'; export { StatusAll } from './ui/types'; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index c51c25c5b976c5..1cb8b094dbfeae 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -17,19 +17,23 @@ import { UserAction, UserActionField, ActionConnector, + CaseMetricsResponse, } from '../api'; -interface CasesFeatures { +export interface CasesContextFeatures { alerts: { sync: boolean }; + metrics: CaseMetricsFeature[]; } +export type CasesFeatures = Partial; + export interface CasesContextValue { owner: string[]; appId: string; appTitle: string; userCanCrud: boolean; basePath: string; - features: CasesFeatures; + features: CasesContextFeatures; } export interface CasesUiConfigType { @@ -52,11 +56,8 @@ export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; */ export type CaseViewRefreshPropInterface = null | { /** - * Refreshes the all of the user actions/comments in the view's timeline - * (note: this also triggers a silent `refreshCase()`) + * Refreshes the case its metrics and user actions/comments in the view's timeline */ - refreshUserActionsAndComments: () => Promise; - /** Refreshes the Case information only */ refreshCase: () => Promise; }; @@ -162,6 +163,14 @@ export interface AllCases extends CasesStatus { total: number; } +export type CaseMetrics = CaseMetricsResponse; +export type CaseMetricsFeature = + | 'alerts.count' + | 'alerts.users' + | 'alerts.hosts' + | 'connectors' + | 'lifespan'; + export enum SortFieldCase { createdAt = 'createdAt', closedAt = 'closedAt', diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index c076ca28c9318b..179e1ad372c7a5 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -11,7 +11,7 @@ import { euiDarkVars } from '@kbn/ui-shared-deps-src/theme'; import { I18nProvider } from '@kbn/i18n-react'; import { ThemeProvider } from 'styled-components'; import { DEFAULT_FEATURES, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import { CasesContextValue } from '../../../common/ui/types'; +import { CasesFeatures } from '../../../common/ui/types'; import { CasesProvider } from '../../components/cases_context'; import { createKibanaContextProviderMock } from '../lib/kibana/kibana_react.mock'; import { FieldHook } from '../shared_imports'; @@ -19,7 +19,7 @@ import { FieldHook } from '../shared_imports'; interface Props { children: React.ReactNode; userCanCrud?: boolean; - features?: CasesContextValue['features']; + features?: CasesFeatures; } window.scrollTo = jest.fn(); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index ac81dfea2fd938..1432d6d707df4b 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -25,7 +25,7 @@ import { CaseService } from '../../containers/use_get_case_user_actions'; import { StatusContextMenu } from './status_context_menu'; import { getStatusDate, getStatusTitle } from './helpers'; import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch'; -import { OnUpdateFields } from '../case_view'; +import type { OnUpdateFields } from '../case_view/types'; import { useCasesFeatures } from '../cases_context/use_cases_features'; const MyDescriptionList = styled(EuiDescriptionList)` diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_metrics.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_metrics.test.tsx new file mode 100644 index 00000000000000..be954515cd653d --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/case_view_metrics.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { basicCaseMetrics, basicCaseMetricsFeatures } from '../../containers/mock'; +import { CaseViewMetrics } from './case_view_metrics'; +import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui'; +import { TestProviders } from '../../common/mock'; + +const renderCaseMetrics = ({ + metrics = basicCaseMetrics, + features = basicCaseMetricsFeatures, + isLoading = false, +}: { + metrics?: CaseMetrics; + features?: CaseMetricsFeature[]; + isLoading?: boolean; +} = {}) => { + return render( + + + + ); +}; + +const metricsFeaturesTests: Array<[CaseMetricsFeature, string, number]> = [ + ['alerts.count', 'Total Alerts', basicCaseMetrics.alerts!.count!], + ['alerts.users', 'Associated Users', basicCaseMetrics.alerts!.users!.total!], + ['alerts.hosts', 'Associated Hosts', basicCaseMetrics.alerts!.hosts!.total!], + ['connectors', 'Total Connectors', basicCaseMetrics.connectors!.total!], +]; + +describe('CaseViewMetrics', () => { + it('should render', () => { + const { getByTestId } = renderCaseMetrics(); + expect(getByTestId('case-view-metrics-panel')).toBeInTheDocument(); + }); + + it('should render loading spinner', () => { + const { getByTestId } = renderCaseMetrics({ isLoading: true }); + expect(getByTestId('case-view-metrics-spinner')).toBeInTheDocument(); + }); + + it('should render metrics', () => { + const { getByText } = renderCaseMetrics(); + expect(getByText('Total Alerts')).toBeInTheDocument(); + expect(getByText('Associated Users')).toBeInTheDocument(); + expect(getByText('Associated Hosts')).toBeInTheDocument(); + expect(getByText('Total Connectors')).toBeInTheDocument(); + }); + + it('should render metrics with default value 0', () => { + const { getByText, getAllByText } = renderCaseMetrics({ metrics: {} }); + expect(getByText('Total Alerts')).toBeInTheDocument(); + expect(getByText('Associated Users')).toBeInTheDocument(); + expect(getByText('Associated Hosts')).toBeInTheDocument(); + expect(getByText('Total Connectors')).toBeInTheDocument(); + expect(getAllByText('0')).toHaveLength(basicCaseMetricsFeatures.length); + }); + + describe.each(metricsFeaturesTests)('Metrics feature: %s ', (feature, text, total) => { + it('should render metric', () => { + const { getByText } = renderCaseMetrics({ features: [feature] }); + expect(getByText(text)).toBeInTheDocument(); + expect(getByText(total)).toBeInTheDocument(); + }); + + it('should not render other metrics', () => { + const { queryByText } = renderCaseMetrics({ features: [feature] }); + metricsFeaturesTests.forEach(([_, otherMetricText]) => { + if (otherMetricText !== text) { + expect(queryByText(otherMetricText)).toBeNull(); + } + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_metrics.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_metrics.tsx new file mode 100644 index 00000000000000..52c9c95655cfbd --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/case_view_metrics.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui'; +import { + ASSOCIATED_HOSTS_METRIC, + ASSOCIATED_USERS_METRIC, + TOTAL_ALERTS_METRIC, + TOTAL_CONNECTORS_METRIC, +} from './translations'; + +const MetricValue = styled(EuiFlexItem)` + font-size: ${({ theme }) => theme.eui.euiSizeL}; + font-weight: bold; +`; + +export interface CaseViewMetricsProps { + metrics: CaseMetrics | null; + features: CaseMetricsFeature[]; + isLoading: boolean; +} + +interface MetricItem { + title: string; + value: number; +} +type MetricItems = MetricItem[]; + +const useMetricItems = ( + metrics: CaseMetrics | null, + features: CaseMetricsFeature[] +): MetricItems => { + const { alerts, connectors } = metrics ?? {}; + const totalConnectors = connectors?.total ?? 0; + const alertsCount = alerts?.count ?? 0; + const totalAlertUsers = alerts?.users?.total ?? 0; + const totalAlertHosts = alerts?.hosts?.total ?? 0; + + const metricItems = useMemo(() => { + const items: Array<[CaseMetricsFeature, MetricItem]> = [ + ['alerts.count', { title: TOTAL_ALERTS_METRIC, value: alertsCount }], + ['alerts.users', { title: ASSOCIATED_USERS_METRIC, value: totalAlertUsers }], + ['alerts.hosts', { title: ASSOCIATED_HOSTS_METRIC, value: totalAlertHosts }], + ['connectors', { title: TOTAL_CONNECTORS_METRIC, value: totalConnectors }], + ]; + + return items.reduce( + (result: MetricItems, [feature, item]) => [ + ...result, + ...(features.includes(feature) ? [item] : []), + ], + [] + ); + }, [features, alertsCount, totalAlertUsers, totalAlertHosts, totalConnectors]); + + return metricItems; +}; + +const CaseViewMetricItems: React.FC<{ metricItems: MetricItems }> = React.memo( + ({ metricItems }) => ( + <> + {metricItems.map(({ title, value }, index) => ( + + + {title} + {value} + + + ))} + + ) +); +CaseViewMetricItems.displayName = 'CaseViewMetricItems'; + +export const CaseViewMetrics: React.FC = React.memo( + ({ metrics, features, isLoading }) => { + const metricItems = useMetricItems(metrics, features); + return ( + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); + } +); +CaseViewMetrics.displayName = 'CaseViewMetrics'; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx new file mode 100644 index 00000000000000..ac10068b88b3ef --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -0,0 +1,650 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import '../../common/mock/match_media'; +import { CaseViewPage } from './case_view_page'; +import { CaseViewPageProps } from './types'; +import { + basicCaseClosed, + basicCaseMetrics, + caseUserActions, + getAlertUserAction, +} from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { useUpdateCase } from '../../containers/use_update_case'; +import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; + +import { useConnectors } from '../../containers/configure/use_connectors'; +import { connectorsMock } from '../../containers/configure/mock'; +import { usePostPushToService } from '../../containers/use_post_push_to_service'; +import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; +import { CaseType, ConnectorTypes } from '../../../common/api'; +import { caseViewProps, caseData } from './index.test'; + +jest.mock('../../containers/use_update_case'); +jest.mock('../../containers/use_get_case_metrics'); +jest.mock('../../containers/use_get_case_user_actions'); +jest.mock('../../containers/use_get_case'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/use_post_push_to_service'); +jest.mock('../user_action_tree/user_action_timestamp'); +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const useConnectorsMock = useConnectors as jest.Mock; +const usePostPushToServiceMock = usePostPushToService as jest.Mock; + +export const caseProps: CaseViewPageProps = { + ...caseViewProps, + caseId: caseData.id, + caseData, + fetchCase: jest.fn(), + updateCase: jest.fn(), +}; + +export const caseClosedProps: CaseViewPageProps = { + ...caseProps, + caseData: basicCaseClosed, +}; + +describe('CaseViewPage', () => { + const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); + const pushCaseToExternalService = jest.fn(); + const fetchCaseMetrics = jest.fn(); + + const data = caseProps.caseData; + + const defaultUpdateCaseState = { + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + + const defaultUseGetCaseUserActions = { + caseUserActions: [...caseUserActions, getAlertUserAction()], + caseServices: {}, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: false, + lastIndexPushToService: -1, + participants: [data.createdBy], + }; + + const defaultGetCaseMetrics = { + isLoading: false, + isError: false, + metrics: basicCaseMetrics, + fetchCaseMetrics, + }; + + beforeEach(() => { + jest.clearAllMocks(); + useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState); + useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics); + useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions); + usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); + useConnectorsMock.mockReturnValue({ connectors: connectorsMock, loading: false }); + }); + + it('should render CaseViewPage', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( + data.title + ); + }); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( + 'Open' + ); + + expect(wrapper.find(`[data-test-subj="case-view-metrics"]`).exists()).toBeFalsy(); + + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) + .first() + .text() + ).toEqual(data.tags[0]); + + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) + .first() + .text() + ).toEqual(data.tags[1]); + + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( + data.createdBy.username + ); + + expect( + wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') + ).toEqual(data.createdAt); + + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) + .first() + .text() + ).toBe(data.description); + + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text() + ).toBe('Mark in progress'); + }); + + it('should render CaseViewPage with metrics', async () => { + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="case-view-metrics"]`).exists()).toBeTruthy(); + }); + }); + + it('should show closed indicators in header when case is closed', async () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: basicCaseClosed, + })); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') + ).toEqual(basicCaseClosed.closedAt); + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( + 'Closed' + ); + }); + }); + + it('should update status', async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click'); + wrapper + .find('button[data-test-subj="case-view-status-dropdown-closed"]') + .first() + .simulate('click'); + + await waitFor(() => { + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateCaseProperty).toHaveBeenCalledTimes(1); + expect(updateObject.updateKey).toEqual('status'); + expect(updateObject.updateValue).toEqual('closed'); + }); + }); + + it('should display EditableTitle isLoading', async () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'title', + })); + const wrapper = mount( + + + + ); + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="editable-title-loading"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists() + ).toBeFalsy(); + }); + }); + + it('should display description isLoading', async () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'description', + })); + const wrapper = mount( + + + + ); + await waitFor(() => { + expect( + wrapper + .find( + '[data-test-subj="description-action"] [data-test-subj="user-action-title-loading"]' + ) + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="description-action"] [data-test-subj="property-actions"]') + .first() + .exists() + ).toBeFalsy(); + }); + }); + + it('should display tags isLoading', async () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'tags', + })); + const wrapper = mount( + + + + ); + await waitFor(() => { + expect( + wrapper + .find('[data-test-subj="case-view-tag-list"] [data-test-subj="tag-list-loading"]') + .first() + .exists() + ).toBeTruthy(); + + expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy(); + }); + }); + + it('should update title', async () => { + const wrapper = mount( + + + + ); + const newTitle = 'The new title'; + wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click'); + wrapper + .find(`[data-test-subj="editable-title-input-field"]`) + .last() + .simulate('change', { target: { value: newTitle } }); + + wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click'); + + const updateObject = updateCaseProperty.mock.calls[0][0]; + await waitFor(() => { + expect(updateObject.updateKey).toEqual('title'); + expect(updateObject.updateValue).toEqual(newTitle); + }); + }); + + it('should push updates on button click', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists() + ).toBeTruthy(); + }); + wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click'); + + await waitFor(() => { + expect(pushCaseToExternalService).toHaveBeenCalled(); + }); + }); + + it('should disable the push button when connector is invalid', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + ); + await waitFor(() => { + expect( + wrapper.find('button[data-test-subj="push-to-external-service"]').first().prop('disabled') + ).toBeTruthy(); + }); + }); + + // TODO: fix when the useEffects in edit_connector are cleaned up + it.skip('should revert to the initial connector in case of failure', async () => { + updateCaseProperty.mockImplementation(({ onError }) => { + onError(); + }); + + const wrapper = mount( + + + + ); + const connectorName = wrapper + .find('[data-test-subj="settings-connector-card"] .euiTitle') + .first() + .text(); + + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); + await waitFor(() => wrapper.update()); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + await waitFor(() => wrapper.update()); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + await waitFor(() => wrapper.update()); + wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); + + await waitFor(() => { + wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('connector'); + expect( + wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text() + ).toBe(connectorName); + }); + }); + + it('should update connector', async () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(updateCaseProperty).toHaveBeenCalledTimes(1); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('connector'); + expect(updateObject.updateValue).toEqual({ + id: 'resilient-2', + name: 'My Connector 2', + type: ConnectorTypes.resilient, + fields: { + incidentTypes: null, + severityCode: null, + }, + }); + }); + }); + + it('it should call onComponentInitialized on mount', async () => { + const onComponentInitialized = jest.fn(); + mount( + + + + ); + + await waitFor(() => { + expect(onComponentInitialized).toHaveBeenCalled(); + }); + }); + + it('should show loading content when loading alerts', async () => { + const useFetchAlertData = jest.fn().mockReturnValue([true]); + useGetCaseUserActionsMock.mockReturnValue({ + caseServices: {}, + caseUserActions: [], + hasDataToPush: false, + isError: false, + isLoading: true, + participants: [], + }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="case-view-loading-content"]').first().exists() + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="user-actions"]').first().exists()).toBeFalsy(); + }); + }); + + it('should call show alert details with expected arguments', async () => { + const showAlertDetails = jest.fn(); + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="comment-action-show-alert-alert-action-id"] button') + .first() + .simulate('click'); + await waitFor(() => { + expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1'); + }); + }); + + it('should show the rule name', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper + .find( + '[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent' + ) + .first() + .text() + ).toBe('added an alert from Awesome rule'); + }); + }); + + it('should update settings', async () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); + await waitFor(() => { + wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('settings'); + expect(updateObject.updateValue).toEqual({ syncAlerts: false }); + }); + }); + + it('should show the correct connector name on the push button', async () => { + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper + .find('[data-test-subj="has-data-to-push-button"]') + .first() + .text() + .includes('My Connector 2') + ).toBe(true); + }); + }); + + describe('Callouts', () => { + it('it shows the danger callout when a connector has been deleted', async () => { + useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: false })); + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeTruthy(); + }); + }); + + it('it does NOT shows the danger callout when connectors are loading', async () => { + useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: true })); + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeFalsy(); + }); + }); + }); + + describe('Collections', () => { + it('it does not allow the user to update the status', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true); + expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true); + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists() + ).toBe(false); + }); + }); + + it('it shows the push button when has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true); + }); + }); + + it('it does not show the horizontal rule when does NOT has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: false, + })); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists() + ).toBe(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx new file mode 100644 index 00000000000000..3ff8845f1e3a54 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -0,0 +1,446 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingContent, + EuiText, +} from '@elastic/eui'; + +import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../common/api'; +import { Case, UpdateKey, UpdateByKey } from '../../../common/ui'; +import { EditableTitle } from '../header_page/editable_title'; +import { TagList } from '../tag_list'; +import { UserActionTree } from '../user_action_tree'; +import { UserList } from '../user_list'; +import { useUpdateCase } from '../../containers/use_update_case'; +import { getTypedPayload } from '../../containers/utils'; +import { ContentWrapper, WhitePageWrapper } from '../wrappers'; +import { CaseActionBar } from '../case_action_bar'; +import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; +import { EditConnector } from '../edit_connector'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { StatusActionButton } from '../status/button'; +import * as i18n from './translations'; +import { useTimelineContext } from '../timeline_context/use_timeline_context'; +import { getConnectorById } from '../utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useCaseViewNavigation } from '../../common/navigation'; +import { HeaderPage } from '../header_page'; +import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; +import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; +import { CaseViewMetrics } from './case_view_metrics'; +import type { CaseViewPageProps, OnUpdateFields } from './types'; +import { useCasesFeatures } from '../cases_context/use_cases_features'; + +const useOnUpdateField = ({ + caseData, + caseId, + subCaseId, + handleUpdateField, +}: { + caseData: Case; + caseId: string; + subCaseId?: string; + handleUpdateField: (newCase: Case, updateKey: UpdateKey) => void; +}) => { + const { + isLoading, + updateKey: loadingKey, + updateCaseProperty, + } = useUpdateCase({ caseId, subCaseId }); + + const onUpdateField = useCallback( + ({ key, value, onSuccess, onError }: OnUpdateFields) => { + const callUpdate = (updateKey: UpdateKey, updateValue: UpdateByKey['updateValue']) => + updateCaseProperty({ + updateKey, + updateValue, + updateCase: (newCase) => handleUpdateField(newCase, updateKey), + caseData, + onSuccess, + onError, + }); + + switch (key) { + case 'title': + const titleUpdate = getTypedPayload(value); + if (titleUpdate.length > 0) { + callUpdate('title', titleUpdate); + } + break; + case 'connector': + const connector = getTypedPayload(value); + if (connector != null) { + callUpdate('connector', connector); + } + break; + case 'description': + const descriptionUpdate = getTypedPayload(value); + if (descriptionUpdate.length > 0) { + callUpdate('description', descriptionUpdate); + } + break; + case 'tags': + const tagsUpdate = getTypedPayload(value); + callUpdate('tags', tagsUpdate); + break; + case 'status': + const statusUpdate = getTypedPayload(value); + if (caseData.status !== value) { + callUpdate('status', statusUpdate); + } + break; + case 'settings': + const settingsUpdate = getTypedPayload(value); + if (caseData.settings !== value) { + callUpdate('settings', settingsUpdate); + } + break; + default: + return null; + } + }, + [updateCaseProperty, handleUpdateField, caseData] + ); + return { onUpdateField, isLoading, loadingKey }; +}; + +export const CaseViewPage = React.memo( + ({ + caseData, + caseId, + fetchCase, + onComponentInitialized, + actionsNavigation, + ruleDetailsNavigation, + showAlertDetails, + subCaseId, + updateCase, + useFetchAlertData, + refreshRef, + }) => { + const { userCanCrud } = useCasesContext(); + const { metricsFeatures } = useCasesFeatures(); + const { getCaseViewUrl } = useCaseViewNavigation(); + useCasesTitleBreadcrumbs(caseData.title); + + const [initLoadingData, setInitLoadingData] = useState(true); + const init = useRef(true); + const timelineUi = useTimelineContext()?.ui; + + const { + caseUserActions, + fetchCaseUserActions, + caseServices, + hasDataToPush, + isLoading: isLoadingUserActions, + participants, + } = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId); + + const refetchCaseUserActions = useCallback(() => { + fetchCaseUserActions(caseId, caseData.connector.id, subCaseId); + }, [caseId, fetchCaseUserActions, subCaseId, caseData]); + + const { + metrics, + isLoading: isLoadingMetrics, + fetchCaseMetrics, + } = useGetCaseMetrics(caseId, metricsFeatures); + + const handleRefresh = useCallback(() => { + fetchCase(); + fetchCaseMetrics(); + refetchCaseUserActions(); + }, [fetchCase, refetchCaseUserActions, fetchCaseMetrics]); + + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(caseId, newCase.connector.id, subCaseId); + fetchCaseMetrics(); + }, + [updateCase, fetchCaseUserActions, caseId, subCaseId, fetchCaseMetrics] + ); + + const handleUpdateField = useCallback( + (newCase: Case, updateKey: UpdateKey) => { + updateCase({ ...newCase, comments: caseData.comments }); + fetchCaseUserActions(caseId, newCase.connector.id, subCaseId); + fetchCaseMetrics(); + }, + [updateCase, caseData, fetchCaseUserActions, caseId, subCaseId, fetchCaseMetrics] + ); + + const { onUpdateField, isLoading, loadingKey } = useOnUpdateField({ + caseId, + subCaseId, + caseData, + handleUpdateField, + }); + + // Set `refreshRef` if needed + useEffect(() => { + let isStale = false; + if (refreshRef) { + refreshRef.current = { + refreshCase: async () => { + // Do nothing if component (or instance of this render cycle) is stale or it is already loading + if (isStale || isLoading || isLoadingMetrics || isLoadingUserActions) { + return; + } + await Promise.all([fetchCase(true), fetchCaseMetrics(true), refetchCaseUserActions()]); + }, + }; + return () => { + isStale = true; + refreshRef.current = null; + }; + } + }, [ + fetchCase, + fetchCaseMetrics, + refetchCaseUserActions, + isLoadingUserActions, + isLoadingMetrics, + isLoading, + refreshRef, + updateCase, + ]); + + const { + loading: isLoadingConnectors, + connectors, + permissionsError, + } = useConnectors({ + toastPermissionsErrors: false, + }); + + const [connectorName, isValidConnector] = useMemo(() => { + const connector = connectors.find((c) => c.id === caseData.connector.id); + return [connector?.name ?? '', !!connector]; + }, [connectors, caseData.connector]); + + const currentExternalIncident = useMemo( + () => + caseServices != null && caseServices[caseData.connector.id] != null + ? caseServices[caseData.connector.id] + : null, + [caseServices, caseData.connector] + ); + + const onSubmitConnector = useCallback( + (connectorId, connectorFields, onError, onSuccess) => { + const connector = getConnectorById(connectorId, connectors); + const connectorToUpdate = connector + ? normalizeActionConnector(connector) + : getNoneConnector(); + + onUpdateField({ + key: 'connector', + value: { ...connectorToUpdate, fields: connectorFields }, + onSuccess, + onError, + }); + }, + [onUpdateField, connectors] + ); + + const onSubmitTags = useCallback( + (newTags) => onUpdateField({ key: 'tags', value: newTags }), + [onUpdateField] + ); + + const onSubmitTitle = useCallback( + (newTitle) => + onUpdateField({ + key: 'title', + value: newTitle, + }), + [onUpdateField] + ); + + const changeStatus = useCallback( + (status: CaseStatuses) => + onUpdateField({ + key: 'status', + value: status, + }), + [onUpdateField] + ); + + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(getCaseViewUrl({ detailName: caseId, subCaseId })), + }), + [caseData.title, getCaseViewUrl, caseId, subCaseId] + ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + + const onShowAlertDetails = useCallback( + (alertId: string, index: string) => { + if (showAlertDetails) { + showAlertDetails(alertId, index); + } + }, + [showAlertDetails] + ); + + // useEffect used for component's initialization + useEffect(() => { + if (init.current) { + init.current = false; + if (onComponentInitialized) { + onComponentInitialized(); + } + } + }, [onComponentInitialized]); + + return ( + <> + + } + title={caseData.title} + > + + + + + + + + {initLoadingData && ( + + )} + {!initLoadingData && ( + + {metricsFeatures.length > 0 && ( + <> + + + + + +

{i18n.ACTIVITY}

+ +
+
+ + )} + + + ) : null + } + updateCase={updateCase} + useFetchAlertData={useFetchAlertData} + userCanCrud={userCanCrud} + /> + +
+ )} +
+ + + + + + +
+
+
+ {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} + + ); + } +); +CaseViewPage.displayName = 'CaseViewPage'; diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.ts b/x-pack/plugins/cases/public/components/case_view/helpers.ts index 7f3924ef2564cf..04052d1eedea55 100644 --- a/x-pack/plugins/cases/public/components/case_view/helpers.ts +++ b/x-pack/plugins/cases/public/components/case_view/helpers.ts @@ -7,7 +7,7 @@ import { isEmpty } from 'lodash'; import { CommentType } from '../../../common/api'; -import { Comment } from '../../containers/types'; +import type { Comment } from '../../containers/types'; export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => { const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => { diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx index daa3ad44162002..29e9a387dc811f 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -10,35 +10,41 @@ import { mount } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import '../../common/mock/match_media'; -import { CaseComponent, CaseComponentProps, CaseView, CaseViewProps } from '.'; +import { CaseView } from '.'; +import { CaseViewProps } from './types'; import { basicCase, - basicCaseClosed, caseUserActions, alertComment, getAlertUserAction, + basicCaseMetrics, } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; import { SpacesApi } from '../../../../spaces/public'; import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetCase } from '../../containers/use_get_case'; +import { UseGetCase, useGetCase } from '../../containers/use_get_case'; +import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { CaseType, ConnectorTypes } from '../../../common/api'; +import { ConnectorTypes } from '../../../common/api'; +import { Case } from '../../../common/ui'; import { useKibana } from '../../common/lib/kibana'; jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_user_actions'); jest.mock('../../containers/use_get_case'); +jest.mock('../../containers/use_get_case_metrics'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../user_action_tree/user_action_timestamp'); jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); +const useGetCaseMock = useGetCase as jest.Mock; +const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; const useConnectorsMock = useConnectors as jest.Mock; @@ -79,8 +85,7 @@ const alertsHit = [ }, ]; -export const caseProps: CaseComponentProps = { - caseId: basicCase.id, +export const caseViewProps: CaseViewProps = { onComponentInitialized: jest.fn(), actionsNavigation: { href: jest.fn(), @@ -98,42 +103,43 @@ export const caseProps: CaseComponentProps = { 'alert-id-2': alertsHit[1], }, ], - caseData: { - ...basicCase, - comments: [...basicCase.comments, alertComment], - connector: { - id: 'resilient-2', - name: 'Resilient', - type: ConnectorTypes.resilient, - fields: null, - }, - }, - fetchCase: jest.fn(), - updateCase: jest.fn(), }; -export const caseClosedProps: CaseComponentProps = { - ...caseProps, - caseData: basicCaseClosed, +export const caseData: Case = { + ...basicCase, + comments: [...basicCase.comments, alertComment], + connector: { + id: 'resilient-2', + name: 'Resilient', + type: ConnectorTypes.resilient, + fields: null, + }, }; -describe('CaseView ', () => { +describe('CaseView', () => { const updateCaseProperty = jest.fn(); const fetchCaseUserActions = jest.fn(); const fetchCase = jest.fn(); + const fetchCaseMetrics = jest.fn(); const updateCase = jest.fn(); const pushCaseToExternalService = jest.fn(); - const data = caseProps.caseData; const defaultGetCase = { isLoading: false, isError: false, - data, + data: caseData, resolveOutcome: 'exactMatch', updateCase, fetchCase, }; + const defaultGetCaseMetrics = { + isLoading: false, + isError: false, + metrics: basicCaseMetrics, + fetchCaseMetrics, + }; + const defaultUpdateCaseState = { isLoading: false, isError: false, @@ -150,245 +156,32 @@ describe('CaseView ', () => { isLoading: false, isError: false, lastIndexPushToService: -1, - participants: [data.createdBy], + participants: [caseData.createdBy], }; - beforeEach(() => { - jest.clearAllMocks(); - useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); - useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); - usePostPushToServiceMock.mockImplementation(() => ({ - isLoading: false, - pushCaseToExternalService, - })); - useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: '.servicenow', - iconClass: 'logoSecurity', - }); - useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi; - }); - - it('should render CaseComponent', async () => { - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( - data.title - ); - }); - - expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( - 'Open' - ); - - expect( - wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) - .first() - .text() - ).toEqual(data.tags[0]); - - expect( - wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) - .first() - .text() - ).toEqual(data.tags[1]); - - expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( - data.createdBy.username - ); - - expect( - wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') - ).toEqual(data.createdAt); - - expect( - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) - .first() - .text() - ).toBe(data.description); - - expect( - wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text() - ).toBe('Mark in progress'); - }); - - it('should show closed indicators in header when case is closed', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - caseData: basicCaseClosed, - })); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') - ).toEqual(basicCaseClosed.closedAt); - expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( - 'Closed' - ); - }); - }); - - it('should update status', async () => { - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click'); - wrapper - .find('button[data-test-subj="case-view-status-dropdown-closed"]') - .first() - .simulate('click'); - - await waitFor(() => { - const updateObject = updateCaseProperty.mock.calls[0][0]; - expect(updateCaseProperty).toHaveBeenCalledTimes(1); - expect(updateObject.updateKey).toEqual('status'); - expect(updateObject.updateValue).toEqual('closed'); - }); - }); - - it('should display EditableTitle isLoading', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - isLoading: true, - updateKey: 'title', - })); - const wrapper = mount( - - - - ); - await waitFor(() => { - expect( - wrapper.find('[data-test-subj="editable-title-loading"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists() - ).toBeFalsy(); - }); - }); - - it('should display description isLoading', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - isLoading: true, - updateKey: 'description', - })); - const wrapper = mount( - - - - ); - await waitFor(() => { - expect( - wrapper - .find( - '[data-test-subj="description-action"] [data-test-subj="user-action-title-loading"]' - ) - .first() - .exists() - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="description-action"] [data-test-subj="property-actions"]') - .first() - .exists() - ).toBeFalsy(); - }); - }); - - it('should display tags isLoading', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - isLoading: true, - updateKey: 'tags', - })); - const wrapper = mount( - - - - ); - await waitFor(() => { - expect( - wrapper - .find('[data-test-subj="case-view-tag-list"] [data-test-subj="tag-list-loading"]') - .first() - .exists() - ).toBeTruthy(); - - expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy(); - }); - }); - - it('should update title', async () => { - const wrapper = mount( - - - - ); - const newTitle = 'The new title'; - wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click'); - wrapper - .find(`[data-test-subj="editable-title-input-field"]`) - .last() - .simulate('change', { target: { value: newTitle } }); - - wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click'); + const mockGetCase = (props: Partial = {}) => { + useGetCaseMock.mockReturnValue({ ...defaultGetCase, ...props }); + }; - const updateObject = updateCaseProperty.mock.calls[0][0]; - await waitFor(() => { - expect(updateObject.updateKey).toEqual('title'); - expect(updateObject.updateValue).toEqual(newTitle); - }); + beforeAll(() => { + mockGetCase(); + useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics); + useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState); + useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions); + usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); + useConnectorsMock.mockReturnValue({ connectors: connectorsMock, loading: false }); + useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi; }); - it('should push updates on button click', async () => { - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - hasDataToPush: true, - })); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists() - ).toBeTruthy(); - }); - wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click'); - - await waitFor(() => { - expect(pushCaseToExternalService).toHaveBeenCalled(); - }); + beforeEach(() => { + jest.clearAllMocks(); }); it('should return null if error', async () => { - (useGetCase as jest.Mock).mockImplementation(() => ({ - ...defaultGetCase, - isError: true, - })); + mockGetCase({ isError: true }); const wrapper = mount( - + ); await waitFor(() => { @@ -397,13 +190,10 @@ describe('CaseView ', () => { }); it('should return spinner if loading', async () => { - (useGetCase as jest.Mock).mockImplementation(() => ({ - ...defaultGetCase, - isLoading: true, - })); + mockGetCase({ isLoading: true }); const wrapper = mount( - + ); await waitFor(() => { @@ -412,13 +202,10 @@ describe('CaseView ', () => { }); it('should return case view when data is there', async () => { - (useGetCase as jest.Mock).mockImplementation(() => ({ - ...defaultGetCase, - resolveOutcome: 'exactMatch', - })); + mockGetCase({ resolveOutcome: 'exactMatch' }); const wrapper = mount( - + ); await waitFor(() => { @@ -430,14 +217,10 @@ describe('CaseView ', () => { it('should redirect case view when resolves to alias match', async () => { const resolveAliasId = `${defaultGetCase.data.id}_2`; - (useGetCase as jest.Mock).mockImplementation(() => ({ - ...defaultGetCase, - resolveOutcome: 'aliasMatch', - resolveAliasId, - })); + mockGetCase({ resolveOutcome: 'aliasMatch', resolveAliasId }); const wrapper = mount( - + ); await waitFor(() => { @@ -452,14 +235,10 @@ describe('CaseView ', () => { it('should redirect case view when resolves to conflict', async () => { const resolveAliasId = `${defaultGetCase.data.id}_2`; - (useGetCase as jest.Mock).mockImplementation(() => ({ - ...defaultGetCase, - resolveOutcome: 'conflict', - resolveAliasId, - })); + mockGetCase({ resolveOutcome: 'conflict', resolveAliasId }); const wrapper = mount( - + ); await waitFor(() => { @@ -479,245 +258,17 @@ describe('CaseView ', () => { (useGetCase as jest.Mock).mockImplementation(() => defaultGetCase); const wrapper = mount( - + ); wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click'); await waitFor(() => { - expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id, 'resilient-2', undefined); + expect(fetchCaseUserActions).toBeCalledWith(caseData.id, 'resilient-2', undefined); + expect(fetchCaseMetrics).toBeCalled(); expect(fetchCase).toBeCalled(); }); }); - it('should disable the push button when connector is invalid', async () => { - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - hasDataToPush: true, - })); - - const wrapper = mount( - - - - ); - await waitFor(() => { - expect( - wrapper.find('button[data-test-subj="push-to-external-service"]').first().prop('disabled') - ).toBeTruthy(); - }); - }); - - // TODO: fix when the useEffects in edit_connector are cleaned up - it.skip('should revert to the initial connector in case of failure', async () => { - updateCaseProperty.mockImplementation(({ onError }) => { - onError(); - }); - - const wrapper = mount( - - - - ); - const connectorName = wrapper - .find('[data-test-subj="settings-connector-card"] .euiTitle') - .first() - .text(); - - wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); - await waitFor(() => wrapper.update()); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - await waitFor(() => wrapper.update()); - wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); - await waitFor(() => wrapper.update()); - wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); - - await waitFor(() => { - wrapper.update(); - const updateObject = updateCaseProperty.mock.calls[0][0]; - expect(updateObject.updateKey).toEqual('connector'); - expect( - wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text() - ).toBe(connectorName); - }); - }); - - it('should update connector', async () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); - }); - - wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(updateCaseProperty).toHaveBeenCalledTimes(1); - const updateObject = updateCaseProperty.mock.calls[0][0]; - expect(updateObject.updateKey).toEqual('connector'); - expect(updateObject.updateValue).toEqual({ - id: 'resilient-2', - name: 'My Connector 2', - type: ConnectorTypes.resilient, - fields: { - incidentTypes: null, - severityCode: null, - }, - }); - }); - }); - - it('it should call onComponentInitialized on mount', async () => { - const onComponentInitialized = jest.fn(); - mount( - - - - ); - - await waitFor(() => { - expect(onComponentInitialized).toHaveBeenCalled(); - }); - }); - - it('should show loading content when loading alerts', async () => { - const useFetchAlertData = jest.fn().mockReturnValue([true]); - useGetCaseUserActionsMock.mockReturnValue({ - caseServices: {}, - caseUserActions: [], - hasDataToPush: false, - isError: false, - isLoading: true, - participants: [], - }); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find('[data-test-subj="case-view-loading-content"]').first().exists() - ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="user-actions"]').first().exists()).toBeFalsy(); - }); - }); - - it('should call show alert details with expected arguments', async () => { - const showAlertDetails = jest.fn(); - const wrapper = mount( - - - - ); - - wrapper - .find('[data-test-subj="comment-action-show-alert-alert-action-id"] button') - .first() - .simulate('click'); - await waitFor(() => { - expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1'); - }); - }); - - it('should show the rule name', async () => { - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper - .find( - '[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent' - ) - .first() - .text() - ).toBe('added an alert from Awesome rule'); - }); - }); - - it('should update settings', async () => { - const wrapper = mount( - - - - ); - - wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); - await waitFor(() => { - wrapper.update(); - const updateObject = updateCaseProperty.mock.calls[0][0]; - expect(updateObject.updateKey).toEqual('settings'); - expect(updateObject.updateValue).toEqual({ syncAlerts: false }); - }); - }); - - it('should show the correct connector name on the push button', async () => { - useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - hasDataToPush: true, - })); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper - .find('[data-test-subj="has-data-to-push-button"]') - .first() - .text() - .includes('My Connector 2') - ).toBe(true); - }); - }); - describe('when a `refreshRef` prop is provided', () => { let refreshRef: CaseViewProps['refreshRef']; @@ -744,121 +295,18 @@ describe('CaseView ', () => { }); it('should set it with expected refresh interface', async () => { - await waitFor(() => { - expect(refreshRef!.current).toEqual({ - refreshUserActionsAndComments: expect.any(Function), - refreshCase: expect.any(Function), - }); + expect(refreshRef!.current).toEqual({ + refreshCase: expect.any(Function), }); }); it('should refresh actions and comments', async () => { + refreshRef!.current!.refreshCase(); await waitFor(() => { - refreshRef!.current!.refreshUserActionsAndComments(); expect(fetchCaseUserActions).toBeCalledWith('basic-case-id', 'resilient-2', undefined); + expect(fetchCaseMetrics).toBeCalledWith(true); expect(fetchCase).toBeCalledWith(true); }); }); - - it('should refresh case', async () => { - await waitFor(() => { - refreshRef!.current!.refreshCase(); - expect(fetchCase).toBeCalledWith(); // No args given to `fetchCase()` - }); - }); - }); - - describe('Callouts', () => { - it('it shows the danger callout when a connector has been deleted', async () => { - useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: false })); - const wrapper = mount( - - - - ); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeTruthy(); - }); - }); - - it('it does NOT shows the danger callout when connectors are loading', async () => { - useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: true })); - const wrapper = mount( - - - - ); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeFalsy(); - }); - }); - }); - - describe('Collections', () => { - it('it does not allow the user to update the status', async () => { - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true); - expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false); - expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true); - expect( - wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists() - ).toBe(false); - }); - }); - - it('it shows the push button when has data to push', async () => { - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - hasDataToPush: true, - })); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true); - }); - }); - - it('it does not show the horizontal rule when does NOT has data to push', async () => { - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - hasDataToPush: false, - })); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists() - ).toBe(false); - }); - }); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index c436547c9e2bdd..afb1666d0f3708 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -5,480 +5,24 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react'; +import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { Case, Ecs, CaseViewRefreshPropInterface } from '../../../common/ui/types'; -import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../common/api'; -import { HeaderPage } from '../header_page'; -import { EditableTitle } from '../header_page/editable_title'; -import { TagList } from '../tag_list'; -import { UseGetCase, useGetCase } from '../../containers/use_get_case'; -import { UserActionTree } from '../user_action_tree'; -import { UserList } from '../user_list'; -import { useUpdateCase } from '../../containers/use_update_case'; -import { getTypedPayload } from '../../containers/utils'; -import { ContentWrapper, WhitePageWrapper } from '../wrappers'; -import { CaseActionBar } from '../case_action_bar'; -import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; -import { EditConnector } from '../edit_connector'; -import { useConnectors } from '../../containers/configure/use_connectors'; -import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; -import { StatusActionButton } from '../status/button'; +import { useGetCase } from '../../containers/use_get_case'; import * as i18n from './translations'; -import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context'; -import { useTimelineContext } from '../timeline_context/use_timeline_context'; -import { CasesNavigation } from '../links'; -import { getConnectorById } from '../utils'; +import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { DoesNotExist } from './does_not_exist'; import { useKibana } from '../../common/lib/kibana'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { - generateCaseViewPath, - useCaseViewNavigation, - useCaseViewParams, -} from '../../common/navigation'; -import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; - -export interface CaseViewComponentProps { - caseId: string; - subCaseId?: string; - onComponentInitialized?: () => void; - actionsNavigation?: CasesNavigation; - ruleDetailsNavigation?: CasesNavigation; - showAlertDetails?: (alertId: string, index: string) => void; - useFetchAlertData: (alertIds: string[]) => [boolean, Record]; - /** - * A React `Ref` that Exposes data refresh callbacks. - * **NOTE**: Do not hold on to the `.current` object, as it could become stale - */ - refreshRef?: MutableRefObject; -} - -export interface CaseViewProps extends Omit { - timelineIntegration?: CasesTimelineIntegration; -} - -export interface OnUpdateFields { - key: keyof Case; - value: Case[keyof Case]; - onSuccess?: () => void; - onError?: () => void; -} +import { generateCaseViewPath, useCaseViewParams } from '../../common/navigation'; +import { CaseViewPage } from './case_view_page'; +import type { CaseViewProps } from './types'; const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; -export interface CaseComponentProps extends CaseViewComponentProps { - fetchCase: UseGetCase['fetchCase']; - caseData: Case; - updateCase: (newCase: Case) => void; -} - -export const CaseComponent = React.memo( - ({ - caseData, - caseId, - fetchCase, - onComponentInitialized, - actionsNavigation, - ruleDetailsNavigation, - showAlertDetails, - subCaseId, - updateCase, - useFetchAlertData, - refreshRef, - }) => { - const { userCanCrud } = useCasesContext(); - const { getCaseViewUrl } = useCaseViewNavigation(); - useCasesTitleBreadcrumbs(caseData.title); - - const [initLoadingData, setInitLoadingData] = useState(true); - const init = useRef(true); - const timelineUi = useTimelineContext()?.ui; - - const { - caseUserActions, - fetchCaseUserActions, - caseServices, - hasDataToPush, - isLoading: isLoadingUserActions, - participants, - } = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId); - - const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ - caseId, - subCaseId, - }); - - // Set `refreshRef` if needed - useEffect(() => { - let isStale = false; - - if (refreshRef) { - refreshRef.current = { - refreshCase: async () => { - // Do nothing if component (or instance of this render cycle) is stale - if (isStale) { - return; - } - - await fetchCase(); - }, - refreshUserActionsAndComments: async () => { - // Do nothing if component (or instance of this render cycle) is stale - // -- OR -- - // it is already loading - if (isStale || isLoadingUserActions) { - return; - } - - await Promise.all([ - fetchCase(true), - fetchCaseUserActions(caseId, caseData.connector.id, subCaseId), - ]); - }, - }; - - return () => { - isStale = true; - refreshRef.current = null; - }; - } - }, [ - caseData.connector.id, - caseId, - fetchCase, - fetchCaseUserActions, - isLoadingUserActions, - refreshRef, - subCaseId, - updateCase, - ]); - - // Update Fields - const onUpdateField = useCallback( - ({ key, value, onSuccess, onError }: OnUpdateFields) => { - const handleUpdateNewCase = (newCase: Case) => - updateCase({ ...newCase, comments: caseData.comments }); - switch (key) { - case 'title': - const titleUpdate = getTypedPayload(value); - if (titleUpdate.length > 0) { - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'title', - updateValue: titleUpdate, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - } - break; - case 'connector': - const connector = getTypedPayload(value); - if (connector != null) { - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'connector', - updateValue: connector, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - } - break; - case 'description': - const descriptionUpdate = getTypedPayload(value); - if (descriptionUpdate.length > 0) { - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'description', - updateValue: descriptionUpdate, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - } - break; - case 'tags': - const tagsUpdate = getTypedPayload(value); - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'tags', - updateValue: tagsUpdate, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - break; - case 'status': - const statusUpdate = getTypedPayload(value); - if (caseData.status !== value) { - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'status', - updateValue: statusUpdate, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - } - break; - case 'settings': - const settingsUpdate = getTypedPayload(value); - if (caseData.settings !== value) { - updateCaseProperty({ - fetchCaseUserActions, - updateKey: 'settings', - updateValue: settingsUpdate, - updateCase: handleUpdateNewCase, - caseData, - onSuccess, - onError, - }); - } - break; - default: - return null; - } - }, - [fetchCaseUserActions, updateCaseProperty, updateCase, caseData] - ); - - const handleUpdateCase = useCallback( - (newCase: Case) => { - updateCase(newCase); - fetchCaseUserActions(caseId, newCase.connector.id, subCaseId); - }, - [updateCase, fetchCaseUserActions, caseId, subCaseId] - ); - - const { - loading: isLoadingConnectors, - connectors, - permissionsError, - } = useConnectors({ - toastPermissionsErrors: false, - }); - - const [connectorName, isValidConnector] = useMemo(() => { - const connector = connectors.find((c) => c.id === caseData.connector.id); - return [connector?.name ?? '', !!connector]; - }, [connectors, caseData.connector]); - - const currentExternalIncident = useMemo( - () => - caseServices != null && caseServices[caseData.connector.id] != null - ? caseServices[caseData.connector.id] - : null, - [caseServices, caseData.connector] - ); - - const onSubmitConnector = useCallback( - (connectorId, connectorFields, onError, onSuccess) => { - const connector = getConnectorById(connectorId, connectors); - const connectorToUpdate = connector - ? normalizeActionConnector(connector) - : getNoneConnector(); - - onUpdateField({ - key: 'connector', - value: { ...connectorToUpdate, fields: connectorFields }, - onSuccess, - onError, - }); - }, - [onUpdateField, connectors] - ); - - const onSubmitTags = useCallback( - (newTags) => onUpdateField({ key: 'tags', value: newTags }), - [onUpdateField] - ); - - const onSubmitTitle = useCallback( - (newTitle) => - onUpdateField({ - key: 'title', - value: newTitle, - }), - [onUpdateField] - ); - - const changeStatus = useCallback( - (status: CaseStatuses) => - onUpdateField({ - key: 'status', - value: status, - }), - [onUpdateField] - ); - - const handleRefresh = useCallback(() => { - fetchCaseUserActions(caseId, caseData.connector.id, subCaseId); - fetchCase(); - }, [caseData.connector.id, caseId, fetchCase, fetchCaseUserActions, subCaseId]); - - const emailContent = useMemo( - () => ({ - subject: i18n.EMAIL_SUBJECT(caseData.title), - body: i18n.EMAIL_BODY(getCaseViewUrl({ detailName: caseId, subCaseId })), - }), - [caseData.title, getCaseViewUrl, caseId, subCaseId] - ); - - useEffect(() => { - if (initLoadingData && !isLoadingUserActions) { - setInitLoadingData(false); - } - }, [initLoadingData, isLoadingUserActions]); - - const onShowAlertDetails = useCallback( - (alertId: string, index: string) => { - if (showAlertDetails) { - showAlertDetails(alertId, index); - } - }, - [showAlertDetails] - ); - - // useEffect used for component's initialization - useEffect(() => { - if (init.current) { - init.current = false; - if (onComponentInitialized) { - onComponentInitialized(); - } - } - }, [onComponentInitialized]); - - return ( - <> - - } - title={caseData.title} - > - - - - - - - - {initLoadingData && ( - - )} - {!initLoadingData && ( - <> - - ) : null - } - updateCase={updateCase} - useFetchAlertData={useFetchAlertData} - userCanCrud={userCanCrud} - /> - - )} - - - - - - - - - - - {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} - - ); - } -); - export const CaseViewLoading = () => ( @@ -539,7 +83,7 @@ export const CaseView = React.memo( data && ( {getLegacyUrlConflictCallout()} - i18n.translate('xpack.cases.caseView.emailSubject', { values: { caseTitle }, @@ -131,3 +135,28 @@ export const DOES_NOT_EXIST_DESCRIPTION = (caseId: string) => export const DOES_NOT_EXIST_BUTTON = i18n.translate('xpack.cases.caseView.doesNotExist.button', { defaultMessage: 'Back to Cases', }); + +export const TOTAL_ALERTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.totalAlerts', { + defaultMessage: 'Total Alerts', +}); + +export const ASSOCIATED_USERS_METRIC = i18n.translate( + 'xpack.cases.caseView.metrics.associatedUsers', + { + defaultMessage: 'Associated Users', + } +); + +export const ASSOCIATED_HOSTS_METRIC = i18n.translate( + 'xpack.cases.caseView.metrics.associatedHosts', + { + defaultMessage: 'Associated Hosts', + } +); + +export const TOTAL_CONNECTORS_METRIC = i18n.translate( + 'xpack.cases.caseView.metrics.totalConnectors', + { + defaultMessage: 'Total Connectors', + } +); diff --git a/x-pack/plugins/cases/public/components/case_view/types.ts b/x-pack/plugins/cases/public/components/case_view/types.ts new file mode 100644 index 00000000000000..525bbcd225cb24 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { MutableRefObject } from 'react'; +import { CasesTimelineIntegration } from '../timeline_context'; +import { CasesNavigation } from '../links'; +import { CaseViewRefreshPropInterface, Ecs, Case } from '../../../common'; +import { UseGetCase } from '../../containers/use_get_case'; + +export interface CaseViewBaseProps { + onComponentInitialized?: () => void; + actionsNavigation?: CasesNavigation; + ruleDetailsNavigation?: CasesNavigation; + showAlertDetails?: (alertId: string, index: string) => void; + useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + /** + * A React `Ref` that Exposes data refresh callbacks. + * **NOTE**: Do not hold on to the `.current` object, as it could become stale + */ + refreshRef?: MutableRefObject; +} + +export interface CaseViewProps extends CaseViewBaseProps { + timelineIntegration?: CasesTimelineIntegration; +} + +export interface CaseViewPageProps extends CaseViewBaseProps { + caseId: string; + subCaseId?: string; + fetchCase: UseGetCase['fetchCase']; + caseData: Case; + updateCase: (newCase: Case) => void; +} + +export interface OnUpdateFields { + key: keyof Case; + value: Case[keyof Case]; + onSuccess?: () => void; + onError?: () => void; +} diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index ecc5719cb81b7b..e24a08c38cfeb6 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect } from 'react'; import { merge } from 'lodash'; -import { CasesContextValue } from '../../../common/ui/types'; +import { CasesContextValue, CasesFeatures } from '../../../common/ui/types'; import { DEFAULT_FEATURES } from '../../../common/constants'; import { DEFAULT_BASE_PATH } from '../../common/navigation'; import { useApplication } from './use_application'; @@ -17,7 +17,7 @@ export const CasesContext = React.createContext(u export interface CasesContextProps extends Omit { basePath?: string; - features?: Partial; + features?: CasesFeatures; } export interface CasesContextStateValue extends Omit { @@ -30,7 +30,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ value: { owner, userCanCrud, basePath = DEFAULT_BASE_PATH, features = {} }, }) => { const { appId, appTitle } = useApplication(); - const [value, setValue] = useState({ + const [value, setValue] = useState(() => ({ owner, userCanCrud, basePath, @@ -39,7 +39,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ * of the DEFAULT_FEATURES object */ features: merge({}, DEFAULT_FEATURES, features), - }); + })); /** * `userCanCrud` prop may change by the parent plugin. diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx index 93efdcabd8c380..1e801edaa3b1bc 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx @@ -6,17 +6,22 @@ */ import { useMemo } from 'react'; +import { CaseMetricsFeature } from '../../containers/types'; import { useCasesContext } from './use_cases_context'; -interface UseCasesFeaturesReturn { +interface UseCasesFeatures { isSyncAlertsEnabled: boolean; + metricsFeatures: CaseMetricsFeature[]; } -export const useCasesFeatures = (): UseCasesFeaturesReturn => { +export const useCasesFeatures = (): UseCasesFeatures => { const { features } = useCasesContext(); - const memoizedReturnValue = useMemo( - () => ({ isSyncAlertsEnabled: features.alerts.sync }), + const casesFeatures = useMemo( + () => ({ + isSyncAlertsEnabled: features.alerts.sync, + metricsFeatures: features.metrics, + }), [features] ); - return memoizedReturnValue; + return casesFeatures; }; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index b5b76f36013c58..72e6df3bf5d9f4 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -35,7 +35,7 @@ import { } from '../../../common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseStringAsExternalService } from '../../common/user_actions'; -import { OnUpdateFields } from '../case_view'; +import type { OnUpdateFields } from '../case_view/types'; import { getConnectorLabelTitle, getLabelTitle, diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 843a9d81d80136..2cd30eb15d2d11 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -19,6 +19,7 @@ import { actionLicenses, allCases, basicCase, + basicCaseMetrics, basicCaseCommentPatch, basicCasePost, basicResolvedCase, @@ -35,6 +36,7 @@ import { CommentRequest, User, CaseStatuses, + CaseMetricsResponse, } from '../../../common/api'; export const getCase = async ( @@ -49,6 +51,11 @@ export const resolveCase = async ( signal: AbortSignal ): Promise => Promise.resolve(basicResolvedCase); +export const getCaseMetrics = async ( + caseId: string, + signal: AbortSignal +): Promise => Promise.resolve(basicCaseMetrics); + export const getCasesStatus = async (signal: AbortSignal): Promise => Promise.resolve(casesStatus); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 81bd6b39be5fde..6ca920fd5a8fe4 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -22,6 +22,7 @@ import { CommentType, getCaseCommentsUrl, getCaseDetailsUrl, + getCaseDetailsMetricsUrl, getCasePushUrl, getCaseUserActionUrl, getSubCaseDetailsUrl, @@ -30,6 +31,7 @@ import { SubCaseResponse, SubCasesResponse, User, + CaseMetricsResponse, } from '../../common/api'; import { CASE_REPORTERS_URL, @@ -48,6 +50,8 @@ import { AllCases, BulkUpdateStatus, Case, + CaseMetrics, + CaseMetricsFeature, CasesStatus, FetchCasesProps, SortFieldCase, @@ -64,6 +68,7 @@ import { decodeCasesStatusResponse, decodeCaseUserActionsResponse, decodeCaseResolveResponse, + decodeCaseMetricsResponse, } from './utils'; export const getCase = async ( @@ -157,6 +162,22 @@ export const getReporters = async (signal: AbortSignal, owner: string[]): Promis return response ?? []; }; +export const getCaseMetrics = async ( + caseId: string, + features: CaseMetricsFeature[], + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + getCaseDetailsMetricsUrl(caseId), + { + method: 'GET', + signal, + query: { features: JSON.stringify(features) }, + } + ); + return convertToCamelCase(decodeCaseMetricsResponse(response)); +}; + export const getCaseUserActions = async ( caseId: string, signal: AbortSignal diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 92fa8caa3ac5b2..3496bce1f129a7 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -8,7 +8,7 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } from './types'; import { isCreateConnector, isPush, isUpdateConnector } from '../../common/utils/user_actions'; -import { ResolvedCase } from '../../common/ui/types'; +import { CaseMetrics, CaseMetricsFeature, ResolvedCase } from '../../common/ui/types'; import { AssociationType, CaseUserActionConnector, @@ -34,6 +34,7 @@ export const basicSubCaseId = 'basic-sub-case-id'; const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; +const basicClosedAt = '2020-02-21T15:02:57.995Z'; const laterTime = '2020-02-28T15:02:57.995Z'; export const elasticUser = { @@ -168,6 +169,32 @@ export const basicResolvedCase: ResolvedCase = { aliasTargetId: `${basicCase.id}_2`, }; +export const basicCaseMetricsFeatures: CaseMetricsFeature[] = [ + 'alerts.count', + 'alerts.users', + 'alerts.hosts', + 'connectors', +]; + +export const basicCaseMetrics: CaseMetrics = { + alerts: { + count: 12, + hosts: { + total: 2, + values: [ + { name: 'foo', count: 2 }, + { name: 'bar', count: 10 }, + ], + }, + users: { + total: 1, + values: [{ name: 'Jon', count: 12 }], + }, + }, + connectors: { total: 1 }, + lifespan: { creationDate: basicCreatedAt, closeDate: basicClosedAt }, +}; + export const collectionCase: Case = { type: CaseType.collection, owner: SECURITY_SOLUTION_OWNER, diff --git a/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx new file mode 100644 index 00000000000000..73c69ec3889775 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { CaseMetricsFeature } from '../../common/ui'; +import { useGetCaseMetrics, UseGetCaseMetrics } from './use_get_case_metrics'; +import { basicCase, basicCaseMetrics } from './mock'; +import * as api from './api'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +describe('useGetCaseMetrics', () => { + const abortCtrl = new AbortController(); + const features: CaseMetricsFeature[] = ['alerts.count']; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + metrics: null, + isLoading: false, + isError: false, + fetchCaseMetrics: result.current.fetchCaseMetrics, + }); + }); + }); + + it('calls getCaseMetrics with correct arguments', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetCaseMetrics).toBeCalledWith(basicCase.id, features, abortCtrl.signal); + }); + }); + + it('does not call getCaseMetrics if empty feature parameter passed', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, []) + ); + await waitForNextUpdate(); + expect(spyOnGetCaseMetrics).not.toBeCalled(); + }); + }); + + it('fetch case metrics', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + metrics: basicCaseMetrics, + isLoading: false, + isError: false, + fetchCaseMetrics: result.current.fetchCaseMetrics, + }); + }); + }); + + it('refetch case metrics', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCaseMetrics(); + expect(spyOnGetCaseMetrics).toHaveBeenCalledTimes(2); + }); + }); + + it('set isLoading to true when refetching case metrics', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCaseMetrics(); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('set isLoading to false when refetching case metrics "silent"ly', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCaseMetrics(true); + + expect(result.current.isLoading).toBe(false); + }); + }); + + it('returns an error when getCaseMetrics throws', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + spyOnGetCaseMetrics.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCaseMetrics(basicCase.id, features) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + metrics: null, + isLoading: false, + isError: true, + fetchCaseMetrics: result.current.fetchCaseMetrics, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx b/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx new file mode 100644 index 00000000000000..411b43e050abfc --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useReducer, useCallback, useRef } from 'react'; + +import { CaseMetrics, CaseMetricsFeature } from './types'; +import * as i18n from './translations'; +import { useToasts } from '../common/lib/kibana'; +import { getCaseMetrics } from './api'; + +interface CaseMeticsState { + metrics: CaseMetrics | null; + isLoading: boolean; + isError: boolean; +} + +type Action = + | { type: 'FETCH_INIT'; payload: { silent: boolean } } + | { type: 'FETCH_SUCCESS'; payload: CaseMetrics } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: CaseMeticsState, action: Action): CaseMeticsState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: !action.payload?.silent, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + metrics: action.payload, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +export interface UseGetCaseMetrics extends CaseMeticsState { + /** + * @param [silent] When set to `true`, the `isLoading` property will not be set to `true` + * while doing the API call + */ + fetchCaseMetrics: (silent?: boolean) => Promise; +} + +export const useGetCaseMetrics = ( + caseId: string, + features: CaseMetricsFeature[] +): UseGetCaseMetrics => { + const [state, dispatch] = useReducer(dataFetchReducer, { + metrics: null, + isLoading: false, + isError: false, + }); + const toasts = useToasts(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const callFetch = useCallback( + async (silent: boolean = false) => { + if (features.length === 0) { + return; + } + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: { silent } }); + + const response: CaseMetrics = await getCaseMetrics( + caseId, + features, + abortCtrlRef.current.signal + ); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + dispatch({ type: 'FETCH_FAILURE' }); + } + } + }, + [caseId, features, toasts] + ); + + useEffect(() => { + callFetch(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, [callFetch]); + + return { ...state, fetchCaseMetrics: callFetch }; +}; diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index 938724a632dcba..069c883b993925 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -32,6 +32,8 @@ import { CasePatchRequest, CaseResolveResponse, CaseResolveResponseRt, + CaseMetricsResponse, + CaseMetricsResponseRt, } from '../../common/api'; import { AllCases, Case, UpdateByKey } from './types'; import * as i18n from './translations'; @@ -88,6 +90,12 @@ export const decodeCaseResolveResponse = (respCase?: CaseResolveResponse) => fold(throwErrors(createToasterPlainError), identity) ); +export const decodeCaseMetricsResponse = (respCase?: CaseMetricsResponse) => + pipe( + CaseMetricsResponseRt.decode(respCase), + fold(throwErrors(createToasterPlainError), identity) + ); + export const decodeCasesResponse = (respCase?: CasesResponse) => pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); diff --git a/x-pack/plugins/cases/server/client/metrics/alert_details.ts b/x-pack/plugins/cases/server/client/metrics/alert_details.ts index 5d25ab5dc12269..c17374ee7d2c99 100644 --- a/x-pack/plugins/cases/server/client/metrics/alert_details.ts +++ b/x-pack/plugins/cases/server/client/metrics/alert_details.ts @@ -16,7 +16,7 @@ export class AlertDetails implements MetricsHandler { private retrievedMetrics: boolean = false; public getFeatures(): Set { - return new Set(['alertHosts', 'alertUsers']); + return new Set(['alerts.hosts', 'alerts.users']); } public async compute(): Promise { diff --git a/x-pack/plugins/cases/server/client/metrics/alerts_count.ts b/x-pack/plugins/cases/server/client/metrics/alerts_count.ts index 118761acb36804..786a53310cb88b 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts_count.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts_count.ts @@ -20,7 +20,7 @@ export class AlertsCount implements MetricsHandler { ) {} public getFeatures(): Set { - return new Set(['alertsCount']); + return new Set(['alerts.count']); } public async compute(): Promise { diff --git a/x-pack/plugins/cases/server/client/metrics/connectors.ts b/x-pack/plugins/cases/server/client/metrics/connectors.ts index 727b5576b4fa28..83e1270baf8467 100644 --- a/x-pack/plugins/cases/server/client/metrics/connectors.ts +++ b/x-pack/plugins/cases/server/client/metrics/connectors.ts @@ -15,7 +15,7 @@ export class Connectors implements MetricsHandler { public async compute(): Promise { return { - connectors: [], + connectors: { total: 0 }, }; } } diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts index b192e681df1097..b34a4fe4e9d394 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts @@ -78,9 +78,9 @@ describe('getMetrics', () => { }); }); - it('populates the alertHosts and alertUsers sections', async () => { + it('populates the alerts.hosts and alerts.users sections', async () => { const metrics = await getCaseMetrics( - { caseId: '', features: ['alertHosts'] }, + { caseId: '', features: ['alerts.hosts', 'alerts.users'] }, client, clientArgs ); @@ -91,7 +91,7 @@ describe('getMetrics', () => { it('populates multiple sections at a time', async () => { const metrics = await getCaseMetrics( - { caseId: '', features: ['alertsCount', 'lifespan'] }, + { caseId: '', features: ['alerts.count', 'lifespan'] }, client, clientArgs ); @@ -105,7 +105,7 @@ describe('getMetrics', () => { it('populates multiple alerts sections at a time', async () => { const metrics = await getCaseMetrics( - { caseId: '', features: ['alertsCount', 'alertHosts'] }, + { caseId: '', features: ['alerts.count', 'alerts.hosts'] }, client, clientArgs ); @@ -127,7 +127,7 @@ describe('getMetrics', () => { try { await getCaseMetrics( - { caseId: '', features: ['bananas', 'lifespan', 'alertsCount'] }, + { caseId: '', features: ['bananas', 'lifespan', 'alerts.count'] }, client, clientArgs ); diff --git a/x-pack/plugins/data_visualizer/kibana.json b/x-pack/plugins/data_visualizer/kibana.json index e8bca61a51ee85..9687a896c2c416 100644 --- a/x-pack/plugins/data_visualizer/kibana.json +++ b/x-pack/plugins/data_visualizer/kibana.json @@ -11,7 +11,8 @@ "share", "discover", "fileUpload", - "uiActions" + "uiActions", + "charts" ], "optionalPlugins": [ "security", @@ -27,7 +28,6 @@ "maps", "esUiShared", "fieldFormats", - "charts", "uiActions" ], "extraPublicDirs": [ diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx index 13b68d3b192cc9..d38e93b66a8daf 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx @@ -57,9 +57,12 @@ export const DocumentCountChart: FC = ({ interval, }) => { const { - services: { data, uiSettings, fieldFormats }, + services: { data, uiSettings, fieldFormats, charts }, } = useDataVisualizerKibana(); + const chartTheme = charts.theme.useChartsTheme(); + const chartBaseTheme = charts.theme.useChartsBaseTheme(); + const xAxisFormatter = fieldFormats.deserialize({ id: 'date' }); const useLegacyTimeAxis = uiSettings.get('visualization:useLegacyTimeAxis', false); @@ -134,6 +137,8 @@ export const DocumentCountChart: FC = ({ xDomain={xDomain} onBrushEnd={onBrushEnd as BrushEndListener} onElementClick={onElementClick} + theme={chartTheme} + baseTheme={chartBaseTheme} /> = ({ additionalLinks }) => { }; return ( - - - + + + + + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx index 243420b4745c97..9ec574c95c3521 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx @@ -20,7 +20,10 @@ import { EmbeddableOutput, IContainer, } from '../../../../../../../../src/plugins/embeddable/public'; -import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../../../../src/plugins/kibana_react/public'; import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants'; import { EmbeddableLoading } from './embeddable_loading_fallback'; import { DataVisualizerStartDependencies } from '../../../../plugin'; @@ -204,16 +207,18 @@ export class DataVisualizerGridEmbeddable extends Embeddable< ReactDOM.render( - - }> - this.updateOutput(output)} - /> - - + + + }> + this.updateOutput(output)} + /> + + + , node ); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index a2b8e7d5d4b0d1..64408ae966f3c1 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -12,7 +12,10 @@ import { isEqual } from 'lodash'; import { encode } from 'rison-node'; import { SimpleSavedObject } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { getCoreStart, getPluginsStart } from '../../kibana_services'; import { IndexDataVisualizerViewProps, @@ -189,6 +192,7 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add lens, dataViewFieldEditor, uiActions, + charts, } = getPluginsStart(); const services = { data, @@ -200,15 +204,18 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add lens, dataViewFieldEditor, uiActions, + charts, ...coreStart, }; return ( - - - + + + + + ); }; diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 39476bef54da42..4bb81d43073f02 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -6,6 +6,7 @@ */ import { CoreSetup, CoreStart } from 'kibana/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { SharePluginStart } from '../../../../src/plugins/share/public'; import { Plugin } from '../../../../src/core/public'; @@ -37,6 +38,7 @@ export interface DataVisualizerStartDependencies { security?: SecurityPluginSetup; share: SharePluginStart; lens?: LensPublicStart; + charts: ChartsPluginStart; dataViewFieldEditor?: IndexPatternFieldEditorStart; fieldFormats: FieldFormatsStart; uiActions?: UiActionsStart; diff --git a/x-pack/plugins/fleet/common/authz.ts b/x-pack/plugins/fleet/common/authz.ts index 2a9205ada0e0fb..0540607a12a0c5 100644 --- a/x-pack/plugins/fleet/common/authz.ts +++ b/x-pack/plugins/fleet/common/authz.ts @@ -10,6 +10,7 @@ export interface FleetAuthz { all: boolean; setup: boolean; readEnrollmentTokens: boolean; + readAgentPolicies: boolean; }; integrations: { @@ -18,6 +19,7 @@ export interface FleetAuthz { installPackages: boolean; upgradePackages: boolean; removePackages: boolean; + uploadPackages: boolean; readPackageSettings: boolean; writePackageSettings: boolean; @@ -37,15 +39,22 @@ interface CalculateParams { all: boolean; read: boolean; }; + + isSuperuser: boolean; } -export const calculateAuthz = ({ fleet, integrations }: CalculateParams): FleetAuthz => ({ +export const calculateAuthz = ({ + fleet, + integrations, + isSuperuser, +}: CalculateParams): FleetAuthz => ({ fleet: { all: fleet.all && (integrations.all || integrations.read), // These are currently used by Fleet Server setup setup: fleet.all || fleet.setup, readEnrollmentTokens: fleet.all || fleet.setup, + readAgentPolicies: fleet.all || fleet.setup, }, integrations: { @@ -54,6 +63,7 @@ export const calculateAuthz = ({ fleet, integrations }: CalculateParams): FleetA installPackages: fleet.all && integrations.all, upgradePackages: fleet.all && integrations.all, removePackages: fleet.all && integrations.all, + uploadPackages: isSuperuser, readPackageSettings: fleet.all && integrations.all, writePackageSettings: fleet.all && integrations.all, diff --git a/x-pack/plugins/fleet/common/mocks.ts b/x-pack/plugins/fleet/common/mocks.ts index 5b71e9b15860ee..b60e1ac13748da 100644 --- a/x-pack/plugins/fleet/common/mocks.ts +++ b/x-pack/plugins/fleet/common/mocks.ts @@ -67,12 +67,14 @@ export const createFleetAuthzMock = (): FleetAuthz => { all: true, setup: true, readEnrollmentTokens: true, + readAgentPolicies: true, }, integrations: { readPackageInfo: true, readInstalledPackages: true, installPackages: true, upgradePackages: true, + uploadPackages: true, removePackages: true, readPackageSettings: true, writePackageSettings: true, diff --git a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx index b0a6a15add9915..f810b0369c1618 100644 --- a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx +++ b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx @@ -35,12 +35,14 @@ const fleetAuthzMock: FleetAuthz = { all: true, setup: true, readEnrollmentTokens: true, + readAgentPolicies: true, }, integrations: { readPackageInfo: true, readInstalledPackages: true, installPackages: true, upgradePackages: true, + uploadPackages: true, removePackages: true, readPackageSettings: true, writePackageSettings: true, diff --git a/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts b/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts index 2373f8dd309ba8..68e3df17bbcee0 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts @@ -19,12 +19,14 @@ export const createStartMock = (extensionsStorage: UIExtensionsStorage = {}): Mo all: true, setup: true, readEnrollmentTokens: true, + readAgentPolicies: true, }, integrations: { readPackageInfo: true, readInstalledPackages: true, installPackages: true, upgradePackages: true, + uploadPackages: true, removePackages: true, readPackageSettings: true, writePackageSettings: true, diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 2330cd3690c775..a15be583d7a1e6 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -238,7 +238,7 @@ export class FleetPlugin implements Plugin core.http.get(appRoutesService.getCheckPermissionsPath()) @@ -260,11 +260,12 @@ export class FleetPlugin implements Plugin { - if (permissionsResponse.success) { + if (permissionsResponse?.success) { // If superuser, give access to everything return calculateAuthz({ fleet: { all: true, setup: true }, integrations: { all: true, read: true }, + isSuperuser: true, }); } else { // All other users only get access to read integrations if they have the read privilege @@ -272,6 +273,7 @@ export class FleetPlugin implements Plugin(); - // Register usage collection registerFleetUsageCollector(core, config, deps.usageCollection); - // Always register app routes for permissions checking - registerAppRoutes(router); + const router: FleetRouter = core.http.createRouter(); // Allow read-only users access to endpoints necessary for Integrations UI // Only some endpoints require superuser so we pass a raw IRouter here // For all the routes we enforce the user to have role superuser - const superuserRouter = RouterWrappers.require.superuser(router); - const fleetSetupRouter = RouterWrappers.require.fleetSetupPrivilege(router); + const { router: fleetAuthzRouter, onPostAuthHandler: fleetAuthzOnPostAuthHandler } = + makeRouterWithFleetAuthz(router); - // Some EPM routes use regular rbac to support integrations app - registerEPMRoutes({ rbac: router, superuser: superuserRouter }); + core.http.registerOnPostAuth(fleetAuthzOnPostAuthHandler); + + // Always register app routes for permissions checking + registerAppRoutes(fleetAuthzRouter); + + // The upload package route is only authorized for the superuser + registerEPMRoutes(fleetAuthzRouter); // Register rest of routes only if security is enabled if (deps.security) { - registerSetupRoutes(fleetSetupRouter, config); - registerAgentPolicyRoutes({ - fleetSetup: fleetSetupRouter, - superuser: superuserRouter, - }); - registerPackagePolicyRoutes(superuserRouter); - registerOutputRoutes(superuserRouter); - registerSettingsRoutes(superuserRouter); - registerDataStreamRoutes(superuserRouter); - registerPreconfigurationRoutes(superuserRouter); + registerSetupRoutes(fleetAuthzRouter, config); + registerAgentPolicyRoutes(fleetAuthzRouter); + registerPackagePolicyRoutes(fleetAuthzRouter); + registerOutputRoutes(fleetAuthzRouter); + registerSettingsRoutes(fleetAuthzRouter); + registerDataStreamRoutes(fleetAuthzRouter); + registerPreconfigurationRoutes(fleetAuthzRouter); // Conditional config routes if (config.agents.enabled) { - registerAgentAPIRoutes(superuserRouter, config); - registerEnrollmentApiKeyRoutes({ - fleetSetup: fleetSetupRouter, - superuser: superuserRouter, - }); + registerAgentAPIRoutes(fleetAuthzRouter, config); + registerEnrollmentApiKeyRoutes(fleetAuthzRouter); } } diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 7297252ff3230b..83f0c17c82c37c 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -5,9 +5,7 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - -import { PLUGIN_ID, AGENT_API_ROUTES } from '../../constants'; +import { AGENT_API_ROUTES } from '../../constants'; import { GetAgentsRequestSchema, GetOneAgentRequestSchema, @@ -24,6 +22,7 @@ import { } from '../../types'; import * as AgentService from '../../services/agents'; import type { FleetConfigType } from '../..'; +import type { FleetAuthzRouter } from '../security'; import { getAgentsHandler, @@ -38,13 +37,15 @@ import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; -export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { +export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => { // Get one router.get( { path: AGENT_API_ROUTES.INFO_PATTERN, validate: GetOneAgentRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getAgentHandler ); @@ -53,7 +54,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.UPDATE_PATTERN, validate: UpdateAgentRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, updateAgentHandler ); @@ -62,7 +65,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.DELETE_PATTERN, validate: DeleteAgentRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, deleteAgentHandler ); @@ -71,7 +76,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.LIST_PATTERN, validate: GetAgentsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getAgentsHandler ); @@ -81,7 +88,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.ACTIONS_PATTERN, validate: PostNewAgentActionRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postNewAgentActionHandlerBuilder({ getAgent: AgentService.getAgentById, @@ -93,7 +102,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.UNENROLL_PATTERN, validate: PostAgentUnenrollRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postAgentUnenrollHandler ); @@ -102,7 +113,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.REASSIGN_PATTERN, validate: PutAgentReassignRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, putAgentsReassignHandler ); @@ -112,7 +125,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.STATUS_PATTERN, validate: GetAgentStatusRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getAgentStatusForAgentPolicyHandler ); @@ -120,7 +135,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.STATUS_PATTERN_DEPRECATED, validate: GetAgentStatusRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getAgentStatusForAgentPolicyHandler ); @@ -130,7 +147,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.UPGRADE_PATTERN, validate: PostAgentUpgradeRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postAgentUpgradeHandler ); @@ -139,7 +158,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, validate: PostBulkAgentUpgradeRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postBulkAgentsUpgradeHandler ); @@ -148,7 +169,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.BULK_REASSIGN_PATTERN, validate: PostBulkAgentReassignRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postBulkAgentsReassignHandler ); @@ -158,7 +181,9 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { { path: AGENT_API_ROUTES.BULK_UNENROLL_PATTERN, validate: PostBulkAgentUnenrollRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postBulkAgentsUnenrollHandler ); diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts index 4c20358e15085d..3819b009f27634 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PLUGIN_ID, AGENT_POLICY_API_ROUTES } from '../../constants'; +import { AGENT_POLICY_API_ROUTES } from '../../constants'; import { GetAgentPoliciesRequestSchema, GetOneAgentPolicyRequestSchema, @@ -15,7 +15,7 @@ import { DeleteAgentPolicyRequestSchema, GetFullAgentPolicyRequestSchema, } from '../../types'; -import type { FleetRouter } from '../../types/request_context'; +import type { FleetAuthzRouter } from '../security'; import { getAgentPoliciesHandler, @@ -28,85 +28,99 @@ import { downloadFullAgentPolicy, } from './handlers'; -export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => { +export const registerRoutes = (router: FleetAuthzRouter) => { // List - Fleet Server needs access to run setup - routers.fleetSetup.get( + router.get( { path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, validate: GetAgentPoliciesRequestSchema, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - // options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { readAgentPolicies: true }, + }, }, getAgentPoliciesHandler ); // Get one - routers.superuser.get( + router.get( { path: AGENT_POLICY_API_ROUTES.INFO_PATTERN, validate: GetOneAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getOneAgentPolicyHandler ); // Create - routers.superuser.post( + router.post( { path: AGENT_POLICY_API_ROUTES.CREATE_PATTERN, validate: CreateAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, createAgentPolicyHandler ); // Update - routers.superuser.put( + router.put( { path: AGENT_POLICY_API_ROUTES.UPDATE_PATTERN, validate: UpdateAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, updateAgentPolicyHandler ); // Copy - routers.superuser.post( + router.post( { path: AGENT_POLICY_API_ROUTES.COPY_PATTERN, validate: CopyAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, copyAgentPolicyHandler ); // Delete - routers.superuser.post( + router.post( { path: AGENT_POLICY_API_ROUTES.DELETE_PATTERN, validate: DeleteAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, deleteAgentPoliciesHandler ); // Get one full agent policy - routers.superuser.get( + router.get( { path: AGENT_POLICY_API_ROUTES.FULL_INFO_PATTERN, validate: GetFullAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getFullAgentPolicy ); // Download one full agent policy - routers.superuser.get( + router.get( { path: AGENT_POLICY_API_ROUTES.FULL_INFO_DOWNLOAD_PATTERN, validate: GetFullAgentPolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, downloadFullAgentPolicy ); diff --git a/x-pack/plugins/fleet/server/routes/app/index.ts b/x-pack/plugins/fleet/server/routes/app/index.ts index cb2a01deecb4f1..563c61d026b7e1 100644 --- a/x-pack/plugins/fleet/server/routes/app/index.ts +++ b/x-pack/plugins/fleet/server/routes/app/index.ts @@ -5,12 +5,13 @@ * 2.0. */ -import type { IRouter, RequestHandler } from 'src/core/server'; +import type { RequestHandler } from 'src/core/server'; -import { PLUGIN_ID, APP_API_ROUTES } from '../../constants'; +import { APP_API_ROUTES } from '../../constants'; import { appContextService } from '../../services'; import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common'; import { defaultIngestErrorHandler, GenerateServiceTokenError } from '../../errors'; +import type { FleetAuthzRouter } from '../security'; export const getCheckPermissionsHandler: RequestHandler = async (context, request, response) => { const missingSecurityBody: CheckPermissionsResponse = { @@ -72,12 +73,13 @@ export const generateServiceTokenHandler: RequestHandler = async (context, reque } }; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { router.get( { path: APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, validate: {}, options: { tags: [] }, + // no permission check for that route }, getCheckPermissionsHandler ); @@ -86,7 +88,9 @@ export const registerRoutes = (router: IRouter) => { { path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN, validate: {}, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, generateServiceTokenHandler ); @@ -95,7 +99,9 @@ export const registerRoutes = (router: IRouter) => { { path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN_DEPRECATED, validate: {}, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, generateServiceTokenHandler ); diff --git a/x-pack/plugins/fleet/server/routes/data_streams/index.ts b/x-pack/plugins/fleet/server/routes/data_streams/index.ts index 2c1fa7b2f3b15f..ddefc537ba207b 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/index.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/index.ts @@ -5,19 +5,20 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - -import { PLUGIN_ID, DATA_STREAM_API_ROUTES } from '../../constants'; +import { DATA_STREAM_API_ROUTES } from '../../constants'; +import type { FleetAuthzRouter } from '../security'; import { getListHandler } from './handlers'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { // List of data streams router.get( { path: DATA_STREAM_API_ROUTES.LIST_PATTERN, validate: false, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getListHandler ); diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts index 39665f14484ba2..d904ab60e19ec8 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { PLUGIN_ID, ENROLLMENT_API_KEY_ROUTES } from '../../constants'; +import { ENROLLMENT_API_KEY_ROUTES } from '../../constants'; import { GetEnrollmentAPIKeysRequestSchema, GetOneEnrollmentAPIKeyRequestSchema, DeleteEnrollmentAPIKeyRequestSchema, PostEnrollmentAPIKeyRequestSchema, } from '../../types'; -import type { FleetRouter } from '../../types/request_context'; +import type { FleetAuthzRouter } from '../security'; import { getEnrollmentApiKeysHandler, @@ -21,83 +21,91 @@ import { postEnrollmentApiKeyHandler, } from './handler'; -export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => { - routers.fleetSetup.get( +export const registerRoutes = (router: FleetAuthzRouter) => { + router.get( { path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN, validate: GetOneEnrollmentAPIKeyRequestSchema, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - // options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { readEnrollmentTokens: true }, + }, }, getOneEnrollmentApiKeyHandler ); - routers.superuser.delete( + router.delete( { path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN, validate: DeleteEnrollmentAPIKeyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, deleteEnrollmentApiKeyHandler ); - routers.fleetSetup.get( + router.get( { path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN, validate: GetEnrollmentAPIKeysRequestSchema, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - // options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { readEnrollmentTokens: true }, + }, }, getEnrollmentApiKeysHandler ); - routers.superuser.post( + router.post( { path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN, validate: PostEnrollmentAPIKeyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postEnrollmentApiKeyHandler ); - routers.fleetSetup.get( + router.get( { path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN_DEPRECATED, validate: GetOneEnrollmentAPIKeyRequestSchema, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - // options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { readEnrollmentTokens: true }, + }, }, getOneEnrollmentApiKeyHandler ); - routers.superuser.delete( + router.delete( { path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN_DEPRECATED, validate: DeleteEnrollmentAPIKeyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, deleteEnrollmentApiKeyHandler ); - routers.fleetSetup.get( + router.get( { path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN_DEPRECATED, validate: GetEnrollmentAPIKeysRequestSchema, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - // options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { readEnrollmentTokens: true }, + }, }, getEnrollmentApiKeysHandler ); - routers.superuser.post( + router.post( { path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN_DEPRECATED, validate: PostEnrollmentAPIKeyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postEnrollmentApiKeyHandler ); diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index b07bb2b1ab77bb..95aadf1b8555ad 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -14,7 +14,7 @@ import type { UpdatePackageResponse, } from '../../../common'; -import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants'; +import { EPM_API_ROUTES } from '../../constants'; import { splitPkgKey } from '../../services/epm/registry'; import { GetCategoriesRequestSchema, @@ -32,7 +32,7 @@ import { UpdatePackageRequestSchema, UpdatePackageRequestSchemaDeprecated, } from '../../types'; -import type { FleetRouter } from '../../types/request_context'; +import type { FleetAuthzRouter } from '../security'; import { getCategoriesHandler, @@ -50,119 +50,144 @@ import { const MAX_FILE_SIZE_BYTES = 104857600; // 100MB -export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRouter }) => { - routers.rbac.get( +export const registerRoutes = (router: FleetAuthzRouter) => { + router.get( { path: EPM_API_ROUTES.CATEGORIES_PATTERN, validate: GetCategoriesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getCategoriesHandler ); - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.LIST_PATTERN, validate: GetPackagesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getListHandler ); - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.LIMITED_LIST_PATTERN, validate: false, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getLimitedListHandler ); - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.STATS_PATTERN, validate: GetStatsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getStatsHandler ); - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.FILEPATH_PATTERN, validate: GetFileRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getFileHandler ); - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.INFO_PATTERN, validate: GetInfoRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, getInfoHandler ); - routers.superuser.put( + router.put( { path: EPM_API_ROUTES.INFO_PATTERN, validate: UpdatePackageRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { upgradePackages: true, writePackageSettings: true }, + }, }, updatePackageHandler ); - routers.superuser.post( + router.post( { path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN, validate: InstallPackageFromRegistryRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { installPackages: true }, + }, }, installPackageFromRegistryHandler ); - routers.superuser.post( + router.post( { path: EPM_API_ROUTES.BULK_INSTALL_PATTERN, validate: BulkUpgradePackagesFromRegistryRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { installPackages: true, upgradePackages: true }, + }, }, bulkInstallPackagesFromRegistryHandler ); - routers.superuser.post( + // Only allow upload for superuser + router.post( { path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN, validate: InstallPackageByUploadRequestSchema, options: { - tags: [`access:${PLUGIN_ID}-all`], body: { accepts: ['application/gzip', 'application/zip'], parse: false, maxBytes: MAX_FILE_SIZE_BYTES, }, }, + fleetAuthz: { + integrations: { uploadPackages: true }, + }, }, installPackageByUploadHandler ); - routers.superuser.delete( + router.delete( { path: EPM_API_ROUTES.DELETE_PATTERN, validate: DeletePackageRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { removePackages: true }, + }, }, deletePackageHandler ); // deprecated since 8.0 - routers.rbac.get( + router.get( { path: EPM_API_ROUTES.INFO_PATTERN_DEPRECATED, validate: GetInfoRequestSchemaDeprecated, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readPackageInfo: true }, + }, }, async (context, request, response) => { const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; @@ -179,11 +204,13 @@ export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRou } ); - routers.superuser.put( + router.put( { path: EPM_API_ROUTES.INFO_PATTERN_DEPRECATED, validate: UpdatePackageRequestSchemaDeprecated, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { upgradePackages: true, writePackageSettings: true }, + }, }, async (context, request, response) => { const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; @@ -199,11 +226,13 @@ export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRou } ); - routers.superuser.post( + router.post( { path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN_DEPRECATED, validate: InstallPackageFromRegistryRequestSchemaDeprecated, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { installPackages: true }, + }, }, async (context, request, response) => { const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; @@ -219,11 +248,13 @@ export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRou } ); - routers.superuser.delete( + router.delete( { path: EPM_API_ROUTES.DELETE_PATTERN_DEPRECATED, validate: DeletePackageRequestSchemaDeprecated, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { removePackages: true }, + }, }, async (context, request, response) => { const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; diff --git a/x-pack/plugins/fleet/server/routes/output/index.ts b/x-pack/plugins/fleet/server/routes/output/index.ts index 9ef8bab6ea4081..b9dfb1f7f742bf 100644 --- a/x-pack/plugins/fleet/server/routes/output/index.ts +++ b/x-pack/plugins/fleet/server/routes/output/index.ts @@ -5,9 +5,7 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - -import { PLUGIN_ID, OUTPUT_API_ROUTES } from '../../constants'; +import { OUTPUT_API_ROUTES } from '../../constants'; import { DeleteOutputRequestSchema, GetOneOutputRequestSchema, @@ -15,6 +13,7 @@ import { PostOutputRequestSchema, PutOutputRequestSchema, } from '../../types'; +import type { FleetAuthzRouter } from '../security'; import { deleteOutputHandler, @@ -24,12 +23,14 @@ import { putOuputHandler, } from './handler'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { router.get( { path: OUTPUT_API_ROUTES.LIST_PATTERN, validate: GetOutputsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getOutputsHandler ); @@ -37,7 +38,9 @@ export const registerRoutes = (router: IRouter) => { { path: OUTPUT_API_ROUTES.INFO_PATTERN, validate: GetOneOutputRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getOneOuputHandler ); @@ -45,7 +48,9 @@ export const registerRoutes = (router: IRouter) => { { path: OUTPUT_API_ROUTES.UPDATE_PATTERN, validate: PutOutputRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, putOuputHandler ); @@ -54,7 +59,9 @@ export const registerRoutes = (router: IRouter) => { { path: OUTPUT_API_ROUTES.CREATE_PATTERN, validate: PostOutputRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, postOuputHandler ); @@ -63,7 +70,9 @@ export const registerRoutes = (router: IRouter) => { { path: OUTPUT_API_ROUTES.DELETE_PATTERN, validate: DeleteOutputRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, deleteOutputHandler ); diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index 2408f8226f5d6a..6ea64f95c92ace 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -7,7 +7,7 @@ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; import type { KibanaRequest } from 'kibana/server'; -import type { IRouter, RequestHandler, RouteConfig } from 'kibana/server'; +import type { RouteConfig } from 'kibana/server'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; import { appContextService, packagePolicyService } from '../../services'; @@ -21,7 +21,8 @@ import type { CreatePackagePolicyRequestSchema, UpdatePackagePolicyRequestSchema, } from '../../types/rest_spec'; - +import type { FleetAuthzRouter } from '../security'; +import type { FleetRequestHandler, FleetRequestHandlerContext } from '../../types'; import type { PackagePolicy } from '../../types'; import { registerRoutes } from './index'; @@ -93,20 +94,20 @@ jest.mock('../../services/epm/packages', () => { }); describe('When calling package policy', () => { - let routerMock: jest.Mocked; - let routeHandler: RequestHandler; + let routerMock: jest.Mocked; + let routeHandler: FleetRequestHandler; let routeConfig: RouteConfig; - let context: ReturnType; + let context: jest.Mocked; let response: ReturnType; beforeEach(() => { - routerMock = httpServiceMock.createRouter(); + routerMock = httpServiceMock.createRouter() as unknown as jest.Mocked; registerRoutes(routerMock); }); beforeEach(() => { appContextService.start(createAppContextStartContractMock()); - context = xpackMocks.createRequestHandlerContext(); + context = xpackMocks.createRequestHandlerContext() as unknown as FleetRequestHandlerContext; response = httpServerMock.createResponseFactory(); }); diff --git a/x-pack/plugins/fleet/server/routes/package_policy/index.ts b/x-pack/plugins/fleet/server/routes/package_policy/index.ts index 596532a17a8c8f..647b1cb03561f3 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/index.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/index.ts @@ -5,9 +5,7 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - -import { PLUGIN_ID, PACKAGE_POLICY_API_ROUTES } from '../../constants'; +import { PACKAGE_POLICY_API_ROUTES } from '../../constants'; import { GetPackagePoliciesRequestSchema, GetOnePackagePolicyRequestSchema, @@ -17,6 +15,7 @@ import { UpgradePackagePoliciesRequestSchema, DryRunPackagePoliciesRequestSchema, } from '../../types'; +import type { FleetAuthzRouter } from '../security'; import { getPackagePoliciesHandler, @@ -28,13 +27,15 @@ import { dryRunUpgradePackagePolicyHandler, } from './handlers'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { // List router.get( { path: PACKAGE_POLICY_API_ROUTES.LIST_PATTERN, validate: GetPackagePoliciesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readIntegrationPolicies: true }, + }, }, getPackagePoliciesHandler ); @@ -44,7 +45,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.INFO_PATTERN, validate: GetOnePackagePolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + integrations: { readIntegrationPolicies: true }, + }, }, getOnePackagePolicyHandler ); @@ -54,7 +57,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.CREATE_PATTERN, validate: CreatePackagePolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { writeIntegrationPolicies: true }, + }, }, createPackagePolicyHandler ); @@ -64,7 +69,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.UPDATE_PATTERN, validate: UpdatePackagePolicyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { writeIntegrationPolicies: true }, + }, }, updatePackagePolicyHandler ); @@ -74,7 +81,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.DELETE_PATTERN, validate: DeletePackagePoliciesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + fleetAuthz: { + integrations: { writeIntegrationPolicies: true }, + }, }, deletePackagePolicyHandler ); @@ -84,7 +93,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.UPGRADE_PATTERN, validate: UpgradePackagePoliciesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { writeIntegrationPolicies: true }, + }, }, upgradePackagePolicyHandler ); @@ -94,7 +105,9 @@ export const registerRoutes = (router: IRouter) => { { path: PACKAGE_POLICY_API_ROUTES.DRYRUN_PATTERN, validate: DryRunPackagePoliciesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + integrations: { writeIntegrationPolicies: true }, + }, }, dryRunUpgradePackagePolicyHandler ); diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts index c2973c2f9d06bf..d13c2ab5ab8e85 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts @@ -5,15 +5,16 @@ * 2.0. */ -import type { IRouter, RequestHandler } from 'src/core/server'; +import type { RequestHandler } from 'src/core/server'; import type { TypeOf } from '@kbn/config-schema'; import type { PreconfiguredAgentPolicy } from '../../../common'; -import { PLUGIN_ID, PRECONFIGURATION_API_ROUTES } from '../../constants'; +import { PRECONFIGURATION_API_ROUTES } from '../../constants'; import { PutPreconfigurationSchema } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; +import type { FleetAuthzRouter } from '../security'; export const updatePreconfigurationHandler: RequestHandler< undefined, @@ -40,12 +41,14 @@ export const updatePreconfigurationHandler: RequestHandler< } }; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { router.put( { path: PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN, validate: PutPreconfigurationSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, updatePreconfigurationHandler ); diff --git a/x-pack/plugins/fleet/server/routes/security.test.ts b/x-pack/plugins/fleet/server/routes/security.test.ts index 80ea142541530c..204574beb5d978 100644 --- a/x-pack/plugins/fleet/server/routes/security.test.ts +++ b/x-pack/plugins/fleet/server/routes/security.test.ts @@ -7,35 +7,53 @@ import type { IRouter, RequestHandler, RouteConfig } from '../../../../../src/core/server'; import { coreMock } from '../../../../../src/core/server/mocks'; -import type { AuthenticatedUser } from '../../../security/server'; +import type { AuthenticatedUser, CheckPrivilegesPayload } from '../../../security/server'; +import type { CheckPrivilegesResponse } from '../../../security/server/authorization/types'; import type { CheckPrivilegesDynamically } from '../../../security/server/authorization/check_privileges_dynamically'; import { createAppContextStartContractMock } from '../mocks'; import { appContextService } from '../services'; +import type { FleetRequestHandlerContext } from '../types'; -import type { RouterWrapper } from './security'; -import { RouterWrappers } from './security'; +import { makeRouterWithFleetAuthz } from './security'; -describe('RouterWrappers', () => { +function getCheckPrivilegesMockedImplementation(kibanaRoles: string[]) { + return (checkPrivileges: CheckPrivilegesPayload) => { + const kibana = ((checkPrivileges?.kibana ?? []) as string[]).map((role: string) => { + return { authorized: kibanaRoles.includes(role) }; + }); + + return Promise.resolve({ + hasAllRequested: kibana.every((r: any) => r.authorized), + privileges: { + kibana, + }, + } as unknown as CheckPrivilegesResponse); + }; +} + +describe('FleetAuthzRouter', () => { const runTest = async ({ - wrapper, security: { roles = [], pluginEnabled = true, licenseEnabled = true, checkPrivilegesDynamically, } = {}, + routeConfig = { + path: '/api/fleet/test', + }, }: { - wrapper: RouterWrapper; security?: { roles?: string[]; pluginEnabled?: boolean; licenseEnabled?: boolean; checkPrivilegesDynamically?: CheckPrivilegesDynamically; }; + routeConfig?: any; }) => { const fakeRouter = { get: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked>; const fakeHandler: RequestHandler = jest.fn((ctx, req, res) => res.ok()); const mockContext = createAppContextStartContractMock(); @@ -65,109 +83,159 @@ describe('RouterWrappers', () => { appContextService.start(mockContext); - const wrappedRouter = wrapper(fakeRouter); - wrappedRouter.get({} as RouteConfig, fakeHandler); + const { router: wrappedRouter, onPostAuthHandler } = makeRouterWithFleetAuthz(fakeRouter); + wrappedRouter.get({ ...routeConfig } as RouteConfig, fakeHandler); const wrappedHandler = fakeRouter.get.mock.calls[0][1]; + const wrappedRouteConfig = fakeRouter.get.mock.calls[0][0]; const resFactory = { forbidden: jest.fn(() => 'forbidden'), ok: jest.fn(() => 'ok') }; + const fakeToolkit = { next: jest.fn(() => 'next') }; + + const fakeReq = { + route: { + path: routeConfig.path, + method: 'get', + options: wrappedRouteConfig.options, + }, + } as any; + const onPostRes = await onPostAuthHandler(fakeReq, resFactory as any, fakeToolkit as any); + + if ((onPostRes as unknown) !== 'next') { + return onPostRes; + } + const res = await wrappedHandler( - { core: coreMock.createRequestHandlerContext() }, - {} as any, + { + core: coreMock.createRequestHandlerContext(), + } as unknown as FleetRequestHandlerContext, + fakeReq, resFactory as any ); return res as unknown as 'forbidden' | 'ok'; }; - describe('require.superuser', () => { - it('allow users with the superuser role', async () => { + const mockCheckPrivileges: jest.Mock< + ReturnType, + Parameters + > = jest.fn().mockResolvedValue({ hasAllRequested: true }); + + it('does not allow security plugin to be disabled', async () => { + expect( + await runTest({ + security: { pluginEnabled: false }, + routeConfig: { + fleetAuthz: { fleet: { all: true } }, + }, + }) + ).toEqual('forbidden'); + }); + + it('does not allow security license to be disabled', async () => { + expect( + await runTest({ + security: { licenseEnabled: false }, + routeConfig: { + fleetAuthz: { fleet: { all: true } }, + }, + }) + ).toEqual('forbidden'); + }); + + describe('with fleet setup privileges', () => { + const routeConfig = { + path: '/api/fleet/test', + fleetAuthz: { fleet: { setup: true } }, + }; + it('allow users with superuser role', async () => { expect( await runTest({ - wrapper: RouterWrappers.require.superuser, security: { roles: ['superuser'] }, + routeConfig, }) ).toEqual('ok'); }); - it('does not allow users without the superuser role', async () => { + it('allow users with fleet-setup role', async () => { + mockCheckPrivileges.mockImplementation( + getCheckPrivilegesMockedImplementation(['api:fleet-setup']) + ); expect( await runTest({ - wrapper: RouterWrappers.require.superuser, - security: { roles: ['foo'] }, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + routeConfig, }) - ).toEqual('forbidden'); + ).toEqual('ok'); }); - it('does not allow security plugin to be disabled', async () => { + it('do not allow users without fleet-setup role', async () => { + mockCheckPrivileges.mockImplementation(getCheckPrivilegesMockedImplementation([])); expect( await runTest({ - wrapper: RouterWrappers.require.superuser, - security: { pluginEnabled: false }, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + routeConfig, }) ).toEqual('forbidden'); }); + }); - it('does not allow security license to be disabled', async () => { + describe('with superuser privileges', () => { + const routeConfig = { + path: '/api/fleet/test', + fleetAuthz: { integrations: { uploadPackages: true } }, + }; + it('allow users with superuser role', async () => { expect( await runTest({ - wrapper: RouterWrappers.require.superuser, - security: { licenseEnabled: false }, + security: { roles: ['superuser'] }, + routeConfig, }) - ).toEqual('forbidden'); - }); - }); - - describe('require.fleetSetupPrivilege', () => { - const mockCheckPrivileges: jest.Mock< - ReturnType, - Parameters - > = jest.fn().mockResolvedValue({ hasAllRequested: true }); - - it('executes custom authz check', async () => { - await runTest({ - wrapper: RouterWrappers.require.fleetSetupPrivilege, - security: { checkPrivilegesDynamically: mockCheckPrivileges }, - }); - expect(mockCheckPrivileges).toHaveBeenCalledWith( - { kibana: ['api:fleet-setup'] }, - { - requireLoginAction: false, - } - ); + ).toEqual('ok'); }); - it('allow users with required privileges', async () => { + it('do not allow users without superuser role', async () => { + mockCheckPrivileges.mockImplementation(getCheckPrivilegesMockedImplementation([])); expect( await runTest({ - wrapper: RouterWrappers.require.fleetSetupPrivilege, security: { checkPrivilegesDynamically: mockCheckPrivileges }, + routeConfig, }) - ).toEqual('ok'); + ).toEqual('forbidden'); }); + }); - it('does not allow users without required privileges', async () => { - mockCheckPrivileges.mockResolvedValueOnce({ hasAllRequested: false } as any); + describe('with fleet role', () => { + const routeConfig = { + path: '/api/fleet/test', + fleetAuthz: { integrations: { readPackageInfo: true } }, + }; + + it('allow users with superuser role', async () => { expect( await runTest({ - wrapper: RouterWrappers.require.fleetSetupPrivilege, - security: { checkPrivilegesDynamically: mockCheckPrivileges }, + security: { roles: ['superuser'] }, + routeConfig, }) - ).toEqual('forbidden'); + ).toEqual('ok'); }); - it('does not allow security plugin to be disabled', async () => { + it('allow users with all required fleet authz role', async () => { + mockCheckPrivileges.mockImplementation( + getCheckPrivilegesMockedImplementation(['api:integrations-read']) + ); expect( await runTest({ - wrapper: RouterWrappers.require.fleetSetupPrivilege, - security: { pluginEnabled: false }, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + routeConfig, }) - ).toEqual('forbidden'); + ).toEqual('ok'); }); - it('does not allow security license to be disabled', async () => { + it('does not allow users without the required fleet role', async () => { + mockCheckPrivileges.mockImplementation(getCheckPrivilegesMockedImplementation([])); expect( await runTest({ - wrapper: RouterWrappers.require.fleetSetupPrivilege, - security: { licenseEnabled: false }, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + routeConfig, }) ).toEqual('forbidden'); }); diff --git a/x-pack/plugins/fleet/server/routes/security.ts b/x-pack/plugins/fleet/server/routes/security.ts index e0a2a557391df8..51b17311809726 100644 --- a/x-pack/plugins/fleet/server/routes/security.ts +++ b/x-pack/plugins/fleet/server/routes/security.ts @@ -7,18 +7,19 @@ import type { IRouter, + RouteConfig, + RouteMethod, KibanaRequest, RequestHandler, RequestHandlerContext, + OnPostAuthHandler, } from 'src/core/server'; import type { FleetAuthz } from '../../common'; import { calculateAuthz } from '../../common'; import { appContextService } from '../services'; - -const SUPERUSER_AUTHZ_MESSAGE = - 'Access to Fleet API requires the superuser role and for stack security features to be enabled.'; +import type { FleetRequestHandlerContext } from '../types'; function checkSecurityEnabled() { return appContextService.hasSecurity() && appContextService.getSecurityLicense().isEnabled(); @@ -43,38 +44,6 @@ export function checkSuperuser(req: KibanaRequest) { return true; } -function enforceSuperuser( - handler: RequestHandler -): RequestHandler { - return function enforceSuperHandler(context, req, res) { - const isSuperuser = checkSuperuser(req); - if (!isSuperuser) { - return res.forbidden({ - body: { - message: SUPERUSER_AUTHZ_MESSAGE, - }, - }); - } - - return handler(context, req, res); - }; -} - -function makeRouterEnforcingSuperuser( - router: IRouter -): IRouter { - return { - get: (options, handler) => router.get(options, enforceSuperuser(handler)), - delete: (options, handler) => router.delete(options, enforceSuperuser(handler)), - post: (options, handler) => router.post(options, enforceSuperuser(handler)), - put: (options, handler) => router.put(options, enforceSuperuser(handler)), - patch: (options, handler) => router.patch(options, enforceSuperuser(handler)), - handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), - getRoutes: () => router.getRoutes(), - routerPath: router.routerPath, - }; -} - async function checkFleetSetupPrivilege(req: KibanaRequest) { if (!checkSecurityEnabled()) { return false; @@ -88,41 +57,12 @@ async function checkFleetSetupPrivilege(req: KibanaRequest) { { kibana: [security.authz.actions.api.get('fleet-setup')] }, { requireLoginAction: false } // exclude login access requirement ); - return !!hasAllRequested; } return true; } -function enforceFleetSetupPrivilege( - handler: RequestHandler -): RequestHandler { - return async (context, req, res) => { - const hasFleetSetupPrivilege = await checkFleetSetupPrivilege(req); - if (!hasFleetSetupPrivilege) { - return res.forbidden({ body: { message: SUPERUSER_AUTHZ_MESSAGE } }); - } - - return handler(context, req, res); - }; -} - -function makeRouterEnforcingFleetSetupPrivilege( - router: IRouter -): IRouter { - return { - get: (options, handler) => router.get(options, enforceFleetSetupPrivilege(handler)), - delete: (options, handler) => router.delete(options, enforceFleetSetupPrivilege(handler)), - post: (options, handler) => router.post(options, enforceFleetSetupPrivilege(handler)), - put: (options, handler) => router.put(options, enforceFleetSetupPrivilege(handler)), - patch: (options, handler) => router.patch(options, enforceFleetSetupPrivilege(handler)), - handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), - getRoutes: () => router.getRoutes(), - routerPath: router.routerPath, - }; -} - export async function getAuthzFromRequest(req: KibanaRequest): Promise { const security = appContextService.getSecurity(); @@ -133,12 +73,14 @@ export async function getAuthzFromRequest(req: KibanaRequest): Promise(route: IRouter) => IRouter; +interface Authz { + [k: string]: Authz | boolean; +} -interface RouterWrappersSetup { - require: { - superuser: RouterWrapper; - fleetSetupPrivilege: RouterWrapper; - }; +function containsRequirement(authz: Authz, requirements: DeepPartialTruthy) { + if (!authz) { + return false; + } + for (const key of Object.keys(requirements)) { + if (typeof requirements[key] !== 'undefined' && typeof requirements[key] === 'boolean') { + if (!authz[key]) { + return false; + } + } else if ( + !containsRequirement(authz[key] as Authz, requirements[key] as DeepPartialTruthy) + ) { + return false; + } + } + return true; } -export const RouterWrappers: RouterWrappersSetup = { - require: { - superuser: (router) => { - return makeRouterEnforcingSuperuser(router); - }, - fleetSetupPrivilege: (router) => { - return makeRouterEnforcingFleetSetupPrivilege(router); - }, - }, +function hasRequiredFleetAuthzPrivilege( + authz: FleetAuthz, + { fleetAuthz }: { fleetAuthz?: FleetAuthzRequirements } +): boolean { + if (!checkSecurityEnabled()) { + return false; + } + if (fleetAuthz && !containsRequirement(authz as unknown as Authz, fleetAuthz)) { + return false; + } + + return true; +} + +type DeepPartialTruthy = { + [P in keyof T]?: T[P] extends boolean ? true : DeepPartialTruthy; }; + +type FleetAuthzRequirements = DeepPartialTruthy; + +type FleetAuthzRouteRegistrar< + Method extends RouteMethod, + Context extends RequestHandlerContext = RequestHandlerContext +> = ( + route: FleetRouteConfig, + handler: RequestHandler +) => void; + +interface FleetAuthzRouteConfig { + fleetAuthz?: FleetAuthzRequirements; +} + +type FleetRouteConfig = RouteConfig & + FleetAuthzRouteConfig; + +// Fleet router that allow to add required access when registering route +export interface FleetAuthzRouter< + TContext extends FleetRequestHandlerContext = FleetRequestHandlerContext +> extends IRouter { + get: FleetAuthzRouteRegistrar<'get', TContext>; + delete: FleetAuthzRouteRegistrar<'delete', TContext>; + post: FleetAuthzRouteRegistrar<'post', TContext>; + put: FleetAuthzRouteRegistrar<'put', TContext>; + patch: FleetAuthzRouteRegistrar<'patch', TContext>; +} + +function shouldHandlePostAuthRequest(req: KibanaRequest) { + if (req?.route?.options?.tags) { + return req.route.options.tags.some((tag) => tag.match(/^fleet:authz/)); + } + return false; +} +function deserializeAuthzConfig(tags: readonly string[]): FleetAuthzRouteConfig { + let fleetAuthz: FleetAuthzRequirements | undefined; + for (const tag of tags) { + if (!tag.match(/^fleet:authz/)) { + continue; + } + + if (!fleetAuthz) { + fleetAuthz = {}; + } + + tag + .replace(/^fleet:authz:/, '') + .split(':') + .reduce((acc: any, key, idx, keys) => { + if (idx === keys.length + 1) { + acc[key] = true; + + return acc; + } + + if (!acc[key]) { + acc[key] = {}; + } + + return acc[key]; + }, fleetAuthz); + } + + return { fleetAuthz }; +} +function serializeAuthzConfig(config: FleetAuthzRouteConfig): string[] { + const tags: string[] = []; + + if (config.fleetAuthz) { + function fleetAuthzToTags(requirements: DeepPartialTruthy, prefix: string = '') { + for (const key of Object.keys(requirements)) { + if (typeof requirements[key] === 'boolean') { + tags.push(`fleet:authz:${prefix}${key}`); + } else if (typeof requirements[key] !== 'undefined') { + fleetAuthzToTags(requirements[key] as DeepPartialTruthy, `${key}:`); + } + } + } + + fleetAuthzToTags(config.fleetAuthz); + } + + return tags; +} + +export function makeRouterWithFleetAuthz( + router: IRouter +): { router: FleetAuthzRouter; onPostAuthHandler: OnPostAuthHandler } { + function buildFleetAuthzRouteConfig({ + fleetAuthz, + ...routeConfig + }: FleetRouteConfig) { + return { + ...routeConfig, + options: { + ...routeConfig.options, + tags: [ + ...(routeConfig?.options?.tags ?? []), + ...serializeAuthzConfig({ + fleetAuthz, + }), + ], + }, + }; + } + + const fleetAuthzOnPostAuthHandler: OnPostAuthHandler = async (req, res, toolkit) => { + if (!shouldHandlePostAuthRequest(req)) { + return toolkit.next(); + } + + if (!checkSecurityEnabled()) { + return res.forbidden(); + } + + const fleetAuthzConfig = deserializeAuthzConfig(req.route.options.tags); + + if (!fleetAuthzConfig) { + return toolkit.next(); + } + const authz = await getAuthzFromRequest(req); + if (!hasRequiredFleetAuthzPrivilege(authz, fleetAuthzConfig)) { + return res.forbidden(); + } + + return toolkit.next(); + }; + + const fleetAuthzRouter: FleetAuthzRouter = { + get: (routeConfig, handler) => router.get(buildFleetAuthzRouteConfig(routeConfig), handler), + delete: (routeConfig, handler) => + router.delete(buildFleetAuthzRouteConfig(routeConfig), handler), + post: (routeConfig, handler) => router.post(buildFleetAuthzRouteConfig(routeConfig), handler), + put: (routeConfig, handler) => router.put(buildFleetAuthzRouteConfig(routeConfig), handler), + patch: (routeConfig, handler) => router.patch(buildFleetAuthzRouteConfig(routeConfig), handler), + handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), + getRoutes: () => router.getRoutes(), + routerPath: router.routerPath, + }; + + return { router: fleetAuthzRouter, onPostAuthHandler: fleetAuthzOnPostAuthHandler }; +} diff --git a/x-pack/plugins/fleet/server/routes/settings/index.ts b/x-pack/plugins/fleet/server/routes/settings/index.ts index adabfb93cd4102..6b86ce17827db5 100644 --- a/x-pack/plugins/fleet/server/routes/settings/index.ts +++ b/x-pack/plugins/fleet/server/routes/settings/index.ts @@ -5,13 +5,14 @@ * 2.0. */ -import type { IRouter, RequestHandler } from 'src/core/server'; +import type { RequestHandler } from 'src/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import { PLUGIN_ID, SETTINGS_API_ROUTES } from '../../constants'; +import { SETTINGS_API_ROUTES } from '../../constants'; import { PutSettingsRequestSchema, GetSettingsRequestSchema } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; import { settingsService, agentPolicyService, appContextService } from '../../services'; +import type { FleetAuthzRouter } from '../security'; export const getSettingsHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; @@ -62,12 +63,14 @@ export const putSettingsHandler: RequestHandler< } }; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: FleetAuthzRouter) => { router.get( { path: SETTINGS_API_ROUTES.INFO_PATTERN, validate: GetSettingsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, getSettingsHandler ); @@ -75,7 +78,9 @@ export const registerRoutes = (router: IRouter) => { { path: SETTINGS_API_ROUTES.UPDATE_PATTERN, validate: PutSettingsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-all`] }, + fleetAuthz: { + fleet: { all: true }, + }, }, putSettingsHandler ); diff --git a/x-pack/plugins/fleet/server/routes/setup/index.ts b/x-pack/plugins/fleet/server/routes/setup/index.ts index d191f1b78e9ae3..fe90bbf1be3e85 100644 --- a/x-pack/plugins/fleet/server/routes/setup/index.ts +++ b/x-pack/plugins/fleet/server/routes/setup/index.ts @@ -5,48 +5,54 @@ * 2.0. */ -import { PLUGIN_ID, AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; +import { AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import type { FleetConfigType } from '../../../common'; -import type { FleetRouter } from '../../types/request_context'; +import type { FleetAuthzRouter } from '../security'; import { getFleetStatusHandler, fleetSetupHandler } from './handlers'; -export const registerFleetSetupRoute = (router: FleetRouter) => { +export const registerFleetSetupRoute = (router: FleetAuthzRouter) => { router.post( { path: SETUP_API_ROUTE, validate: false, + fleetAuthz: { + fleet: { setup: true }, + }, }, fleetSetupHandler ); }; // That route is used by agent to setup Fleet -export const registerCreateFleetSetupRoute = (router: FleetRouter) => { +export const registerCreateFleetSetupRoute = (router: FleetAuthzRouter) => { router.post( { path: AGENTS_SETUP_API_ROUTES.CREATE_PATTERN, validate: false, + fleetAuthz: { + fleet: { setup: true }, + }, }, fleetSetupHandler ); }; -export const registerGetFleetStatusRoute = (router: FleetRouter) => { +export const registerGetFleetStatusRoute = (router: FleetAuthzRouter) => { router.get( { path: AGENTS_SETUP_API_ROUTES.INFO_PATTERN, validate: false, - // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 - // Required to allow elastic/fleet-server to access this API. - options: { tags: [`access:${PLUGIN_ID}-read`] }, + fleetAuthz: { + fleet: { setup: true }, + }, }, getFleetStatusHandler ); }; -export const registerRoutes = (router: FleetRouter, config: FleetConfigType) => { +export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => { // Ingest manager setup registerFleetSetupRoute(router); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 64091e16d2cef6..c6385897cb6950 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -9,10 +9,11 @@ import { readFileSync } from 'fs'; import path from 'path'; import { safeLoad } from 'js-yaml'; +import { loggerMock } from '@kbn/logging/mocks'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; import { createAppContextStartContractMock } from '../../../../mocks'; import { appContextService } from '../../../../services'; - import type { RegistryDataStream } from '../../../../types'; import { processFields } from '../../fields/field'; import type { Field } from '../../fields/field'; @@ -22,6 +23,7 @@ import { getTemplate, getTemplatePriority, generateTemplateIndexPattern, + updateCurrentWriteIndices, } from './template'; const FLEET_COMPONENT_TEMPLATE = '.fleet_component_template-1'; @@ -835,4 +837,34 @@ describe('EPM template', () => { expect(templateIndexPattern).toEqual(templateIndexPatternDatasetIsPrefixTrue); expect(templatePriority).toEqual(templatePriorityDatasetIsPrefixTrue); }); + + describe('updateCurrentWriteIndices', () => { + it('update non replicated datastream', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getDataStream.mockResolvedValue({ + body: { + data_streams: [ + { name: 'test-non-replicated' }, + { name: 'test-replicated', replicated: true }, + ], + }, + } as any); + const logger = loggerMock.create(); + await updateCurrentWriteIndices(esClient, logger, [ + { + templateName: 'test', + indexTemplate: { + template: { + settings: { index: {} }, + mappings: { properties: {} }, + }, + } as any, + }, + ]); + + const putMappingsCall = esClient.indices.putMapping.mock.calls.map(([{ index }]) => index); + expect(putMappingsCall).toHaveLength(1); + expect(putMappingsCall[0]).toBe('test-non-replicated'); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 4c223c348fe42b..2bbd05b2cdedb1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -33,6 +33,7 @@ export interface IndexTemplateMapping { } export interface CurrentDataStream { dataStreamName: string; + replicated: boolean; indexTemplate: IndexTemplate; } const DEFAULT_SCALING_FACTOR = 1000; @@ -435,8 +436,17 @@ export const updateCurrentWriteIndices = async ( if (!templates.length) return; const allIndices = await queryDataStreamsFromTemplates(esClient, templates); - if (!allIndices.length) return; - return updateAllDataStreams(allIndices, esClient, logger); + const allUpdatablesIndices = allIndices.filter((indice) => { + if (indice.replicated) { + logger.warn( + `Datastream ${indice.dataStreamName} cannot be updated because this is a replicated datastream.` + ); + return false; + } + return true; + }); + if (!allUpdatablesIndices.length) return; + return updateAllDataStreams(allUpdatablesIndices, esClient, logger); }; function isCurrentDataStream(item: CurrentDataStream[] | undefined): item is CurrentDataStream[] { @@ -460,10 +470,12 @@ const getDataStreams = async ( ): Promise => { const { templateName, indexTemplate } = template; const { body } = await esClient.indices.getDataStream({ name: `${templateName}-*` }); + const dataStreams = body.data_streams; if (!dataStreams.length) return; return dataStreams.map((dataStream: any) => ({ dataStreamName: dataStream.name, + replicated: dataStream.replicated, indexTemplate, })); }; @@ -498,7 +510,6 @@ const updateExistingDataStream = async ({ // to skip updating and assume the value in the index mapping is correct delete mappings.properties.stream; delete mappings.properties.data_stream; - // try to update the mappings first try { await retryTransientEsErrors( diff --git a/x-pack/plugins/fleet/server/services/epm/registry/requests.test.ts b/x-pack/plugins/fleet/server/services/epm/registry/requests.test.ts index 61f6cc164eb301..e13ff87bf45e52 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/requests.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/requests.test.ts @@ -6,10 +6,24 @@ */ import { RegistryError, RegistryConnectionError, RegistryResponseError } from '../../../errors'; +import { appContextService } from '../../app_context'; -import { fetchUrl } from './requests'; +import { fetchUrl, getResponse } from './requests'; jest.mock('node-fetch'); +let mockRegistryProxyUrl: string | undefined; +jest.mock('./proxy', () => ({ + getProxyAgent: jest.fn().mockReturnValue('proxy agent'), + getRegistryProxyUrl: () => mockRegistryProxyUrl, +})); + +jest.mock('../../app_context', () => ({ + appContextService: { + getKibanaVersion: jest.fn(), + getLogger: jest.fn().mockReturnValue({ debug: jest.fn() }), + }, +})); + const { Response, FetchError } = jest.requireActual('node-fetch'); // eslint-disable-next-line @typescript-eslint/no-var-requires const fetchMock = require('node-fetch') as jest.Mock; @@ -22,6 +36,35 @@ describe('Registry request', () => { jest.clearAllMocks(); }); + describe('fetch options', () => { + beforeEach(() => { + fetchMock.mockImplementationOnce(() => Promise.resolve(new Response(''))); + (appContextService.getKibanaVersion as jest.Mock).mockReturnValue('8.0.0'); + }); + it('should set User-Agent header including kibana version', async () => { + getResponse(''); + + expect(fetchMock).toHaveBeenCalledWith('', { + headers: { + 'User-Agent': 'Kibana/8.0.0 node-fetch', + }, + }); + }); + + it('should set User-Agent header including kibana version with agent', async () => { + mockRegistryProxyUrl = 'url'; + + getResponse(''); + + expect(fetchMock).toHaveBeenCalledWith('', { + agent: 'proxy agent', + headers: { + 'User-Agent': 'Kibana/8.0.0 node-fetch', + }, + }); + }); + }); + describe('fetchUrl / getResponse errors', () => { it('regular Errors do not retry. Becomes RegistryError', async () => { fetchMock.mockImplementationOnce(() => { diff --git a/x-pack/plugins/fleet/server/services/epm/registry/requests.ts b/x-pack/plugins/fleet/server/services/epm/registry/requests.ts index ed6df5f6459ec3..f5cabadc5c60d1 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/requests.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/requests.ts @@ -88,15 +88,19 @@ function isSystemError(error: FailedAttemptErrors): boolean { } export function getFetchOptions(targetUrl: string): RequestInit | undefined { + const options: RequestInit = { + headers: { + 'User-Agent': `Kibana/${appContextService.getKibanaVersion()} node-fetch`, + }, + }; const proxyUrl = getRegistryProxyUrl(); if (!proxyUrl) { - return undefined; + return options; } const logger = appContextService.getLogger(); logger.debug(`Using ${proxyUrl} as proxy for ${targetUrl}`); - return { - agent: getProxyAgent({ proxyUrl, targetUrl }), - }; + options.agent = getProxyAgent({ proxyUrl, targetUrl }); + return options; } diff --git a/x-pack/plugins/fleet/storybook/context/index.tsx b/x-pack/plugins/fleet/storybook/context/index.tsx index bb40962f823bb7..5fdf7011e97aec 100644 --- a/x-pack/plugins/fleet/storybook/context/index.tsx +++ b/x-pack/plugins/fleet/storybook/context/index.tsx @@ -78,12 +78,14 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ all: true, setup: true, readEnrollmentTokens: true, + readAgentPolicies: true, }, integrations: { readPackageInfo: true, readInstalledPackages: true, installPackages: true, upgradePackages: true, + uploadPackages: true, removePackages: true, readPackageSettings: true, writePackageSettings: true, 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 daf9b486da9a0e..d7442848f95192 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 @@ -205,8 +205,10 @@ async function executeRatioAlert( } if (hasGroupBy(alertParams)) { - const numeratorGroupedResults = await getGroupedResults(numeratorQuery, esClient); - const denominatorGroupedResults = await getGroupedResults(denominatorQuery, esClient); + const [numeratorGroupedResults, denominatorGroupedResults] = await Promise.all([ + getGroupedResults(numeratorQuery, esClient), + getGroupedResults(denominatorQuery, esClient), + ]); processGroupByRatioResults( numeratorGroupedResults, denominatorGroupedResults, @@ -215,8 +217,10 @@ async function executeRatioAlert( updateAlert ); } else { - const numeratorUngroupedResults = await getUngroupedResults(numeratorQuery, esClient); - const denominatorUngroupedResults = await getUngroupedResults(denominatorQuery, esClient); + const [numeratorUngroupedResults, denominatorUngroupedResults] = await Promise.all([ + getUngroupedResults(numeratorQuery, esClient), + getUngroupedResults(denominatorQuery, esClient), + ]); processUngroupedRatioResults( numeratorUngroupedResults, denominatorUngroupedResults, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.test.tsx new file mode 100644 index 00000000000000..1321d765e119fe --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { FormatSelector } from './format_selector'; +import { act } from 'react-dom/test-utils'; +import { GenericIndexPatternColumn } from '../..'; + +const bytesColumn: GenericIndexPatternColumn = { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, +}; + +const getDefaultProps = () => ({ + onChange: jest.fn(), + selectedColumn: bytesColumn, +}); +describe('FormatSelector', () => { + it('updates the format decimals', () => { + const props = getDefaultProps(); + const component = shallow(); + act(() => { + component + .find('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .simulate('change', { + currentTarget: { value: '10' }, + }); + }); + expect(props.onChange).toBeCalledWith({ id: 'bytes', params: { decimals: 10 } }); + }); + it('updates the format decimals to upper range when input exceeds the range', () => { + const props = getDefaultProps(); + const component = shallow(); + act(() => { + component + .find('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .simulate('change', { + currentTarget: { value: '100' }, + }); + }); + expect(props.onChange).toBeCalledWith({ id: 'bytes', params: { decimals: 15 } }); + }); + it('updates the format decimals to lower range when input is smaller than range', () => { + const props = getDefaultProps(); + const component = shallow(); + act(() => { + component + .find('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .simulate('change', { + currentTarget: { value: '-2' }, + }); + }); + expect(props.onChange).toBeCalledWith({ id: 'bytes', params: { decimals: 0 } }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx index 7ee25a790db622..dd3185b3c79906 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx @@ -35,16 +35,23 @@ const defaultOption = { }), }; +const singleSelectionOption = { asPlainText: true }; + +const label = i18n.translate('xpack.lens.indexPattern.columnFormatLabel', { + defaultMessage: 'Value format', +}); + +const decimalsLabel = i18n.translate('xpack.lens.indexPattern.decimalPlacesLabel', { + defaultMessage: 'Decimals', +}); + interface FormatSelectorProps { selectedColumn: GenericIndexPatternColumn; onChange: (newFormat?: { id: string; params?: Record }) => void; } -interface State { - decimalPlaces: number; -} - -const singleSelectionOption = { asPlainText: true }; +const RANGE_MIN = 0; +const RANGE_MAX = 15; export function FormatSelector(props: FormatSelectorProps) { const { selectedColumn, onChange } = props; @@ -53,21 +60,10 @@ export function FormatSelector(props: FormatSelectorProps) { 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params ? selectedColumn.params.format : undefined; - const [state, setState] = useState({ - decimalPlaces: - typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2, - }); - - const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; - const label = i18n.translate('xpack.lens.indexPattern.columnFormatLabel', { - defaultMessage: 'Value format', - }); - - const decimalsLabel = i18n.translate('xpack.lens.indexPattern.decimalPlacesLabel', { - defaultMessage: 'Decimals', - }); + const [decimals, setDecimals] = useState(currentFormat?.params?.decimals ?? 2); + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; const stableOptions = useMemo( () => [ defaultOption, @@ -91,10 +87,10 @@ export function FormatSelector(props: FormatSelectorProps) { } onChange({ id: choices[0].value, - params: { decimals: state.decimalPlaces }, + params: { decimals }, }); }, - [onChange, state.decimalPlaces] + [onChange, decimals] ); const currentOption = useMemo( @@ -130,15 +126,17 @@ export function FormatSelector(props: FormatSelectorProps) { { - setState({ decimalPlaces: Number(e.currentTarget.value) }); + const value = Number(e.currentTarget.value); + setDecimals(value); + const validatedValue = Math.min(RANGE_MAX, Math.max(RANGE_MIN, value)); onChange({ id: currentFormat.id, params: { - decimals: Number(e.currentTarget.value), + decimals: validatedValue, }, }); }} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index d8d20f93900e47..676b19b8bf5fcb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -39,16 +39,6 @@ jest.mock('@elastic/eui', () => { }; }); -// mocking random id generator function -jest.mock('@elastic/eui', () => { - const original = jest.requireActual('@elastic/eui'); - - return { - ...original, - htmlIdGenerator: () => () => '', - }; -}); - const uiSettingsMock = {} as IUiSettingsClient; const defaultProps = { @@ -1528,8 +1518,7 @@ describe('terms', () => { ); const selection = instance.find(EuiButtonGroup); - - expect(selection.prop('idSelected')).toEqual('asc'); + expect(selection.prop('idSelected')).toContain('asc'); expect(selection.prop('options').map(({ value }) => value)).toEqual(['asc', 'desc']); }); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx index 746d134a1c093b..f5031242d268c8 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiSwitchEvent, EuiSwitch, + EuiIcon, } from '@elastic/eui'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; @@ -33,6 +34,7 @@ import { FIXED_PROGRESSION, getStopsForFixedMode, PalettePanelContainer, + TooltipWrapper, } from '../../shared_components/'; import type { VisualizationDimensionEditorProps } from '../../types'; import './dimension_editor.scss'; @@ -193,11 +195,32 @@ export function GaugeDimensionEditor( + + {i18n.translate('xpack.lens.shared.ticksPositionOptions', { + defaultMessage: 'Ticks on bands', + })} + + + + + } > { const features = makeFeatures(['CN', 'CN', 'US', 'CN', 'US', 'IN']); const meta = colorStyle.pluckCategoricalStyleMetaFromFeatures(features); - expect(meta).toEqual({ - categories: [ - { key: 'CN', count: 3 }, - { key: 'US', count: 2 }, - { key: 'IN', count: 1 }, - ], - }); + expect(meta).toEqual([ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ]); }); test('Should pluck the categorical style-meta from fieldmeta', async () => { @@ -262,13 +260,11 @@ test('Should pluck the categorical style-meta from fieldmeta', async () => { }, }); - expect(meta).toEqual({ - categories: [ - { key: 'CN', count: 3 }, - { key: 'US', count: 2 }, - { key: 'IN', count: 1 }, - ], - }); + expect(meta).toEqual([ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ]); }); describe('supportsFieldMeta', () => { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index 03800fa03827eb..c9e7cfb6d7e39f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -263,8 +263,8 @@ export class DynamicColorProperty extends DynamicStyleProperty extends IStyleProperty { getMbFieldName(): string; getFieldOrigin(): FIELD_ORIGIN | null; getRangeFieldMeta(): RangeFieldMeta | null; - getCategoryFieldMeta(): CategoryFieldMeta | null; + getCategoryFieldMeta(): Category[]; /* * Returns hash that signals style meta needs to be re-fetched when value changes */ @@ -57,11 +57,9 @@ export interface IDynamicStyleProperty extends IStyleProperty { supportsFieldMeta(): boolean; getFieldMetaRequest(): Promise; pluckOrdinalStyleMetaFromFeatures(features: Feature[]): RangeFieldMeta | null; - pluckCategoricalStyleMetaFromFeatures(features: Feature[]): CategoryFieldMeta | null; + pluckCategoricalStyleMetaFromFeatures(features: Feature[]): Category[]; pluckOrdinalStyleMetaFromTileMetaFeatures(metaFeatures: TileMetaFeature[]): RangeFieldMeta | null; - pluckCategoricalStyleMetaFromTileMetaFeatures( - features: TileMetaFeature[] - ): CategoryFieldMeta | null; + pluckCategoricalStyleMetaFromTileMetaFeatures(features: TileMetaFeature[]): Category[]; getValueSuggestions(query: string): Promise; enrichGeoJsonAndMbFeatureState( featureCollection: FeatureCollection, @@ -175,19 +173,19 @@ export class DynamicStyleProperty _getCategoryFieldMetaFromStyleMetaRequest() { const dataRequestId = this._getStyleMetaDataRequestId(this.getFieldName()); if (!dataRequestId) { - return null; + return []; } const styleMetaDataRequest = this._layer.getDataRequest(dataRequestId); if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { - return null; + return []; } const data = styleMetaDataRequest.getData() as StyleMetaData; return this._pluckCategoricalStyleMetaFromFieldMetaData(data); } - getCategoryFieldMeta(): CategoryFieldMeta | null { + getCategoryFieldMeta(): Category[] { const categoryFieldMetaFromLocalFeatures = this._getCategoryFieldMetaFromLocalFeatures(); if (!this.isFieldMetaEnabled()) { @@ -195,7 +193,7 @@ export class DynamicStyleProperty } const categoricalFieldMetaFromServer = this._getCategoryFieldMetaFromStyleMetaRequest(); - return categoricalFieldMetaFromServer + return categoricalFieldMetaFromServer.length ? categoricalFieldMetaFromServer : categoryFieldMetaFromLocalFeatures; } @@ -332,10 +330,8 @@ export class DynamicStyleProperty }; } - pluckCategoricalStyleMetaFromTileMetaFeatures( - metaFeatures: TileMetaFeature[] - ): CategoryFieldMeta | null { - return null; + pluckCategoricalStyleMetaFromTileMetaFeatures(metaFeatures: TileMetaFeature[]): Category[] { + return []; } pluckOrdinalStyleMetaFromFeatures(features: Feature[]): RangeFieldMeta | null { @@ -364,10 +360,10 @@ export class DynamicStyleProperty }; } - pluckCategoricalStyleMetaFromFeatures(features: Feature[]): CategoryFieldMeta | null { + pluckCategoricalStyleMetaFromFeatures(features: Feature[]): Category[] { const size = this.getNumberOfCategories(); if (!this.isCategorical() || size <= 0) { - return null; + return []; } const counts = new Map(); @@ -384,7 +380,7 @@ export class DynamicStyleProperty } } - const ordered = []; + const ordered: Category[] = []; for (const [key, value] of counts) { ordered.push({ key, count: value }); } @@ -392,10 +388,7 @@ export class DynamicStyleProperty ordered.sort((a, b) => { return b.count - a.count; }); - const truncated = ordered.slice(0, size); - return { - categories: truncated, - } as CategoryFieldMeta; + return ordered.slice(0, size); } _pluckOrdinalStyleMetaFromFieldMetaData(styleMetaData: StyleMetaData): RangeFieldMeta | null { @@ -422,26 +415,22 @@ export class DynamicStyleProperty }; } - _pluckCategoricalStyleMetaFromFieldMetaData( - styleMetaData: StyleMetaData - ): CategoryFieldMeta | null { + _pluckCategoricalStyleMetaFromFieldMetaData(styleMetaData: StyleMetaData): Category[] { if (!this.isCategorical() || !this._field) { - return null; + return []; } const fieldMeta = styleMetaData[`${this._field.getRootName()}_terms`]; if (!fieldMeta || !('buckets' in fieldMeta)) { - return null; + return []; } - return { - categories: fieldMeta.buckets.map((bucket) => { - return { - key: bucket.key, - count: bucket.doc_count, - }; - }), - }; + return fieldMeta.buckets.map((bucket) => { + return { + key: bucket.key, + count: bucket.doc_count, + }; + }); } formatField(value: RawValue): string | number { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts index 9d6560ecb8888b..13455b3e4f840b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts @@ -8,12 +8,6 @@ // eslint-disable-next-line max-classes-per-file import { FIELD_ORIGIN, LAYER_STYLE_TYPE } from '../../../../../../common/constants'; import { StyleMeta } from '../../style_meta'; -import { - CategoryFieldMeta, - GeometryTypes, - RangeFieldMeta, - StyleMetaDescriptor, -} from '../../../../../../common/descriptor_types'; import { AbstractField, IField } from '../../../../fields/field'; import { IStyle } from '../../../style'; @@ -77,40 +71,32 @@ export class MockStyle implements IStyle { } getStyleMeta(): StyleMeta { - const geomTypes: GeometryTypes = { - isPointsOnly: false, - isLinesOnly: false, - isPolygonsOnly: false, - }; - const rangeFieldMeta: RangeFieldMeta = { - min: this._min, - max: this._max, - delta: this._max - this._min, - }; - const catFieldMeta: CategoryFieldMeta = { - categories: [ - { - key: 'US', - count: 10, - }, - { - key: 'CN', - count: 8, - }, - ], - }; - - const styleMetaDescriptor: StyleMetaDescriptor = { - geometryTypes: geomTypes, + return new StyleMeta({ + geometryTypes: { + isPointsOnly: false, + isLinesOnly: false, + isPolygonsOnly: false, + }, fieldMeta: { foobar: { - range: rangeFieldMeta, - categories: catFieldMeta, + range: { + min: this._min, + max: this._max, + delta: this._max - this._min, + }, + categories: [ + { + key: 'US', + count: 10, + }, + { + key: 'CN', + count: 8, + }, + ], }, }, - }; - - return new StyleMeta(styleMetaDescriptor); + }); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_meta.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_meta.ts index 14fab5ca937488..5177cdb8148333 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_meta.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_meta.ts @@ -5,11 +5,7 @@ * 2.0. */ -import { - StyleMetaDescriptor, - RangeFieldMeta, - CategoryFieldMeta, -} from '../../../../common/descriptor_types'; +import { StyleMetaDescriptor, RangeFieldMeta, Category } from '../../../../common/descriptor_types'; export class StyleMeta { private readonly _descriptor: StyleMetaDescriptor; @@ -23,10 +19,8 @@ export class StyleMeta { : null; } - getCategoryFieldMetaDescriptor(fieldName: string): CategoryFieldMeta | null { - return this._descriptor.fieldMeta[fieldName] && this._descriptor.fieldMeta[fieldName].categories - ? this._descriptor.fieldMeta[fieldName].categories! - : null; + getCategoryFieldMetaDescriptor(fieldName: string): Category[] { + return this._descriptor.fieldMeta[fieldName].categories; } isPointsOnly(): boolean { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index a4ea62cb639704..bb83d6b1eb448e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -511,19 +511,15 @@ export class VectorStyle implements IVectorStyle { } dynamicProperties.forEach((dynamicProperty) => { - const ordinalStyleMeta = - dynamicProperty.pluckOrdinalStyleMetaFromTileMetaFeatures(metaFeatures); - const categoricalStyleMeta = - dynamicProperty.pluckCategoricalStyleMetaFromTileMetaFeatures(metaFeatures); - const name = dynamicProperty.getFieldName(); if (!styleMeta.fieldMeta[name]) { - styleMeta.fieldMeta[name] = {}; - } - if (categoricalStyleMeta) { - styleMeta.fieldMeta[name].categories = categoricalStyleMeta; + styleMeta.fieldMeta[name] = { categories: [] }; } + styleMeta.fieldMeta[name].categories = + dynamicProperty.pluckCategoricalStyleMetaFromTileMetaFeatures(metaFeatures); + const ordinalStyleMeta = + dynamicProperty.pluckOrdinalStyleMetaFromTileMetaFeatures(metaFeatures); if (ordinalStyleMeta) { styleMeta.fieldMeta[name].range = ordinalStyleMeta; } @@ -595,17 +591,13 @@ export class VectorStyle implements IVectorStyle { dynamicProperties.forEach( (dynamicProperty: IDynamicStyleProperty) => { - const categoricalStyleMeta = - dynamicProperty.pluckCategoricalStyleMetaFromFeatures(features); - const ordinalStyleMeta = dynamicProperty.pluckOrdinalStyleMetaFromFeatures(features); const name = dynamicProperty.getFieldName(); if (!styleMeta.fieldMeta[name]) { - styleMeta.fieldMeta[name] = {}; - } - if (categoricalStyleMeta) { - styleMeta.fieldMeta[name].categories = categoricalStyleMeta; + styleMeta.fieldMeta[name] = { categories: [] }; } - + styleMeta.fieldMeta[name].categories = + dynamicProperty.pluckCategoricalStyleMetaFromFeatures(features); + const ordinalStyleMeta = dynamicProperty.pluckOrdinalStyleMetaFromFeatures(features); if (ordinalStyleMeta) { styleMeta.fieldMeta[name].range = ordinalStyleMeta; } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index db903c6a025937..3f3b7558409f8f 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -84,13 +84,14 @@ export class TOCEntryActionsPopover extends Component { async _getIsFeatureEditingEnabled(): Promise { const vectorLayer = this.props.layer as IVectorLayer; - const layerSource = this.props.layer.getSource(); - if (!(layerSource instanceof ESSearchSource)) { + const source = this.props.layer.getSource(); + if (!(source instanceof ESSearchSource)) { return false; } if ( - (layerSource as ESSearchSource).getSyncMeta().scalingType === SCALING_TYPES.CLUSTERS || + (source as ESSearchSource).getApplyGlobalQuery() || + (source as ESSearchSource).getSyncMeta().scalingType === SCALING_TYPES.CLUSTERS || (await vectorLayer.isFilteredByGlobalTime()) || vectorLayer.isPreviewLayer() || !vectorLayer.isVisible() || @@ -191,9 +192,9 @@ export class TOCEntryActionsPopover extends Component { 'data-test-subj': 'editLayerButton', toolTipContent: this.state.isFeatureEditingEnabled ? null - : i18n.translate('xpack.maps.layerTocActions.editLayerTooltip', { + : i18n.translate('xpack.maps.layerTocActions.editFeaturesTooltip.disabledMessage', { defaultMessage: - 'Edit features only supported for document layers without clustering, joins, or time filtering', + 'Edit features only supported for document layers without clustering, term joins, time filtering, or global search.', }), disabled: !this.state.isFeatureEditingEnabled || this.props.editModeActiveForLayer, onClick: async () => { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts index d4bafe38980337..7f3378fc1c858c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FIELD_ORIGIN, STYLE_TYPE } from '../../../../../maps/common'; +import { FIELD_ORIGIN, LAYER_TYPE, STYLE_TYPE } from '../../../../../maps/common'; import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; import { AnomaliesTableData } from '../explorer_utils'; @@ -104,7 +104,7 @@ export const getMLAnomaliesTypicalLayer = (anomalies: AnomaliesTableData['anomal }, }, }, - type: 'VECTOR', + type: LAYER_TYPE.GEOJSON_VECTOR, }; }; @@ -161,6 +161,6 @@ export const getMLAnomaliesActualLayer = (anomalies: any) => { }, }, }, - type: 'VECTOR', + type: LAYER_TYPE.GEOJSON_VECTOR, }; }; diff --git a/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx b/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx index 110e1c1837b9af..18806162b8b30e 100644 --- a/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx +++ b/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx @@ -7,8 +7,9 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiLink } from '@elastic/eui'; +import type { Observable } from 'rxjs'; +import type { CoreTheme } from 'kibana/public'; import { Legacy } from '../../legacy_shims'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; @@ -18,21 +19,19 @@ export interface EnableAlertResponse { disabledWatcherClusterAlerts?: boolean; } -const showApiKeyAndEncryptionError = () => { +const showApiKeyAndEncryptionError = (theme$?: Observable) => { const settingsUrl = Legacy.shims.docLinks.links.alerting.generalSettings; Legacy.shims.toastNotifications.addWarning({ - title: toMountPoint( - - ), + title: i18n.translate('xpack.monitoring.healthCheck.tlsAndEncryptionErrorTitle', { + defaultMessage: 'Additional setup required', + }), text: toMountPoint(

{i18n.translate('xpack.monitoring.healthCheck.tlsAndEncryptionError', { - defaultMessage: `Stack Monitoring rules require API keys to be enabled and an encryption key to be configured.`, + defaultMessage: + 'Stack Monitoring rules require API keys to be enabled and an encryption key to be configured.', })}

@@ -41,26 +40,25 @@ const showApiKeyAndEncryptionError = () => { defaultMessage: 'Learn how.', })} -
+ , + { theme$ } ), }); }; -const showUnableToDisableWatcherClusterAlertsError = () => { +const showUnableToDisableWatcherClusterAlertsError = (theme$?: Observable) => { const settingsUrl = Legacy.shims.docLinks.links.alerting.generalSettings; Legacy.shims.toastNotifications.addWarning({ - title: toMountPoint( - - ), + title: i18n.translate('xpack.monitoring.healthCheck.unableToDisableWatches.title', { + defaultMessage: 'Legacy cluster alerts still active', + }), text: toMountPoint(

{i18n.translate('xpack.monitoring.healthCheck.unableToDisableWatches.text', { - defaultMessage: `We failed to remove legacy cluster alerts. Please check the Kibana server log for more details, or try again later.`, + defaultMessage: + 'We failed to remove legacy cluster alerts. Please check the Kibana server log for more details, or try again later.', })}

@@ -69,38 +67,33 @@ const showUnableToDisableWatcherClusterAlertsError = () => { defaultMessage: 'Learn more.', })} -
+ , + { theme$ } ), }); }; const showDisabledWatcherClusterAlertsError = () => { Legacy.shims.toastNotifications.addWarning({ - title: toMountPoint( - - ), - text: toMountPoint( -

- {i18n.translate('xpack.monitoring.healthCheck.disabledWatches.text', { - defaultMessage: `Review the alert definition using Setup mode and configure additional action connectors to get notified via your favorite method.`, - })} -

- ), + title: i18n.translate('xpack.monitoring.healthCheck.disabledWatches.title', { + defaultMessage: 'New alerts created', + }), + text: i18n.translate('xpack.monitoring.healthCheck.disabledWatches.text', { + defaultMessage: + 'Review the alert definition using Setup mode and configure additional action connectors to get notified via your favorite method.', + }), 'data-test-subj': 'alertsCreatedToast', }); }; -export const showAlertsToast = (response: EnableAlertResponse) => { +export const showAlertsToast = (response: EnableAlertResponse, theme$?: Observable) => { const { isSufficientlySecure, hasPermanentEncryptionKey, disabledWatcherClusterAlerts } = response; if (isSufficientlySecure === false || hasPermanentEncryptionKey === false) { - showApiKeyAndEncryptionError(); + showApiKeyAndEncryptionError(theme$); } else if (disabledWatcherClusterAlerts === false) { - showUnableToDisableWatcherClusterAlertsError(); + showUnableToDisableWatcherClusterAlertsError(theme$); } else if (disabledWatcherClusterAlerts === true) { showDisabledWatcherClusterAlertsError(); } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts index db692889e1140f..6cbeffd3cb7376 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts @@ -38,7 +38,7 @@ export const useAlertsModal = () => { {} )!; window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); - showAlertsToast(response); + showAlertsToast(response, services.theme?.theme$); } catch (err) { await handleRequestError(err); } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx index 424dda666945b1..ccf58c1f9d47af 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx +++ b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx @@ -5,6 +5,7 @@ * 2.0. */ import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; import { useHistory } from 'react-router-dom'; import { includes } from 'lodash'; import { IHttpFetchError, ResponseErrorBody } from 'kibana/public'; @@ -42,15 +43,16 @@ export const useRequestErrorHandler = () => { // redirect to error message view history.push('/access-denied'); } else if (err.response?.status === 404 && !includes(window.location.hash, 'no-data')) { - // pass through if this is a 404 and we're already on the no-data page + // pass through if this is a 404, and we're already on the no-data page const formattedError = formatMonitoringError(err); services.notifications?.toasts.addDanger({ - title: toMountPoint( - + title: i18n.translate( + 'xpack.monitoring.ajaxErrorHandler.requestFailedNotificationTitle', + { + defaultMessage: 'Monitoring Request Failed', + } ), + text: toMountPoint(
{formattedError} @@ -61,21 +63,19 @@ export const useRequestErrorHandler = () => { defaultMessage="Retry" /> -
+ , + { theme$: services.theme?.theme$ } ), }); } else { services.notifications?.toasts.addDanger({ - title: toMountPoint( - - ), - text: toMountPoint(formatMonitoringError(err)), + title: i18n.translate('xpack.monitoring.ajaxErrorHandler.requestErrorNotificationTitle', { + defaultMessage: 'Monitoring Request Error', + }), + text: toMountPoint(formatMonitoringError(err), { theme$: services.theme?.theme$ }), }); } }, - [history, services.notifications?.toasts] + [history, services.notifications?.toasts, services.theme] ); }; diff --git a/x-pack/plugins/monitoring/public/components/action_menu/index.tsx b/x-pack/plugins/monitoring/public/components/action_menu/index.tsx index 1348ac170395e9..c1de80ccaf2429 100644 --- a/x-pack/plugins/monitoring/public/components/action_menu/index.tsx +++ b/x-pack/plugins/monitoring/public/components/action_menu/index.tsx @@ -20,7 +20,8 @@ export const ActionMenu: React.FC<{}> = ({ children }) => { if (setHeaderActionMenu) { setHeaderActionMenu((element) => { const mount = toMountPoint( - {children} + {children}, + { theme$: services.theme?.theme$ } ); return mount(element); }); diff --git a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js index 42a08d65b61188..2c6200284bdd7a 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js +++ b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, Component } from 'react'; +import React, { Fragment } from 'react'; import { Legacy } from '../../../legacy_shims'; import moment from 'moment'; import numeral from '@elastic/numeral'; @@ -24,11 +24,11 @@ import { import { EuiMonitoringTable } from '../../table'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertsStatus } from '../../../alerts/status'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; import './listing.scss'; +import { toMountPoint, useKibana } from '../../../../../../../src/plugins/kibana_react/public'; const IsClusterSupported = ({ isSupported, children }) => { return isSupported ? children : '-'; @@ -119,7 +119,7 @@ const getColumns = ( render: (_status, cluster) => ( - + ), @@ -252,14 +252,14 @@ const licenseWarning = (scope, { title, text }) => { }); }; -const handleClickIncompatibleLicense = (scope, clusterName) => { +const handleClickIncompatibleLicense = (scope, theme$, clusterName) => { licenseWarning(scope, { - title: toMountPoint( - + title: i18n.translate( + 'xpack.monitoring.cluster.listing.incompatibleLicense.warningMessageTitle', + { + defaultMessage: "You can't view the {clusterName} cluster", + values: { clusterName: '"' + clusterName + '"' }, + } ), text: toMountPoint( @@ -285,22 +285,20 @@ const handleClickIncompatibleLicense = (scope, clusterName) => { }} />

-
+ , + { theme$ } ), }); }; -const handleClickInvalidLicense = (scope, clusterName) => { +const handleClickInvalidLicense = (scope, theme$, clusterName) => { const licensingPath = `${Legacy.shims.getBasePath()}/app/management/stack/license_management/home`; licenseWarning(scope, { - title: toMountPoint( - - ), + title: i18n.translate('xpack.monitoring.cluster.listing.invalidLicense.warningMessageTitle', { + defaultMessage: "You can't view the {clusterName} cluster", + values: { clusterName: '"' + clusterName + '"' }, + }), text: toMountPoint(

@@ -333,124 +331,123 @@ const handleClickInvalidLicense = (scope, clusterName) => { }} />

-
+ , + { theme$ } ), }); }; -export class Listing extends Component { - constructor(props) { - super(props); - this.state = { - [STANDALONE_CLUSTER_STORAGE_KEY]: false, - }; +const StandaloneClusterCallout = ({ changeCluster, storage }) => { + if (storage.get(STANDALONE_CLUSTER_STORAGE_KEY)) { + return null; } - renderStandaloneClusterCallout(changeCluster, storage) { - if (storage.get(STANDALONE_CLUSTER_STORAGE_KEY)) { - return null; - } - - return ( -
- -

- changeCluster(STANDALONE_CLUSTER_CLUSTER_UUID)} - data-test-subj="standaloneClusterLink" - > - - + return ( +

+ +

+ changeCluster(STANDALONE_CLUSTER_CLUSTER_UUID)} + data-test-subj="standaloneClusterLink" + > + + +   + +

+

+ { + storage.set(STANDALONE_CLUSTER_STORAGE_KEY, true); + }} + > +   -

-

- { - storage.set(STANDALONE_CLUSTER_STORAGE_KEY, true); - this.setState({ [STANDALONE_CLUSTER_STORAGE_KEY]: true }); - }} - > - -   - - -

-
- -
- ); - } + +

+
+ +
+ ); +}; - render() { - const { angular, clusters, sorting, pagination, onTableChange } = this.props; +export const Listing = ({ angular, clusters, sorting, pagination, onTableChange }) => { + const { services } = useKibana(); - const _changeCluster = partial(changeCluster, angular.scope, angular.globalState); - const _handleClickIncompatibleLicense = partial(handleClickIncompatibleLicense, angular.scope); - const _handleClickInvalidLicense = partial(handleClickInvalidLicense, angular.scope); - const hasStandaloneCluster = !!clusters.find( - (cluster) => cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID - ); + const _changeCluster = partial(changeCluster, angular.scope, angular.globalState); + const _handleClickIncompatibleLicense = partial( + handleClickIncompatibleLicense, + angular.scope, + services.theme.theme$ + ); + const _handleClickInvalidLicense = partial( + handleClickInvalidLicense, + angular.scope, + services.theme.theme$ + ); + const hasStandaloneCluster = !!clusters.find( + (cluster) => cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID + ); - return ( - - - - {hasStandaloneCluster - ? this.renderStandaloneClusterCallout(_changeCluster, angular.storage) - : null} - { - return { - 'data-test-subj': `clusterRow_${item.cluster_uuid}`, - }; - }} - sorting={{ - ...sorting, - sort: { - ...sorting.sort, - field: 'cluster_name', - }, - }} - pagination={pagination} - search={{ - box: { - incremental: true, - placeholder: angular.scope.filterText, - }, - }} - onTableChange={onTableChange} - executeQueryOptions={{ - defaultFields: ['cluster_name'], - }} - /> - - - - ); - } -} + return ( + + + + {hasStandaloneCluster ? ( + + ) : null} + { + return { + 'data-test-subj': `clusterRow_${item.cluster_uuid}`, + }; + }} + sorting={{ + ...sorting, + sort: { + ...sorting.sort, + field: 'cluster_name', + }, + }} + pagination={pagination} + search={{ + box: { + incremental: true, + placeholder: angular.scope.filterText, + }, + }} + onTableChange={onTableChange} + executeQueryOptions={{ + defaultFields: ['cluster_name'], + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts index bb5d379f3c80c4..bce6f57d6f950a 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts @@ -14,7 +14,7 @@ import { getAllStats } from './get_all_stats'; import { getClusterUuids } from './get_cluster_uuids'; import { getLicenses } from './get_licenses'; -interface MonitoringStats extends UsageStatsPayload { +interface MonitoringStats extends Omit { stack_stats: { logstash?: LogstashBaseStats; beats?: BeatsBaseStats; @@ -149,6 +149,7 @@ export function registerMonitoringTelemetryCollection( getLicenses(clusterDetails, callCluster, maxBucketSize), getAllStats(clusterDetails, callCluster, timestamp, maxBucketSize), ]); + return { stats: stats.map((stat) => { const license = licenses[stat.cluster_uuid]; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index b60382481e9d60..a2bc3a2c0456aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useMemo } from 'react'; import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts'; -import { getOr, get, isNumber, isEmpty } from 'lodash/fp'; +import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; import uuid from 'uuid'; import styled from 'styled-components'; @@ -18,6 +18,7 @@ import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { useTimeZone } from '../../lib/kibana'; import { defaultLegendColors } from '../matrix_histogram/utils'; import { useThrottledResizeObserver } from '../utils'; +import { hasValueToDisplay } from '../../utils/validators'; import { EMPTY_VALUE_LABEL } from '../charts/translation'; import { ChartPlaceHolder } from './chart_place_holder'; @@ -53,7 +54,7 @@ const checkIfAnyValidSeriesExist = ( const yAccessors = ['y']; const splitSeriesAccessors = [ - (datum: ChartData) => (isEmpty(datum.g) ? EMPTY_VALUE_LABEL : datum.g), + (datum: ChartData) => (hasValueToDisplay(datum.g) ? datum.g : EMPTY_VALUE_LABEL), ]; // Bar chart rotation: https://ela.st/chart-rotations diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 9d1dde39d8cc02..6261783a53a682 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -68,4 +68,13 @@ describe('DraggableLegendItem', () => { ); expect(wrapper.find('[data-test-subj="value-wrapper-empty"]').first().exists()).toBeTruthy(); }); + + it('does not render the empty value label when the value is a number', () => { + wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="value-wrapper-empty"]').first().exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index 3182189a52e2a8..1b9085f8869cc2 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -7,24 +7,28 @@ import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; import React from 'react'; -import { isEmpty } from 'lodash/fp'; import { DefaultDraggable } from '../draggables'; import { EMPTY_VALUE_LABEL } from './translation'; +import { hasValueToDisplay } from '../../utils/validators'; export interface LegendItem { color?: string; dataProviderId: string; field: string; timelineId?: string; - value: string; + value: string | number; } /** * Renders the value or a placeholder in case the value is empty */ -const ValueWrapper = React.memo<{ value?: string | null }>(({ value }) => - isEmpty(value) ? {EMPTY_VALUE_LABEL} : <>{value} +const ValueWrapper = React.memo<{ value: LegendItem['value'] }>(({ value }) => + hasValueToDisplay(value) ? ( + <>{value} + ) : ( + {EMPTY_VALUE_LABEL} + ) ); ValueWrapper.displayName = 'ValueWrapper'; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index 26eaec4f7a76ec..c9e67e8181d285 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -24,7 +24,7 @@ export interface DefaultDraggableType { id: string; isDraggable?: boolean; field: string; - value?: string | null; + value?: string | number | null; name?: string | null; queryValue?: string | null; children?: React.ReactNode; @@ -63,7 +63,7 @@ export const Content = React.memo<{ field: string; tooltipContent?: React.ReactNode; tooltipPosition?: ToolTipPositions; - value?: string | null; + value?: string | number | null; }>(({ children, field, tooltipContent, tooltipPosition, value }) => !tooltipContentIsExplicitlyNull(tooltipContent) ? ( ( and: [], enabled: true, id: escapeDataProviderId(id), - name: name ? name : value ?? '', + name: name ? name : value?.toString() ?? '', excluded: false, kqlQuery: '', queryMatch: { diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx index 089707ee0eec18..b410a1689b4766 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx @@ -27,7 +27,7 @@ export const CaseDetailsRefreshContext = * const caseDetailsRefresh = useWithCaseDetailsRefresh(); * ... * if (caseDetailsRefresh) { - * caseDetailsRefresh.refreshUserActionsAndComments(); + * caseDetailsRefresh.refreshCase(); * } */ export const useWithCaseDetailsRefresh = (): Readonly | undefined => { diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts index 2fbe0ed3e6bb8d..090d929ae67e9a 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isUrlInvalid } from '.'; +import { isUrlInvalid, hasValueToDisplay } from '.'; describe('helpers', () => { describe('isUrlInvalid', () => { @@ -49,4 +49,18 @@ describe('helpers', () => { expect(isUrlInvalid('http:www.thisIsNotValid.com/foo')).toBeTruthy(); }); }); + + describe('hasValueToDisplay', () => { + test('identifies valid values', () => { + expect(hasValueToDisplay('test')).toBeTruthy(); + expect(hasValueToDisplay(0)).toBeTruthy(); + expect(hasValueToDisplay(100)).toBeTruthy(); + }); + + test('identifies empty/invalid values', () => { + expect(hasValueToDisplay('')).toBeFalsy(); + expect(hasValueToDisplay(null)).toBeFalsy(); + expect(hasValueToDisplay(undefined)).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 6989e27fc779d1..2194943adab76a 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty, isNumber } from 'lodash/fp'; export * from './is_endpoint_host_isolated'; const allowedSchemes = ['http:', 'https:']; @@ -29,3 +30,7 @@ export const isUrlInvalid = (url: string | null | undefined) => { } return true; }; + +export function hasValueToDisplay(value: string | number | null | undefined) { + return isNumber(value) || !isEmpty(value); +} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx index 561126f3264adb..4fce0361d5d133 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx @@ -18,6 +18,11 @@ import { AlertsCountAggregation } from './types'; jest.mock('../../../../common/lib/kibana'); const mockDispatch = jest.fn(); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); return { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx index bd747fb637cb8c..cd407a125cdb65 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx @@ -6,10 +6,9 @@ */ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; -import type { AlertsStackByField } from '../common/types'; export const getAlertsCountQuery = ( - stackByField: AlertsStackByField, + stackByField: string, from: string, to: string, additionalFilters: Array<{ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx index 9660916d4f32c1..d0b05587a4711f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx @@ -13,6 +13,11 @@ import { TestProviders } from '../../../../common/mock'; import { AlertsCountPanel } from './index'; +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 94b09c4a5ea213..b69f4f1f498f9e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -21,8 +21,7 @@ import * as i18n from './translations'; import { AlertsCount } from './alerts_count'; import type { AlertsCountAggregation } from './types'; import { DEFAULT_STACK_BY_FIELD } from '../common/config'; -import type { AlertsStackByField } from '../common/types'; -import { KpiPanel, StackBySelect } from '../common/components'; +import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -39,8 +38,7 @@ export const AlertsCountPanel = memo( // create a unique, but stable (across re-renders) query id const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COUNT_ID}-${uuid.v4()}`, []); - const [selectedStackByOption, setSelectedStackByOption] = - useState(DEFAULT_STACK_BY_FIELD); + const [selectedStackByOption, setSelectedStackByOption] = useState(DEFAULT_STACK_BY_FIELD); // TODO: Once we are past experimental phase this code should be removed // const fetchMethod = useIsExperimentalFeatureEnabled('ruleRegistryEnabled') @@ -99,7 +97,7 @@ export const AlertsCountPanel = memo( titleSize="s" hideSubtitle > - + ( const id = 'alertsHistogram'; const yAccessors = useMemo(() => ['y'], []); const splitSeriesAccessors = useMemo( - () => [(datum: ChartData) => (isEmpty(datum.g) ? EMPTY_VALUE_LABEL : datum.g)], + () => [(datum: ChartData) => (hasValueToDisplay(datum.g) ? datum.g : EMPTY_VALUE_LABEL)], [] ); const tickFormat = useMemo(() => histogramDateTimeFormatter([from, to]), [from, to]); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx index 5c3808843aae7e..a4768ac3c4c8f2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx @@ -10,7 +10,6 @@ import moment from 'moment'; import type { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } from './types'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; -import type { AlertsStackByField } from '../common/types'; const EMPTY_ALERTS_DATA: HistogramData[] = []; @@ -33,7 +32,7 @@ export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggre }; export const getAlertsHistogramQuery = ( - stackByField: AlertsStackByField, + stackByField: string, from: string, to: string, additionalFilters: Array<{ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 09c184bb62bd52..eb06f85a0a76fe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => { ...originalModule, createHref: jest.fn(), useHistory: jest.fn(), + useLocation: jest.fn().mockReturnValue({ pathname: '' }), }; }); @@ -37,9 +38,21 @@ jest.mock('../../../../common/lib/kibana/kibana_react', () => { navigateToApp: mockNavigateToApp, getUrlForApp: jest.fn(), }, + data: { + search: { + search: jest.fn(), + }, + }, uiSettings: { get: jest.fn(), }, + notifications: { + toasts: { + addWarning: jest.fn(), + addError: jest.fn(), + addSuccess: jest.fn(), + }, + }, }, }), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 873b5d40184ef6..11dbb4da863db4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -41,7 +41,7 @@ import { LinkButton } from '../../../../common/components/links'; import { SecurityPageName } from '../../../../app/types'; import { DEFAULT_STACK_BY_FIELD, PANEL_HEIGHT } from '../common/config'; import type { AlertsStackByField } from '../common/types'; -import { KpiPanel, StackBySelect } from '../common/components'; +import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; @@ -109,7 +109,7 @@ export const AlertsHistogramPanel = memo( const [isInspectDisabled, setIsInspectDisabled] = useState(false); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const [totalAlertsObj, setTotalAlertsObj] = useState(defaultTotalAlertsObj); - const [selectedStackByOption, setSelectedStackByOption] = useState( + const [selectedStackByOption, setSelectedStackByOption] = useState( onlyField == null ? defaultStackByOption : onlyField ); @@ -276,10 +276,12 @@ export const AlertsHistogramPanel = memo( {showStackBy && ( - + <> + + )} {headerChildren != null && headerChildren} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 53d41835d6bb92..6a56f7bc220ac2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { EuiPanel, EuiSelect } from '@elastic/eui'; +import { EuiPanel, EuiComboBox } from '@elastic/eui'; import styled from 'styled-components'; -import React, { useCallback } from 'react'; -import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT, alertsStackByOptions } from './config'; -import type { AlertsStackByField } from './types'; +import React, { useCallback, useMemo } from 'react'; +import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; +import { useStackByFields } from './hooks'; import * as i18n from './translations'; export const KpiPanel = styled(EuiPanel)<{ height?: number }>` @@ -25,24 +25,45 @@ export const KpiPanel = styled(EuiPanel)<{ height?: number }>` } `; interface StackedBySelectProps { - selected: AlertsStackByField; - onSelect: (selected: AlertsStackByField) => void; + selected: string; + onSelect: (selected: string) => void; } -export const StackBySelect: React.FC = ({ selected, onSelect }) => { - const setSelectedOptionCallback = useCallback( - (event: React.ChangeEvent) => { - onSelect(event.target.value as AlertsStackByField); +export const StackByComboBoxWrapper = styled.div` + width: 400px; +`; + +export const StackByComboBox: React.FC = ({ selected, onSelect }) => { + const onChange = useCallback( + (options) => { + if (options && options.length > 0) { + onSelect(options[0].value); + } else { + onSelect(''); + } }, [onSelect] ); - + const selectedOptions = useMemo(() => { + return [{ label: selected, value: selected }]; + }, [selected]); + const stackOptions = useStackByFields(); + const singleSelection = useMemo(() => { + return { asPlainText: true }; + }, []); return ( - + + + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx index ad0fc1fa7ac61d..d68c5c303cfd70 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx @@ -5,8 +5,16 @@ * 2.0. */ +import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { useInspectButton, UseInspectButtonParams } from './hooks'; +import { useInspectButton, UseInspectButtonParams, useStackByFields } from './hooks'; +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); describe('hooks', () => { describe('useInspectButton', () => { @@ -43,4 +51,22 @@ describe('hooks', () => { expect(mockDeleteQuery).toHaveBeenCalledWith({ id: defaultParams.uniqueQueryId }); }); }); + + describe('useStackByFields', () => { + jest.mock('../../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ browserFields: mockBrowserFields }), + })); + it('returns only aggregateable fields', () => { + const wrapper = ({ children }: { children: JSX.Element }) => ( + {children} + ); + const { result, unmount } = renderHook(() => useStackByFields(), { wrapper }); + const aggregateableFields = result.current; + unmount(); + expect(aggregateableFields?.find((field) => field.label === 'agent.id')).toBeTruthy(); + expect( + aggregateableFields?.find((field) => field.label === 'nestedField.firstAttributes') + ).toBe(undefined); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts index 6375e2b0c27fb3..65b87670810b09 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts @@ -5,8 +5,13 @@ * 2.0. */ -import { useEffect } from 'react'; +import { useEffect, useState, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { BrowserField } from '../../../../../../timelines/common'; import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time'; +import { getScopeFromPath, useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { getAllFieldsByName } from '../../../../common/containers/source'; export interface UseInspectButtonParams extends Pick { response: string; @@ -15,6 +20,7 @@ export interface UseInspectButtonParams extends Pick }) { + return Object.entries(fields).reduce( + (filteredOptions: EuiComboBoxOptionOption[], [key, field]) => { + if (field.aggregatable === true) { + return [...filteredOptions, { label: key, value: key }]; + } else { + return filteredOptions; + } + }, + [] + ); +} + +export const useStackByFields = () => { + const { pathname } = useLocation(); + + const { browserFields } = useSourcererDataView(getScopeFromPath(pathname)); + const allFields = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); + const [stackByFieldOptions, setStackByFieldOptions] = useState(() => + getAggregatableFields(allFields) + ); + useEffect(() => { + setStackByFieldOptions(getAggregatableFields(allFields)); + }, [allFields]); + return useMemo(() => stackByFieldOptions, [stackByFieldOptions]); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts index d99e1d4744ae7b..d45563675154e9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts @@ -13,3 +13,17 @@ export const STACK_BY_LABEL = i18n.translate( defaultMessage: 'Stack by', } ); + +export const STACK_BY_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByPlaceholder', + { + defaultMessage: 'Select a field to stack by', + } +); + +export const STACK_BY_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByAriaLabel', + { + defaultMessage: 'Stack the alerts histogram by a field value', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index eeb22a2aa071c5..c5d053c57fc970 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -23,6 +23,7 @@ import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { createStore, State } from '../../../common/store'; import { mockHistory, Router } from '../../../common/mock/router'; import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; +import { mockBrowserFields } from '../../../common/containers/source/mock'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -71,6 +72,9 @@ jest.mock('../../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, }, }, + uiSettings: { + get: jest.fn(), + }, timelines: { ...mockTimelines }, data: { query: { @@ -113,6 +117,7 @@ describe('DetectionEnginePageComponent', () => { (useSourcererDataView as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, + browserFields: mockBrowserFields, }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 44a389c5a3a46a..cd72bbf3494077 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -216,7 +216,7 @@ const normalizeEventFiltersPageLocation = ( }; const normalizeHostIsolationExceptionsPageLocation = ( - location?: Partial + location?: Partial ): Partial => { if (location) { return { @@ -229,6 +229,9 @@ const normalizeHostIsolationExceptionsPageLocation = ( ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + ...(!isDefaultOrMissing(location.included_policies, '') + ? { included_policies: location.included_policies } + : ''), }; } else { return {}; @@ -373,6 +376,7 @@ export const extractHostIsolationExceptionsPageLocation = ( return { ...extractListPaginationParams(query), + included_policies: extractIncludedPolicies(query), show: showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, id: extractFirstParamValue(query, 'id'), diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index e9f93e85bdb605..a19c2913a12a0a 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -53,7 +53,8 @@ export const parsePoliciesToKQL = (includedPolicies: string, excludedPolicies: s /** * Takes a list of policies (string[]) and an existing kuery * (string) and returns an unified KQL with and AND - * @param policies string[] a list of policies ids + * The policy list can also contain "unassigned" and "global". + * @param policies string[] a list of policies ids. * @param kuery string an existing KQL. */ export const parsePoliciesAndFilterToKql = ({ diff --git a/x-pack/plugins/security_solution/public/management/components/policies_selector/policies_selector.tsx b/x-pack/plugins/security_solution/public/management/components/policies_selector/policies_selector.tsx index 02357f63b4f9ac..73401c9b3d3cae 100644 --- a/x-pack/plugins/security_solution/public/management/components/policies_selector/policies_selector.tsx +++ b/x-pack/plugins/security_solution/public/management/components/policies_selector/policies_selector.tsx @@ -193,7 +193,9 @@ export const PoliciesSelector = memo( value={query} /> -
{dropdownItems}
+
+ {dropdownItems} +
diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts index 4d74378e872981..d8d71da41f7d24 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -13,5 +13,6 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, filter: '', + included_policies: '', }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts index e70957fa2d74d5..56529025ed0861 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -34,6 +34,7 @@ describe('Host isolation exceptions Reducer', () => { expect(getCurrentLocation(result)).toEqual({ filter: '', id: undefined, + included_policies: '', page_index: 0, page_size: 10, show: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts index 9affcc8c50438f..a70e6dc6f2e1e7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -12,6 +12,8 @@ export interface HostIsolationExceptionsPageLocation { /** Used for editing. The ID of the selected event filter */ id?: string; filter: string; + // A string with comma dlimetered list of included policy IDs + included_policies: string; } export interface HostIsolationExceptionsPageState { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index c53371167c536f..5ac4f97591fdd6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import { act, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { act, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; +import { EndpointPrivileges } from '../../../../../common/endpoint/types'; +import { useUserPrivileges as _useUserPrivileges } from '../../../../common/components/user_privileges'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utilts'; import { getHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; -import { useUserPrivileges as _useUserPrivileges } from '../../../../common/components/user_privileges'; -import { EndpointPrivileges } from '../../../../../common/endpoint/types'; jest.mock('../service'); jest.mock('../../../../common/hooks/use_license'); @@ -118,13 +118,14 @@ describe('When on the host isolation exceptions page', () => { ); }); - it('should display the search bar and item count', async () => { + it('should display the search bar, item count and policy filter', async () => { render(); await waitForApiCall(); expect(renderResult.getByTestId('searchExceptions')).toBeTruthy(); expect(renderResult.getByTestId('hostIsolationExceptions-totalCount').textContent).toBe( 'Showing 1 exception' ); + expect(renderResult.getByTestId('policiesSelectorButton')).toBeTruthy(); }); it('should show items on the list', async () => { @@ -182,6 +183,39 @@ describe('When on the host isolation exceptions page', () => { // check the searchbar is still there expect(renderResult.getByTestId('searchExceptions')).toBeTruthy(); }); + + it('should apply a policy filter when a filter is selected', async () => { + const policies = await sendGetEndpointSpecificPackagePoliciesMock(); + const firstPolicy = policies.items[0]; + (sendGetEndpointSpecificPackagePolicies as jest.Mock).mockResolvedValue(policies); + + render(); + await waitForApiCall(); + + // press the filter button + const button = renderResult.getByTestId('policiesSelectorButton'); + expect(button).toBeTruthy(); + userEvent.click(button); + + // find the first policy option and click it + const option = within(renderResult.getByTestId('policiesSelector-popover')).getByText( + firstPolicy.name + ); + userEvent.click(option); + + // wait for the page render and request + await waitFor(() => + expect(getHostIsolationExceptionItemsMock).toHaveBeenLastCalledWith({ + filter: `((exception-list-agnostic.attributes.tags:"policy:${firstPolicy.id}"))`, + http: mockedContext.coreStart.http, + page: 1, + perPage: 10, + }) + ); + + // check the url changed + expect(mockedContext.history.location.search).toBe(`?included_policies=${firstPolicy.id}`); + }); }); describe('has canIsolateHost privileges', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index c0e4b4b36402cc..21e511fed6c7d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -55,10 +55,16 @@ export const HostIsolationExceptionsList = () => { const [itemToDelete, setItemToDelete] = useState(null); + const includedPoliciesParam = location.included_policies; + const { isLoading, data, error, refetch } = useFetchHostIsolationExceptionsList({ filter: location.filter, page: location.page_index, perPage: location.page_size, + policies: + includedPoliciesParam && includedPoliciesParam !== '' + ? includedPoliciesParam.split(',') + : undefined, }); const toasts = useToasts(); @@ -90,8 +96,11 @@ export const HostIsolationExceptionsList = () => { }, [history, isLoading, listItems.length, privileges.canIsolateHost]); const handleOnSearch = useCallback( - (query: string) => { - navigateCallback({ filter: query }); + (filter: string, includedPolicies: string) => { + navigateCallback({ + filter, + included_policies: includedPolicies, + }); }, [navigateCallback] ); @@ -209,6 +218,9 @@ export const HostIsolationExceptionsList = () => { = ({ setIsIsolateActionSuccessBannerVisible(true); // If a case details refresh ref is defined, then refresh actions and comments if (caseDetailsRefresh) { - caseDetailsRefresh.refreshUserActionsAndComments(); + caseDetailsRefresh.refreshCase(); } }, [caseDetailsRefresh]); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index e59af74d9a4786..b91c4ac59e3039 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -93,7 +93,6 @@ export const TimelinesPageComponent: React.FC = () => { ) : ( - )} 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 5d6dbeca3e0c65..811934299bca4b 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 @@ -36,6 +36,7 @@ import { } from './errors'; import { EndpointFleetServicesFactory, + EndpointInternalFleetServicesInterface, EndpointScopedFleetServicesInterface, } from './services/endpoint_fleet_services'; @@ -140,6 +141,14 @@ export class EndpointAppContextService { return this.fleetServicesFactory.asScoped(req); } + public getInternalFleetServices(): EndpointInternalFleetServicesInterface { + if (this.fleetServicesFactory === null) { + throw new EndpointAppContentServicesNotStartedError(); + } + + return this.fleetServicesFactory.asInternalUser(); + } + /** @deprecated use `getScopedFleetServices()` instead */ public getAgentService(): AgentService | undefined { return this.startDependencies?.agentService; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts index cb5418030c9e03..53afaadc231428 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts @@ -61,6 +61,10 @@ export function registerPolicyRoutes(router: IRouter, endpointAppContext: Endpoi validate: GetEndpointPackagePolicyRequestSchema, options: { authRequired: true }, }, - getPolicyListHandler(endpointAppContext) + withEndpointAuthz( + { all: ['canAccessEndpointManagement'] }, + logger, + getPolicyListHandler(endpointAppContext) + ) ); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts index 199ef75e22f25b..859f84241ae383 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -18,6 +18,7 @@ import { findRules } from '../../rules/find_rules'; import { buildSiemResponse } from '../utils'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; import { transformFindAlerts } from './utils'; +import { getCurrentRuleStatuses } from './utils/get_current_rule_statuses'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../../rule_actions/legacy_get_bulk_rule_actions_saved_object'; @@ -66,14 +67,12 @@ export const findRulesRoute = ( filter: query.filter, fields: query.fields, }); - const alertIds = rules.data.map((rule) => rule.id); + const ruleIds = rules.data.map((rule) => rule.id); + const spaceId = context.securitySolution.getSpaceId(); const [currentStatusesByRuleId, ruleActions] = await Promise.all([ - execLogClient.getCurrentStatusBulk({ - ruleIds: alertIds, - spaceId: context.securitySolution.getSpaceId(), - }), - legacyGetBulkRuleActionsSavedObject({ alertIds, savedObjectsClient, logger }), + getCurrentRuleStatuses({ ruleIds, execLogClient, spaceId, logger }), + legacyGetBulkRuleActionsSavedObject({ alertIds: ruleIds, savedObjectsClient, logger }), ]); const transformed = transformFindAlerts(rules, currentStatusesByRuleId, ruleActions); if (transformed == null) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/get_current_rule_statuses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/get_current_rule_statuses.ts new file mode 100644 index 00000000000000..4622805e11db66 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/get_current_rule_statuses.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { chunk } from 'lodash'; +import { Logger } from 'src/core/server'; +import { initPromisePool } from '../../../../../utils/promise_pool'; +import { GetCurrentStatusBulkResult, IRuleExecutionLogClient } from '../../../rule_execution_log'; + +const RULES_PER_CHUNK = 1000; + +interface GetCurrentRuleStatusesArgs { + ruleIds: string[]; + execLogClient: IRuleExecutionLogClient; + spaceId: string; + logger: Logger; +} + +/** + * Get the most recent execution status for each of the given rule IDs. + * This method splits work into chunks so not to owerwhelm Elasticsearch + * when fetching statuses for a big number of rules. + * + * @param ruleIds Rule IDs to fetch statuses for + * @param execLogClient RuleExecutionLogClient + * @param spaceId Current Space ID + * @param logger Logger + * @returns A dict with rule IDs as keys and rule statuses as values + * + * @throws AggregateError if any of the rule status requests fail + */ +export async function getCurrentRuleStatuses({ + ruleIds, + execLogClient, + spaceId, + logger, +}: GetCurrentRuleStatusesArgs): Promise { + const { results, errors } = await initPromisePool({ + concurrency: 1, + items: chunk(ruleIds, RULES_PER_CHUNK), + executor: (ruleIdsChunk) => + execLogClient + .getCurrentStatusBulk({ + ruleIds: ruleIdsChunk, + spaceId, + }) + .catch((error) => { + logger.error( + `Error fetching rule status: ${error instanceof Error ? error.message : String(error)}` + ); + throw error; + }), + }); + + if (errors.length) { + throw new AggregateError(errors, 'Error fetching rule statuses'); + } + + // Merge all rule statuses into a single dict + return Object.assign({}, ...results); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions/legacy_get_bulk_rule_actions_saved_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions/legacy_get_bulk_rule_actions_saved_object.ts index b0c5dba77ad748..72cfada909cdce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions/legacy_get_bulk_rule_actions_saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions/legacy_get_bulk_rule_actions_saved_object.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { chunk } from 'lodash'; import { SavedObjectsFindOptionsReference } from 'kibana/server'; import { Logger } from 'src/core/server'; @@ -17,6 +18,7 @@ import { LegacyIRuleActionsAttributesSavedObjectAttributes } from './legacy_type import { legacyGetRuleActionsFromSavedObject } from './legacy_utils'; // eslint-disable-next-line no-restricted-imports import { LegacyRulesActionsSavedObject } from './legacy_get_rule_actions_saved_object'; +import { initPromisePool } from '../../../utils/promise_pool'; /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function @@ -39,15 +41,29 @@ export const legacyGetBulkRuleActionsSavedObject = async ({ id: alertId, type: 'alert', })); - const { - // eslint-disable-next-line @typescript-eslint/naming-convention - saved_objects, - } = await savedObjectsClient.find({ - type: legacyRuleActionsSavedObjectType, - perPage: 10000, - hasReference: references, + const { results, errors } = await initPromisePool({ + concurrency: 1, + items: chunk(references, 1000), + executor: (referencesChunk) => + savedObjectsClient + .find({ + type: legacyRuleActionsSavedObjectType, + perPage: 10000, + hasReference: referencesChunk, + }) + .catch((error) => { + logger.error( + `Error fetching rule actions: ${error instanceof Error ? error.message : String(error)}` + ); + throw error; + }), }); - return saved_objects.reduce( + if (errors.length) { + throw new AggregateError(errors, 'Error fetching rule actions'); + } + + const savedObjects = results.flatMap((result) => result.saved_objects); + return savedObjects.reduce( (acc: { [key: string]: LegacyRulesActionsSavedObject }, savedObject) => { const ruleAlertId = savedObject.references.find((reference) => { // Find the first rule alert and assume that is the one we want since we should only ever have 1. diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index 8bbd30a4a6a1d7..28f114b1cccf7e 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -188,11 +188,11 @@ export const getHostEndpoint = async ( return null; } - const { esClient, endpointContext, request } = deps; + const { esClient, endpointContext } = deps; const logger = endpointContext.logFactory.get('metadata'); try { - const fleetServices = endpointContext.service.getScopedFleetServices(request); + const fleetServices = endpointContext.service.getInternalFleetServices(); const endpointMetadataService = endpointContext.service.getEndpointMetadataService(); const endpointData = await endpointMetadataService diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts new file mode 100644 index 00000000000000..3a2e7ad160bd2a --- /dev/null +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { initPromisePool } from './promise_pool'; + +const nextTick = () => new Promise((resolve) => setImmediate(resolve)); + +const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { + const asyncTasks: Record< + number, + { + status: 'pending' | 'resolved' | 'rejected'; + resolve: () => void; + reject: () => void; + } + > = {}; + + const promisePool = initPromisePool({ + concurrency, + items, + executor: async (x) => + new Promise((resolve, reject) => { + asyncTasks[x] = { + status: 'pending', + resolve: () => { + asyncTasks[x].status = 'resolved'; + resolve(x); + }, + reject: () => { + asyncTasks[x].status = 'rejected'; + reject(new Error(`Error processing ${x}`)); + }, + }; + }), + }); + + return [promisePool, asyncTasks] as const; +}; + +describe('initPromisePool', () => { + it('should execute async tasks', async () => { + const { results, errors } = await initPromisePool({ + concurrency: 1, + items: [1, 2, 3], + executor: async (x) => x, + }); + + expect(results).toEqual([1, 2, 3]); + expect(errors).toEqual([]); + }); + + it('should capture any errors that occur during tasks execution', async () => { + const { results, errors } = await initPromisePool({ + concurrency: 1, + items: [1, 2, 3], + executor: async (x) => { + throw new Error(`Error processing ${x}`); + }, + }); + + expect(results).toEqual([]); + expect(errors).toEqual([ + new Error(`Error processing 1`), + new Error(`Error processing 2`), + new Error(`Error processing 3`), + ]); + }); + + it('should respect concurrency', async () => { + const [promisePool, asyncTasks] = initPoolWithTasks({ + concurrency: 1, + items: [1, 2, 3], + }); + + // Check that we have only one task pending initially as concurrency = 1 + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[1].resolve(); + await nextTick(); + + // Check that after resolving the first task, the second is pending + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[2].reject(); + await nextTick(); + + // Check that after rejecting the second task, the third is pending + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'rejected' }), + 3: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[3].resolve(); + await nextTick(); + + // Check that all taks have been settled + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'rejected' }), + 3: expect.objectContaining({ status: 'resolved' }), + }); + + const { results, errors } = await promisePool; + + // Check final reesuts + expect(results).toEqual([1, 3]); + expect(errors).toEqual([new Error(`Error processing 2`)]); + }); + + it('should be possible to configure concurrency', async () => { + const [promisePool, asyncTasks] = initPoolWithTasks({ + concurrency: 2, + items: [1, 2, 3, 4, 5], + }); + + // Check that we have only two tasks pending initially as concurrency = 2 + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'pending' }), + 2: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[1].resolve(); + await nextTick(); + + // Check that after resolving the first task, the second and the third is pending + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'pending' }), + 3: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[2].reject(); + asyncTasks[3].reject(); + await nextTick(); + + // Check that after rejecting the second and the third tasks, the rest are pending + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'rejected' }), + 3: expect.objectContaining({ status: 'rejected' }), + 4: expect.objectContaining({ status: 'pending' }), + 5: expect.objectContaining({ status: 'pending' }), + }); + + asyncTasks[4].resolve(); + asyncTasks[5].resolve(); + await nextTick(); + + // Check that all taks have been settled + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + 2: expect.objectContaining({ status: 'rejected' }), + 3: expect.objectContaining({ status: 'rejected' }), + 4: expect.objectContaining({ status: 'resolved' }), + 5: expect.objectContaining({ status: 'resolved' }), + }); + + const { results, errors } = await promisePool; + + // Check final reesuts + expect(results).toEqual([1, 4, 5]); + expect(errors).toEqual([new Error(`Error processing 2`), new Error(`Error processing 3`)]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.ts new file mode 100644 index 00000000000000..d0c848bc117872 --- /dev/null +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface PromisePoolArgs { + concurrency?: number; + items: Item[]; + executor: (item: Item) => Promise; +} + +/** + * Runs promises in batches. It ensures that the number of running async tasks + * doesn't exceed the concurrency parameter passed to the function. + * + * @param concurrency - number of tasks run in parallel + * @param items - array of items to be passes to async executor + * @param executor - an async function to be called with each provided item + * + * @returns Struct holding results or errors of async tasks + */ +export const initPromisePool = async ({ + concurrency = 1, + items, + executor, +}: PromisePoolArgs) => { + const tasks: Array> = []; + const results: Result[] = []; + const errors: unknown[] = []; + + for (const item of items) { + // Check if the pool is full + if (tasks.length >= concurrency) { + // Wait for any first task to finish + await Promise.race(tasks); + } + + const task: Promise = executor(item) + .then((result) => { + results.push(result); + }) + .catch(async (error) => { + errors.push(error); + }) + .finally(() => { + tasks.splice(tasks.indexOf(task), 1); + }); + + tasks.push(task); + } + + // Wait for all remaining tasks to finish + await Promise.all(tasks); + + return { results, errors }; +}; diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts index 2d1fea23c5ef3d..39ffc566a06b00 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts @@ -119,6 +119,7 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { usageCollection, soClient, kibanaRequest: undefined, + refreshCache: false, }, context ); @@ -141,6 +142,7 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { usageCollection, soClient, kibanaRequest: undefined, + refreshCache: false, }, context ); @@ -168,6 +170,7 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { usageCollection, soClient, kibanaRequest: undefined, + refreshCache: false, }, context ); diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx index f634e460844e83..6648bedd390a4c 100644 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx +++ b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx @@ -7,7 +7,7 @@ import React, { memo, useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { CaseStatuses, StatusAll, CasesContextValue } from '../../../../../../cases/common'; +import { CaseStatuses, StatusAll, CasesFeatures } from '../../../../../../cases/common'; import { TimelineItem } from '../../../../../common/search_strategy'; import { useAddToCase, normalizedEventFields } from '../../../../hooks/use_add_to_case'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -24,7 +24,7 @@ export interface AddToCaseActionProps { appId: string; owner: string; onClose?: Function; - casesFeatures?: CasesContextValue['features']; + casesFeatures?: CasesFeatures; } const AddToCaseActionComponent: React.FC = ({ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6676f3c4e8cd1a..f5262b244266ca 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15074,7 +15074,6 @@ "xpack.maps.layers.newVectorLayerWizard.indexPrivsErrorTitle": "インデックス権限がありません", "xpack.maps.layerSettings.attributionLegend": "属性", "xpack.maps.layerTocActions.cloneLayerTitle": "レイヤーおクローンを作成", - "xpack.maps.layerTocActions.editLayerTooltip": "クラスタリング、結合、または時間フィルタリングなしで、ドキュメントレイヤーでのサポートされている機能を編集", "xpack.maps.layerTocActions.fitToDataTitle": "データに合わせる", "xpack.maps.layerTocActions.hideLayerTitle": "レイヤーの非表示", "xpack.maps.layerTocActions.layerActionsTitle": "レイヤー操作", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a4ac5566ce5a32..3ade915db4eae4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15272,7 +15272,6 @@ "xpack.maps.layers.newVectorLayerWizard.indexPrivsErrorTitle": "缺少索引权限", "xpack.maps.layerSettings.attributionLegend": "归因", "xpack.maps.layerTocActions.cloneLayerTitle": "克隆图层", - "xpack.maps.layerTocActions.editLayerTooltip": "编辑在没有集群、联接或时间筛选的情况下文档图层仅支持的特征", "xpack.maps.layerTocActions.fitToDataTitle": "适应数据", "xpack.maps.layerTocActions.hideLayerTitle": "隐藏图层", "xpack.maps.layerTocActions.layerActionsTitle": "图层操作", diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry.ts b/x-pack/test/api_integration/apis/telemetry/telemetry.ts index 527d755123f264..fb0d56f7d7532a 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry.ts +++ b/x-pack/test/api_integration/apis/telemetry/telemetry.ts @@ -20,6 +20,22 @@ import ossPluginsTelemetrySchema from '../../../../../src/plugins/telemetry/sche import xpackPluginsTelemetrySchema from '../../../../plugins/telemetry_collection_xpack/schema/xpack_plugins.json'; import { assertTelemetryPayload } from '../../../../../test/api_integration/apis/telemetry/utils'; import type { UnencryptedTelemetryPayload } from '../../../../../src/plugins/telemetry/common/types'; +import type { + UsageStatsPayload, + CacheDetails, +} from '../../../../../src/plugins/telemetry_collection_manager/server/types'; + +function omitCacheDetails(usagePayload: Array>) { + return usagePayload.map(({ cacheDetails, ...item }) => item); +} + +function updateFixtureTimestamps(fixture: Array>, timestamp: string) { + return fixture.map((item) => ({ ...item, timestamp })); +} + +function getCacheDetails(body: UnencryptedTelemetryPayload): CacheDetails[] { + return body.map(({ stats }) => (stats as UsageStatsPayload).cacheDetails); +} /** * Update the .monitoring-* documents loaded via the archiver to the recent `timestamp` @@ -96,7 +112,7 @@ export default function ({ getService }: FtrProviderContext) { const { body }: { body: UnencryptedTelemetryPayload } = await supertest .post('/api/telemetry/v2/clusters/_stats') .set('kbn-xsrf', 'xxx') - .send({ unencrypted: true }) + .send({ unencrypted: true, refreshCache: true }) .expect(200); expect(body.length).to.be.greaterThan(1); @@ -116,6 +132,7 @@ export default function ({ getService }: FtrProviderContext) { monitoringRootTelemetrySchema.properties.monitoringTelemetry.properties.stats.items ); const plugins = deepmerge(ossPluginsTelemetrySchema, xpackPluginsTelemetrySchema); + try { assertTelemetryPayload({ root, plugins }, localXPack); monitoring.forEach((stats) => { @@ -130,7 +147,10 @@ export default function ({ getService }: FtrProviderContext) { it('should load multiple trial-license clusters', async () => { expect(monitoring).length(3); expect(localXPack.collectionSource).to.eql('local_xpack'); - expect(monitoring).to.eql(multiClusterFixture.map((item) => ({ ...item, timestamp }))); + + expect(omitCacheDetails(monitoring)).to.eql( + updateFixtureTimestamps(multiClusterFixture, timestamp) + ); }); }); @@ -147,15 +167,76 @@ export default function ({ getService }: FtrProviderContext) { const { body }: { body: UnencryptedTelemetryPayload } = await supertest .post('/api/telemetry/v2/clusters/_stats') .set('kbn-xsrf', 'xxx') - .send({ unencrypted: true }) + .send({ unencrypted: true, refreshCache: true }) .expect(200); expect(body).length(2); const telemetryStats = body.map(({ stats }) => stats); - const [localXPack, ...monitoring] = telemetryStats; - expect((localXPack as Record).collectionSource).to.eql('local_xpack'); - expect(monitoring).to.eql(basicClusterFixture.map((item) => ({ ...item, timestamp }))); + const [localXPack, ...monitoring] = telemetryStats as Array>; + expect(localXPack.collectionSource).to.eql('local_xpack'); + expect(omitCacheDetails(monitoring)).to.eql( + updateFixtureTimestamps(basicClusterFixture, timestamp) + ); + }); + }); + + describe('Telemetry caching', () => { + const archive = 'x-pack/test/functional/es_archives/monitoring/basic_6.3.x'; + const fromTimestamp = '2018-07-23T22:54:59.087Z'; + const toTimestamp = '2018-07-23T22:55:05.933Z'; + let cacheLastUpdated: string[] = []; + + before(async () => { + await esArchiver.load(archive); + await updateMonitoringDates(esSupertest, fromTimestamp, toTimestamp, timestamp); + + // hit the endpoint to cache results + const { body }: { body: UnencryptedTelemetryPayload } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ unencrypted: true, refreshCache: true }) + .expect(200); + + cacheLastUpdated = getCacheDetails(body).map(({ updatedAt }) => updatedAt); + }); + after(() => esArchiver.unload(archive)); + + it('returns cached results by default', async () => { + const now = Date.now(); + const { body }: { body: UnencryptedTelemetryPayload } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ unencrypted: true }) + .expect(200); + + expect(body).length(2); + + const cacheDetails = getCacheDetails(body); + // Check that the fetched payload is actually cached by comparing cache and updatedAt timestamps + expect(cacheDetails.map(({ updatedAt }) => updatedAt)).to.eql(cacheLastUpdated); + // Check that the fetchedAt timestamp is updated when the data is fethed + cacheDetails.forEach(({ fetchedAt }) => { + expect(new Date(fetchedAt).getTime()).to.be.greaterThan(now); + }); + }); + }); + + it('grabs a fresh copy on refresh', async () => { + const now = Date.now(); + const { body }: { body: UnencryptedTelemetryPayload } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ unencrypted: true, refreshCache: true }) + .expect(200); + + expect(body).length(1); + getCacheDetails(body).forEach(({ updatedAt, fetchedAt }) => { + // Check that the cache is fresh by comparing updatedAt timestamp with + // the timestamp the data was fetched. + expect(new Date(updatedAt).getTime()).to.be.greaterThan(now); + // Check that the fetchedAt timestamp is updated when the data is fethed + expect(new Date(fetchedAt).getTime()).to.be.greaterThan(now); }); }); }); diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry_local.ts b/x-pack/test/api_integration/apis/telemetry/telemetry_local.ts index e34e0fff25888d..630fdef6a8d1f5 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/x-pack/test/api_integration/apis/telemetry/telemetry_local.ts @@ -43,7 +43,7 @@ export default function ({ getService }: FtrProviderContext) { const { body } = await supertest .post('/api/telemetry/v2/clusters/_stats') .set('kbn-xsrf', 'xxx') - .send({ unencrypted: true }) + .send({ unencrypted: true, refreshCache: true }) .expect(200); expect(body.length).to.be(1); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics.ts index 130629de8b91f2..69b0b705129876 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { getPostCaseRequest, postCommentAlertReq } from '../../../..//common/lib/mock'; +import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -89,7 +89,7 @@ export default ({ getService }: FtrProviderContext): void => { const metrics = await getCaseMetrics({ supertest, caseId: theCase.id, - features: ['alertsCount'], + features: ['alerts.count'], }); expect(metrics).to.eql({ @@ -116,7 +116,7 @@ export default ({ getService }: FtrProviderContext): void => { const metrics = await getCaseMetrics({ supertest, caseId: theCase.id, - features: ['alertsCount'], + features: ['alerts.count'], }); expect(metrics).to.eql({ diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 858c4901b2dd3e..e13174d66fb1df 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -1930,7 +1930,7 @@ export const getStats = async ( const response = await supertest .post(getStatsUrl()) .set('kbn-xsrf', 'true') - .send({ unencrypted: true }); + .send({ unencrypted: true, refreshCache: true }); if (response.status !== 200) { log.error( `Did not get an expected 200 "ok" when getting the stats for detections. CI issues could happen. Suspect this line if you are seeing CI issues. body: ${JSON.stringify( diff --git a/x-pack/test/fleet_api_integration/apis/agents/list.ts b/x-pack/test/fleet_api_integration/apis/agents/list.ts index ef1efcf1a46565..0b3d8fd4e9a3ce 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/list.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/list.ts @@ -23,12 +23,14 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); }); - it('should return a 403 if a user without the superuser role try to access the APU', async () => { - await supertestWithoutAuth + // Only superuser can access Fleet for now. + it.skip('should return a 200 if a user with the fleet all try to access the list', async () => { + await supertest .get(`/api/fleet/agents`) .auth(testUsers.fleet_all.username, testUsers.fleet_all.password) - .expect(403); + .expect(200); }); + it('should not return the list of agents when requesting as a user without fleet permissions', async () => { await supertestWithoutAuth .get(`/api/fleet/agents`) @@ -36,12 +38,13 @@ export default function ({ getService }: FtrProviderContext) { .expect(403); }); - it('should return the list of agents when requesting as a user with fleet write permissions', async () => { + it('should return the list of agents when requesting as a superuser', async () => { const { body: apiResponse } = await supertest.get(`/api/fleet/agents`).expect(200); expect(apiResponse).to.have.keys('page', 'total', 'items', 'list'); expect(apiResponse.total).to.eql(4); }); + it('should return the list of agents when requesting as a user with fleet read permissions', async () => { const { body: apiResponse } = await supertest.get(`/api/fleet/agents`).expect(200); expect(apiResponse).to.have.keys('page', 'total', 'items', 'list'); diff --git a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts index 0d8f38c55c7f87..4d06de83f69175 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts @@ -113,6 +113,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .send({ unencrypted: true, + refreshCache: true, }) .expect(200); diff --git a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts index a392df276a34ca..ba65cd8d624651 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts @@ -60,7 +60,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should render the "Data" section with ILM', async () => { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - expect(sections).to.have.length(1); + // Changed sections to have a length of 2 because of + // https://github.com/elastic/kibana/pull/121262 + expect(sections).to.have.length(2); expect(sections[0]).to.eql({ sectionId: 'data', sectionLinks: ['index_lifecycle_management'], diff --git a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts index 95ddd0a7b59448..d13aeab4d2334e 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts @@ -16,9 +16,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const log = getService('log'); const retry = getService('retry'); const esClient = getService('es'); + const security = getService('security'); describe('Home page', function () { before(async () => { + await security.testUser.setRoles(['manage_ilm'], true); await esClient.snapshot.createRepository({ name: repoName, body: { @@ -30,11 +32,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }, verify: false, }); + await pageObjects.common.navigateToApp('indexLifecycleManagement'); }); after(async () => { await esClient.snapshot.deleteRepository({ name: repoName }); await esClient.ilm.deleteLifecycle({ name: policyName }); + await security.testUser.restoreDefaults(); }); it('Loads the app', async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index de0c5dbd3699f6..6812fc97181e61 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -516,6 +516,14 @@ export default async function ({ readConfigFile }) { elasticsearch: { cluster: ['manage_ilm'], }, + kibana: [ + { + feature: { + advancedSettings: ['read'], + }, + spaces: ['default'], + }, + ], }, index_management_user: { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts index 672c9a7c78d27a..b27dc32725f035 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts @@ -62,8 +62,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.existOrFail('noIngestPermissions'); }); - // FIXME:PT skipped. need to fix security-team bug #1929 - it.skip('should display endpoint data on Host Details', async () => { + it('should display endpoint data on Host Details', async () => { const endpoint = indexedData.hosts[0]; await PageObjects.hosts.navigateToHostDetails(endpoint.host.name); const endpointSummary = await PageObjects.hosts.hostDetailsEndpointOverviewData(); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts index 1b9ce8911c5bfa..a2b03198234a6d 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts @@ -62,6 +62,11 @@ export default function ({ getService }: FtrProviderContext) { path: '/api/endpoint/policy_response?agentId=1', body: undefined, }, + { + method: 'get', + path: '/api/endpoint/policy', + body: undefined, + }, { method: 'post', path: '/api/endpoint/isolate',