From 4920ace1d5a86a34e248320ac4f5e9233f6a2a2b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 18 Mar 2022 16:11:11 -0400 Subject: [PATCH 01/38] Add enhancements for legacy URL aliases (#125960) --- docs/api/saved-objects/bulk_resolve.asciidoc | 25 +- docs/api/saved-objects/resolve.asciidoc | 25 +- .../advanced/sharing-saved-objects.asciidoc | 15 +- ...resolvedsimplesavedobject.alias_purpose.md | 17 + ...solvedsimplesavedobject.alias_target_id.md | 4 +- ...n-core-public.resolvedsimplesavedobject.md | 3 +- ...vedobjectsresolveresponse.alias_purpose.md | 17 + ...dobjectsresolveresponse.alias_target_id.md | 4 +- ...core-public.savedobjectsresolveresponse.md | 3 +- ...vedobjectsresolveresponse.alias_purpose.md | 17 + ...dobjectsresolveresponse.alias_target_id.md | 4 +- ...core-server.savedobjectsresolveresponse.md | 3 +- ...sserializer.generaterawlegacyurlaliasid.md | 4 +- src/core/public/public.api.md | 2 + .../saved_objects/saved_objects_client.ts | 1 + src/core/public/saved_objects/types.ts | 13 +- .../migrations/core/document_migrator.test.ts | 22 +- .../migrations/core/document_migrator.ts | 28 +- .../integration_tests/rewriting_id.test.ts | 6 +- .../object_types/registration.ts | 13 + .../saved_objects/object_types/types.ts | 7 + .../saved_objects_service.test.mocks.ts | 4 + .../saved_objects/serialization/serializer.ts | 6 +- .../service/lib/internal_bulk_resolve.test.ts | 308 ++++++++++-------- .../service/lib/internal_bulk_resolve.ts | 65 ++-- .../delete_legacy_url_aliases.test.ts | 39 +-- .../delete_legacy_url_aliases.ts | 10 +- .../service/saved_objects_client.ts | 13 +- src/core/server/server.api.md | 3 +- .../hooks/use_dashboard_app_state.ts | 3 +- .../saved_dashboards/saved_dashboard.ts | 13 +- .../saved_searches/get_saved_searches.test.ts | 1 + .../saved_searches/get_saved_searches.ts | 1 + .../saved_search_alias_match_redirect.test.ts | 11 +- .../saved_search_alias_match_redirect.ts | 14 +- .../public/services/saved_searches/types.ts | 6 +- src/plugins/visualizations/public/types.ts | 7 +- .../public/utils/saved_visualize_utils.ts | 2 + .../visualize_editor_common.test.tsx | 10 +- .../components/visualize_editor_common.tsx | 11 +- .../routes/workpad/hooks/use_workpad.test.tsx | 7 +- .../routes/workpad/hooks/use_workpad.ts | 26 +- .../canvas/public/services/kibana/workpad.ts | 5 +- .../plugins/canvas/public/services/workpad.ts | 1 + .../canvas/server/routes/workpad/resolve.ts | 3 + x-pack/plugins/cases/common/api/cases/case.ts | 1 + x-pack/plugins/cases/common/ui/types.ts | 6 +- .../components/case_view/index.test.tsx | 12 +- .../public/components/case_view/index.tsx | 24 +- .../cases/public/containers/use_get_case.tsx | 4 +- .../public/helpers/saved_workspace_utils.ts | 1 + .../helpers/use_workspace_loader.test.tsx | 10 +- .../public/helpers/use_workspace_loader.ts | 19 +- .../lens/public/lens_attribute_service.ts | 2 + .../init_middleware/load_initial.ts | 13 +- .../state_management/load_initial.test.tsx | 10 +- x-pack/plugins/lens/public/types.ts | 7 +- .../maps/public/map_attribute_service.ts | 8 +- .../routes/map_page/map_app/map_app.tsx | 8 +- .../schemas/common/schemas.ts | 6 + .../schemas/request/rule_schemas.ts | 2 + .../schemas/response/rules_schema.ts | 2 + .../common/types/timeline/index.ts | 9 +- .../detection_alerts/acknowledged.spec.ts | 3 +- .../common/hooks/use_resolve_redirect.test.ts | 20 +- .../common/hooks/use_resolve_redirect.ts | 11 +- .../detection_engine/rules/types.ts | 5 +- .../rules/details/index.test.tsx | 22 +- .../detection_engine/rules/details/index.tsx | 13 +- .../components/open_timeline/helpers.ts | 1 + .../components/open_timeline/types.ts | 1 + .../schemas/rule_converters.ts | 1 + x-pack/plugins/spaces/public/index.ts | 6 +- .../spaces/public/legacy_urls/index.ts | 6 +- .../legacy_urls/redirect_legacy_url.test.ts | 16 +- .../public/legacy_urls/redirect_legacy_url.ts | 26 +- .../spaces/public/legacy_urls/types.ts | 24 ++ x-pack/plugins/spaces/public/ui_api/types.ts | 12 +- .../lib/rule_api/common_transformations.ts | 3 + .../lib/rule_api/resolve_rule.test.ts | 2 + .../components/rule_details_route.test.tsx | 10 +- .../components/rule_details_route.tsx | 15 +- .../tests/resolve_read_rules.ts | 1 + .../apps/canvas/saved_object_resolve.ts | 4 + .../saved_objects/spaces/data.json | 55 ++++ .../common/suites/bulk_create.ts | 6 +- .../common/suites/delete.ts | 8 +- .../common/suites/resolve.ts | 2 +- .../security_and_spaces/apis/bulk_create.ts | 10 +- .../security_and_spaces/apis/bulk_resolve.ts | 8 +- .../security_and_spaces/apis/create.ts | 14 +- .../security_and_spaces/apis/resolve.ts | 8 +- .../security_and_spaces/apis/update.ts | 2 +- .../spaces_only/apis/bulk_create.ts | 8 +- .../spaces_only/apis/bulk_resolve.ts | 8 +- .../spaces_only/apis/create.ts | 4 +- .../spaces_only/apis/resolve.ts | 8 +- .../spaces_only/apis/update.ts | 2 +- .../saved_objects/spaces/data.json | 44 +-- .../common/suites/delete.ts | 10 +- .../suites/disable_legacy_url_aliases.ts | 33 +- .../common/suites/get_shareable_references.ts | 2 +- .../apis/disable_legacy_url_aliases.ts | 35 +- .../apis/disable_legacy_url_aliases.ts | 16 +- .../spaces_only/apis/update_objects_spaces.ts | 4 +- 105 files changed, 936 insertions(+), 499 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md diff --git a/docs/api/saved-objects/bulk_resolve.asciidoc b/docs/api/saved-objects/bulk_resolve.asciidoc index e8b947638abeb..0779b30a308ea 100644 --- a/docs/api/saved-objects/bulk_resolve.asciidoc +++ b/docs/api/saved-objects/bulk_resolve.asciidoc @@ -104,13 +104,24 @@ The API returns the following: Only the index pattern exists, the dashboard was not found. -The `outcome` field may be any of the following: - -* `"exactMatch"` -- One document exactly matched the given ID, *or* {kib} failed to find this object. -* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. -* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. - -If the outcome is `"aliasMatch"` or `"conflict"`, the response will also include an `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID. +[NOTE] +==== +In addition to `saved_object`, several fields can be returned: + +* `outcome` (required string) -- One of the following values: + - `"exactMatch"` -- One document exactly matched the given ID. + - `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than + the given ID. + - `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the + `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. +* `alias_target_id` (optional string) -- If the `outcome` is `"aliasMatch"` or `"conflict"`, the response will also include the + `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID. +* `alias_purpose` (optional string) -- If the `outcome` is `"aliasMatch"` or `"conflict"`, the response will also include the + `alias_purpose` field. This indicates why the alias was created, and it can be used to change the client behavior accordingly. One of the + following values: `"savedObjectConversion"`, `"savedObjectImport"` + +Client-side code uses these fields to behave differently depending on the `outcome` -- <>. +==== Retrieve a dashboard object in the `testspace` by ID: diff --git a/docs/api/saved-objects/resolve.asciidoc b/docs/api/saved-objects/resolve.asciidoc index 9136e7f3e1398..785ae54b4134a 100644 --- a/docs/api/saved-objects/resolve.asciidoc +++ b/docs/api/saved-objects/resolve.asciidoc @@ -64,13 +64,24 @@ The API returns the following: } -------------------------------------------------- -The `outcome` field may be any of the following: - -* `"exactMatch"` -- One document exactly matched the given ID. -* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. -* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. - -If the outcome is `"aliasMatch"` or `"conflict"`, the response will also include an `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID. +[NOTE] +==== +In addition to `saved_object`, several fields can be returned: + +* `outcome` (required string) -- One of the following values: + - `"exactMatch"` -- One document exactly matched the given ID. + - `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than + the given ID. + - `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the + `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. +* `alias_target_id` (optional string) -- If the `outcome` is `"aliasMatch"` or `"conflict"`, the response will also include the + `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID. +* `alias_purpose` (optional string) -- If the `outcome` is `"aliasMatch"` or `"conflict"`, the response will also include the + `alias_purpose` field. This indicates why the alias was created, and it can be used to change the client behavior accordingly. One of the + following values: `"savedObjectConversion"`, `"savedObjectImport"` + +Client-side code uses these fields to behave differently depending on the `outcome` -- <>. +==== Retrieve a dashboard object in the `testspace` by ID: diff --git a/docs/developer/advanced/sharing-saved-objects.asciidoc b/docs/developer/advanced/sharing-saved-objects.asciidoc index beff7cc007b6d..5dd93adf19123 100644 --- a/docs/developer/advanced/sharing-saved-objects.asciidoc +++ b/docs/developer/advanced/sharing-saved-objects.asciidoc @@ -158,12 +158,13 @@ TIP: See an example of this in https://github.com/elastic/kibana/pull/107256#use The https://github.com/elastic/kibana/blob/main/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md[SavedObjectsResolveResponse -interface] has three fields, summarized below: +interface] has four fields, summarized below: * `saved_object` - The saved object that was found. * `outcome` - One of the following values: `'exactMatch' | 'aliasMatch' | 'conflict'` * `alias_target_id` - This is defined if the outcome is `'aliasMatch'` or `'conflict'`. It means that a legacy URL alias with this ID points to an object with a _different_ ID. +* `alias_purpose` - This is defined if the outcome is `'aliasMatch'` or `'conflict'`. It describes why the legacy URL alis was created. The SavedObjectsClient is available both on the server-side and the client-side. You may be fetching the object on the server-side via a custom HTTP route, or you may be fetching it on the client-side directly. Either way, the `outcome` and `alias_target_id` fields need to be @@ -236,12 +237,18 @@ if (spacesApi && resolveResult.outcome === 'aliasMatch') { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash const newObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'aliasMatch' const newPath = `/this/page/${newObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) - await spacesApi.ui.redirectLegacyUrl(newPath, OBJECT_NOUN); + await spacesApi.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: resolveResult.alias_purpose, <1> + objectNoun: OBJECT_NOUN <2> + }); return; } ``` -_Note that `OBJECT_NOUN` is optional, it just changes "object" in the toast to whatever you specify -- you may want the toast to say -"dashboard" or "index pattern" instead!_ +<1> The `aliasPurpose` field is required as of 8.2, because the API response now includes the reason the alias was created to inform the + client whether a toast should be shown or not. +<2> The `objectNoun` field is optional, it just changes "object" in the toast to whatever you specify -- you may want the toast to say + "dashboard" or "index pattern" instead! 5. And finally, in your deep link page, add a function that will create a callout in the case of a `'conflict'` outcome: + diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md new file mode 100644 index 0000000000000..fb6f98eda62d1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [alias\_purpose](./kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md) + +## ResolvedSimpleSavedObject.alias\_purpose property + +The reason this alias was created. + +Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). + +Signature: + +```typescript +alias_purpose?: SavedObjectsResolveResponse['alias_purpose']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md index 0054f533a23d0..0e82842ef749d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md @@ -4,7 +4,9 @@ ## ResolvedSimpleSavedObject.alias\_target\_id property -The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. +The ID of the object that the legacy URL alias points to. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md index 2844bd97db7f2..7b35e7711e6c2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md @@ -16,7 +16,8 @@ export interface ResolvedSimpleSavedObject | Property | Type | Description | | --- | --- | --- | -| [alias\_target\_id?](./kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md) | SavedObjectsResolveResponse\['alias\_target\_id'\] | (Optional) The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [alias\_purpose?](./kibana-plugin-core-public.resolvedsimplesavedobject.alias_purpose.md) | SavedObjectsResolveResponse\['alias\_purpose'\] | (Optional) The reason this alias was created.Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | +| [alias\_target\_id?](./kibana-plugin-core-public.resolvedsimplesavedobject.alias_target_id.md) | SavedObjectsResolveResponse\['alias\_target\_id'\] | (Optional) The ID of the object that the legacy URL alias points to.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | | [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md) | SavedObjectsResolveResponse\['outcome'\] | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-public.resolvedsimplesavedobject.saved_object.md) | SimpleSavedObject<T> | The saved object that was found. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md new file mode 100644 index 0000000000000..5e56fe402b10b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [alias\_purpose](./kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md) + +## SavedObjectsResolveResponse.alias\_purpose property + +The reason this alias was created. + +Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). + +Signature: + +```typescript +alias_purpose?: 'savedObjectConversion' | 'savedObjectImport'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md index 07c55ae922363..534c5ffde730b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md @@ -4,7 +4,9 @@ ## SavedObjectsResolveResponse.alias\_target\_id property -The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. +The ID of the object that the legacy URL alias points to. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md index 6364493a9ef09..e212b1ea8b002 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md @@ -15,7 +15,8 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | -| [alias\_target\_id?](./kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md) | string | (Optional) The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [alias\_purpose?](./kibana-plugin-core-public.savedobjectsresolveresponse.alias_purpose.md) | 'savedObjectConversion' \| 'savedObjectImport' | (Optional) The reason this alias was created.Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | +| [alias\_target\_id?](./kibana-plugin-core-public.savedobjectsresolveresponse.alias_target_id.md) | string | (Optional) The ID of the object that the legacy URL alias points to.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | | [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md) | 'exactMatch' \| 'aliasMatch' \| 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md new file mode 100644 index 0000000000000..afad46f9a84cd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [alias\_purpose](./kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md) + +## SavedObjectsResolveResponse.alias\_purpose property + +The reason this alias was created. + +Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). + +Signature: + +```typescript +alias_purpose?: 'savedObjectConversion' | 'savedObjectImport'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md index 4e8bc5e787ede..52419918c0831 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md @@ -4,7 +4,9 @@ ## SavedObjectsResolveResponse.alias\_target\_id property -The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. +The ID of the object that the legacy URL alias points to. + +\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index 1eab71a7d7e75..0228c624f69d0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,7 +15,8 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | -| [alias\_target\_id?](./kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md) | string | (Optional) The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [alias\_purpose?](./kibana-plugin-core-server.savedobjectsresolveresponse.alias_purpose.md) | 'savedObjectConversion' \| 'savedObjectImport' | (Optional) The reason this alias was created.Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | +| [alias\_target\_id?](./kibana-plugin-core-server.savedobjectsresolveresponse.alias_target_id.md) | string | (Optional) The ID of the object that the legacy URL alias points to.\*\*Note:\*\* this field is \*only\* included when an alias was found (in other words, when the outcome is 'aliasMatch' or 'conflict'). | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' \| 'aliasMatch' \| 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md index a0465b96f05b5..0f7c3dc22faf1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md @@ -9,14 +9,14 @@ Given a saved object type and id, generates the compound id that is stored in th Signature: ```typescript -generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; +generateRawLegacyUrlAliasId(namespace: string | undefined, type: string, id: string): string; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| namespace | string | The namespace of the saved object | +| namespace | string \| undefined | The namespace of the saved object | | type | string | The saved object type | | id | string | The id of the saved object | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6145cce3912fd..4ce68770d7f18 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -962,6 +962,7 @@ export type ResolveDeprecationResponse = { // @public export interface ResolvedSimpleSavedObject { + alias_purpose?: SavedObjectsResolveResponse['alias_purpose']; alias_target_id?: SavedObjectsResolveResponse['alias_target_id']; outcome: SavedObjectsResolveResponse['outcome']; saved_object: SimpleSavedObject; @@ -1347,6 +1348,7 @@ export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolat // @public (undocumented) export interface SavedObjectsResolveResponse { + alias_purpose?: 'savedObjectConversion' | 'savedObjectImport'; alias_target_id?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; saved_object: SavedObject; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 8509ace047691..ec401e12fd8d8 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -612,6 +612,7 @@ export class SavedObjectsClient { saved_object: simpleSavedObject, outcome: resolveResponse.outcome, alias_target_id: resolveResponse.alias_target_id, + alias_purpose: resolveResponse.alias_purpose, }; } diff --git a/src/core/public/saved_objects/types.ts b/src/core/public/saved_objects/types.ts index 1251e75b5d6e2..45f07962986b8 100644 --- a/src/core/public/saved_objects/types.ts +++ b/src/core/public/saved_objects/types.ts @@ -31,7 +31,18 @@ export interface ResolvedSimpleSavedObject { */ outcome: SavedObjectsResolveResponse['outcome']; /** - * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + * The ID of the object that the legacy URL alias points to. + * + * **Note:** this field is *only* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). */ alias_target_id?: SavedObjectsResolveResponse['alias_target_id']; + /** + * The reason this alias was created. + * + * Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias + * was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + * + * **Note:** this field is *only* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). + */ + alias_purpose?: SavedObjectsResolveResponse['alias_purpose']; } diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index f92d505c058ed..dcdb4c77fc73b 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -33,6 +33,15 @@ const createRegistry = (...types: Array>) => { ...type, }) ); + registry.registerType({ + name: LEGACY_URL_ALIAS_TYPE, + namespaceType: 'agnostic', + hidden: false, + mappings: { properties: {} }, + migrations: { + '0.1.2': () => ({} as SavedObjectUnsanitizedDoc), // the migration version is non-existent and the result doesn't matter, this migration function is never applied, we just want to assert that aliases are marked as "up-to-date" + }, + }); return registry; }; @@ -783,6 +792,7 @@ describe('DocumentMigrator', () => { aaa: '10.4.0', bbb: '3.2.3', ccc: '11.0.0', + [LEGACY_URL_ALIAS_TYPE]: '0.1.2', }); }); @@ -948,8 +958,9 @@ describe('DocumentMigrator', () => { targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { [LEGACY_URL_ALIAS_TYPE]: '0.1.2' }, coreMigrationVersion: kibanaVersion, }, ]); @@ -1023,8 +1034,9 @@ describe('DocumentMigrator', () => { targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { [LEGACY_URL_ALIAS_TYPE]: '0.1.2' }, coreMigrationVersion: kibanaVersion, }, ]); @@ -1148,8 +1160,9 @@ describe('DocumentMigrator', () => { targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { [LEGACY_URL_ALIAS_TYPE]: '0.1.2' }, coreMigrationVersion: kibanaVersion, }, ]); @@ -1231,8 +1244,9 @@ describe('DocumentMigrator', () => { targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { [LEGACY_URL_ALIAS_TYPE]: '0.1.2' }, coreMigrationVersion: kibanaVersion, }, ]); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 5f2870fb6e244..a5c185044ac3d 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -549,19 +549,21 @@ function convertNamespaceType(doc: SavedObjectUnsanitizedDoc) { const { id: originId, type } = otherAttrs; const id = SavedObjectsUtils.getConvertedObjectId(namespace, type, originId!); - if (namespace !== undefined) { - const legacyUrlAlias: SavedObjectUnsanitizedDoc = { - id: `${namespace}:${type}:${originId}`, - type: LEGACY_URL_ALIAS_TYPE, - attributes: { - sourceId: originId, - targetNamespace: namespace, - targetType: type, - targetId: id, - }, - }; - additionalDocs.push(legacyUrlAlias); - } + const legacyUrlAlias: SavedObjectUnsanitizedDoc = { + id: `${namespace}:${type}:${originId}`, + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + // NOTE TO MAINTAINERS: If a saved object migration is added in `src/core/server/saved_objects/object_types/registration.ts`, these + // values must be updated accordingly. That's because a user can upgrade Kibana from 7.17 to 8.x, and any defined migrations will not + // be applied to aliases that are created during the conversion process. + sourceId: originId, + targetNamespace: namespace, + targetType: type, + targetId: id, + purpose: 'savedObjectConversion', + }, + }; + additionalDocs.push(legacyUrlAlias); return { transformedDoc: { ...otherAttrs, id, originId, namespaces: [namespace] }, additionalDocs, diff --git a/src/core/server/saved_objects/migrations/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrations/integration_tests/rewriting_id.test.ts index 82f7bc5de978e..5948425ed737c 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/rewriting_id.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/rewriting_id.test.ts @@ -200,8 +200,9 @@ describe('migration v2', () => { targetId: newFooId, targetNamespace: 'spacex', targetType: 'foo', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { 'legacy-url-alias': '8.2.0' }, references: [], coreMigrationVersion: pkg.version, }, @@ -233,8 +234,9 @@ describe('migration v2', () => { targetId: newBarId, targetNamespace: 'spacex', targetType: 'bar', + purpose: 'savedObjectConversion', }, - migrationVersion: {}, + migrationVersion: { 'legacy-url-alias': '8.2.0' }, references: [], coreMigrationVersion: pkg.version, }, diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index a199ce947f96b..cb27233d0b3ce 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -8,6 +8,7 @@ import { LEGACY_URL_ALIAS_TYPE } from './constants'; import { ISavedObjectTypeRegistry, SavedObjectsType, SavedObjectTypeRegistry } from '..'; +import type { LegacyUrlAlias } from './types'; const legacyUrlAliasType: SavedObjectsType = { name: LEGACY_URL_ALIAS_TYPE, @@ -25,6 +26,18 @@ const legacyUrlAliasType: SavedObjectsType = { }, }, hidden: false, + migrations: { + // NOTE TO MAINTAINERS: If you add a migration here, be sure to update the alias creation code in the document migrator accordingly, + // see: `src/core/server/saved_objects/migrations/core/document_migrator.ts` + '8.2.0': (doc) => { + // In version 8.2.0 we added the "purpose" field. Any aliases created before this were created because of saved object conversion. + const purpose: LegacyUrlAlias['purpose'] = 'savedObjectConversion'; + return { + ...doc, + attributes: { ...doc.attributes, purpose }, + }; + }, + }, }; /** diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts index 9038d1a606067..9148b4e903c82 100644 --- a/src/core/server/saved_objects/object_types/types.ts +++ b/src/core/server/saved_objects/object_types/types.ts @@ -52,4 +52,11 @@ export interface LegacyUrlAlias { * `SavedObjectsClient.collectMultiNamespaceReferences()`. */ disabled?: boolean; + /** + * The reason this alias was created. + * + * Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if it was + * created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + */ + purpose?: 'savedObjectConversion' | 'savedObjectImport'; } diff --git a/src/core/server/saved_objects/saved_objects_service.test.mocks.ts b/src/core/server/saved_objects/saved_objects_service.test.mocks.ts index 65273827122ec..f4b58fd12d8ba 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.mocks.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.mocks.ts @@ -30,3 +30,7 @@ export const registerRoutesMock = jest.fn(); jest.doMock('./routes', () => ({ registerRoutes: registerRoutesMock, })); + +// The SavedObjectsSerializer imports SavedObjectUtils from the '../service' module, and that somehow breaks unit tests for the +// SavedObjectsService. To avoid this, we mock the entire './serialization' module, since we don't need it for these tests. +jest.mock('./serialization'); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 9d9d65e735866..f5f0eeddfc9e3 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -9,6 +9,7 @@ import typeDetect from 'type-detect'; import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; +import { SavedObjectsUtils } from '../service'; import { SavedObjectsRawDoc, SavedObjectSanitizedDoc, @@ -170,8 +171,9 @@ export class SavedObjectsSerializer { * @param {string} type - The saved object type * @param {string} id - The id of the saved object */ - public generateRawLegacyUrlAliasId(namespace: string, type: string, id: string) { - return `${LEGACY_URL_ALIAS_TYPE}:${namespace}:${type}:${id}`; + public generateRawLegacyUrlAliasId(namespace: string | undefined, type: string, id: string) { + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + return `${LEGACY_URL_ALIAS_TYPE}:${namespaceString}:${type}:${id}`; } /** diff --git a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts index 883d7fa241944..1c18f0ed9b990 100644 --- a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts +++ b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts @@ -20,6 +20,8 @@ import { SavedObjectsErrorHelpers } from './errors'; import { SavedObjectsBulkResolveObject } from '../saved_objects_client'; import { SavedObject, SavedObjectsBaseOptions } from '../../types'; import { internalBulkResolve, InternalBulkResolveParams } from './internal_bulk_resolve'; +import { SavedObjectsUtils } from './utils'; +import { normalizeNamespace } from './internal_utils'; const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; const OBJ_TYPE = 'obj-type'; @@ -64,10 +66,10 @@ describe('internalBulkResolve', () => { /** Mocks the elasticsearch client so it returns the expected results for a bulk operation */ function mockBulkResults( - ...results: Array<{ found: boolean; targetId?: string; disabled?: boolean }> + ...results: Array<{ found: boolean; targetId?: string; disabled?: boolean; purpose?: string }> ) { client.bulk.mockResponseOnce({ - items: results.map(({ found, targetId, disabled }) => ({ + items: results.map(({ found, targetId, disabled, purpose }) => ({ update: { _index: 'doesnt-matter', status: 0, @@ -75,7 +77,7 @@ describe('internalBulkResolve', () => { found, _source: { ...((targetId || disabled) && { - [LEGACY_URL_ALIAS_TYPE]: { targetId, disabled }, + [LEGACY_URL_ALIAS_TYPE]: { targetId, disabled, purpose }, }), }, ...VERSION_PROPS, @@ -111,7 +113,7 @@ describe('internalBulkResolve', () => { } /** Asserts that bulk is called for the given aliases */ - function expectBulkArgs(namespace: string, aliasIds: string[]) { + function expectBulkArgs(namespaceString: string, aliasIds: string[]) { expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ @@ -119,7 +121,7 @@ describe('internalBulkResolve', () => { .map((id) => [ { update: { - _id: `legacy-url-alias:${namespace}:${OBJ_TYPE}:${id}`, + _id: `legacy-url-alias:${namespaceString}:${OBJ_TYPE}:${id}`, _index: `index-for-${LEGACY_URL_ALIAS_TYPE}`, _source: true, }, @@ -133,12 +135,13 @@ describe('internalBulkResolve', () => { /** Asserts that mget is called for the given objects */ function expectMgetArgs(namespace: string | undefined, objectIds: string[]) { + const normalizedNamespace = normalizeNamespace(namespace); expect(client.mget).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledWith( { body: { docs: objectIds.map((id) => ({ - _id: serializer.generateRawId(namespace, OBJ_TYPE, id), + _id: serializer.generateRawId(normalizedNamespace, OBJ_TYPE, id), _index: `index-for-${OBJ_TYPE}`, })), }, @@ -147,166 +150,193 @@ describe('internalBulkResolve', () => { ); } - function expectUnsupportedTypeError(id: string) { + function expectUnsupportedTypeError({ id }: { id: string }) { const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(UNSUPPORTED_TYPE); return { type: UNSUPPORTED_TYPE, id, error }; } - function expectNotFoundError(id: string) { + function expectNotFoundError({ id }: { id: string }) { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(OBJ_TYPE, id); return { type: OBJ_TYPE, id, error }; } - function expectExactMatchResult(id: string) { + function expectExactMatchResult({ id }: { id: string }) { return { saved_object: `mock-obj-for-${id}`, outcome: 'exactMatch' }; } - function expectAliasMatchResult(id: string) { - return { saved_object: `mock-obj-for-${id}`, outcome: 'aliasMatch', alias_target_id: id }; + function expectAliasMatchResult({ + id, + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_purpose, + }: { + id: string; + alias_purpose?: string; + }) { + return { + saved_object: `mock-obj-for-${id}`, + outcome: 'aliasMatch', + alias_target_id: id, + alias_purpose, + }; } - // eslint-disable-next-line @typescript-eslint/naming-convention - function expectConflictResult(id: string, alias_target_id: string) { - return { saved_object: `mock-obj-for-${id}`, outcome: 'conflict', alias_target_id }; + function expectConflictResult({ + id, + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_target_id, + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_purpose, + }: { + id: string; + alias_target_id: string; + alias_purpose?: string; + }) { + return { + saved_object: `mock-obj-for-${id}`, + outcome: 'conflict', + alias_target_id, + alias_purpose, + }; } - it('throws if mget call results in non-ES-originated 404 error', async () => { - const objects = [{ type: OBJ_TYPE, id: '1' }]; - const params = setup(objects, { namespace: 'space-x' }); - mockBulkResults( - { found: false } // fetch alias for obj 1 - ); - mockMgetResults( - { found: false } // fetch obj 1 (actual result body doesn't matter, just needs statusCode and headers) - ); - mockIsNotFoundFromUnsupportedServer.mockReturnValue(true); - - await expect(() => internalBulkResolve(params)).rejects.toThrow( - SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() - ); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(client.mget).toHaveBeenCalledTimes(1); - }); + for (const namespace of [undefined, 'default', 'space-x']) { + const expectedNamespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - it('returns an empty array if no object args are passed in', async () => { - const params = setup([], { namespace: 'space-x' }); + it('throws if mget call results in non-ES-originated 404 error', async () => { + const objects = [{ type: OBJ_TYPE, id: '1' }]; + const params = setup(objects, { namespace }); + mockBulkResults( + { found: false } // fetch alias for obj 1 + ); + mockMgetResults( + { found: false } // fetch obj 1 (actual result body doesn't matter, just needs statusCode and headers) + ); + mockIsNotFoundFromUnsupportedServer.mockReturnValue(true); - const result = await internalBulkResolve(params); - expect(client.bulk).not.toHaveBeenCalled(); - expect(client.mget).not.toHaveBeenCalled(); - expect(result.resolved_objects).toEqual([]); - }); + await expect(() => internalBulkResolve(params)).rejects.toThrow( + SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); + }); - it('returns errors for unsupported object types', async () => { - const objects = [{ type: UNSUPPORTED_TYPE, id: '1' }]; - const params = setup(objects, { namespace: 'space-x' }); + it('returns an empty array if no object args are passed in', async () => { + const params = setup([], { namespace }); - const result = await internalBulkResolve(params); - expect(client.bulk).not.toHaveBeenCalled(); - expect(client.mget).not.toHaveBeenCalled(); - expect(result.resolved_objects).toEqual([expectUnsupportedTypeError('1')]); - }); + const result = await internalBulkResolve(params); + expect(client.bulk).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(result.resolved_objects).toEqual([]); + }); - it('returns errors for objects that are not found', async () => { - const objects = [ - { type: OBJ_TYPE, id: '1' }, // does not have an alias, and is not found - { type: OBJ_TYPE, id: '2' }, // has an alias, but the object _and_ the alias target are not found - { type: OBJ_TYPE, id: '3' }, // has an alias, and the object and alias target are both found, but the object _and_ the alias target do not exist in this space - ]; - const params = setup(objects, { namespace: 'space-x' }); - mockBulkResults( - { found: false }, // fetch alias for obj 1 - { found: true, targetId: '2-newId' }, // fetch alias for obj 2 - { found: true, targetId: '3-newId' } // fetch alias for obj 3 - ); - mockMgetResults( - { found: false }, // fetch obj 1 - { found: false }, // fetch obj 2 - { found: false }, // fetch obj 2-newId - { found: true }, // fetch obj 3 - { found: true } // fetch obj 3-newId - ); - mockRawDocExistsInNamespace.mockReturnValue(false); // for objs 3 and 3-newId + it('returns errors for unsupported object types', async () => { + const objects = [{ type: UNSUPPORTED_TYPE, id: '1' }]; + const params = setup(objects, { namespace }); - const result = await internalBulkResolve(params); - expectBulkArgs('space-x', ['1', '2', '3']); - expectMgetArgs('space-x', ['1', '2', '2-newId', '3', '3-newId']); - expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(2); // for objs 3 and 3-newId - expect(result.resolved_objects).toEqual([ - expectNotFoundError('1'), - expectNotFoundError('2'), - expectNotFoundError('3'), - ]); - }); + const result = await internalBulkResolve(params); + expect(client.bulk).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(result.resolved_objects).toEqual([expectUnsupportedTypeError({ id: '1' })]); + }); - it('does not call bulk update in the Default space', async () => { - // Aliases cannot exist in the Default space, so we skip the alias check part of the algorithm in that case (e.g., bulk update) - for (const namespace of [undefined, 'default']) { - const params = setup([{ type: OBJ_TYPE, id: '1' }], { namespace }); + it('returns errors for objects that are not found', async () => { + const objects = [ + { type: OBJ_TYPE, id: '1' }, // does not have an alias, and is not found + { type: OBJ_TYPE, id: '2' }, // has an alias, but the object _and_ the alias target are not found + { type: OBJ_TYPE, id: '3' }, // has an alias, and the object and alias target are both found, but the object _and_ the alias target do not exist in this space + ]; + const params = setup(objects, { namespace }); + mockBulkResults( + { found: false }, // fetch alias for obj 1 + { found: true, targetId: '2-newId' }, // fetch alias for obj 2 + { found: true, targetId: '3-newId' } // fetch alias for obj 3 + ); mockMgetResults( - { found: true } // fetch obj 1 + { found: false }, // fetch obj 1 + { found: false }, // fetch obj 2 + { found: false }, // fetch obj 2-newId + { found: true }, // fetch obj 3 + { found: true } // fetch obj 3-newId ); + mockRawDocExistsInNamespace.mockReturnValue(false); // for objs 3 and 3-newId - await internalBulkResolve(params); - expect(client.bulk).not.toHaveBeenCalled(); - // 'default' is normalized to undefined - expectMgetArgs(undefined, ['1']); - } - }); + const result = await internalBulkResolve(params); + expectBulkArgs(expectedNamespaceString, ['1', '2', '3']); + expectMgetArgs(namespace, ['1', '2', '2-newId', '3', '3-newId']); + expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(2); // for objs 3 and 3-newId + expect(result.resolved_objects).toEqual([ + expectNotFoundError({ id: '1' }), + expectNotFoundError({ id: '2' }), + expectNotFoundError({ id: '3' }), + ]); + }); - it('ignores aliases that are disabled', async () => { - const objects = [{ type: OBJ_TYPE, id: '1' }]; - const params = setup(objects, { namespace: 'space-x' }); - mockBulkResults( - { found: true, targetId: '1-newId', disabled: true } // fetch alias for obj 1 - ); - mockMgetResults( - { found: true } // fetch obj 1 - // does not attempt to fetch obj 1-newId, because that alias is disabled - ); + it('ignores aliases that are disabled', async () => { + const objects = [{ type: OBJ_TYPE, id: '1' }]; + const params = setup(objects, { namespace }); + mockBulkResults( + { found: true, targetId: '1-newId', disabled: true } // fetch alias for obj 1 + ); + mockMgetResults( + { found: true } // fetch obj 1 + // does not attempt to fetch obj 1-newId, because that alias is disabled + ); - const result = await internalBulkResolve(params); - expectBulkArgs('space-x', ['1']); - expectMgetArgs('space-x', ['1']); - expect(result.resolved_objects).toEqual([ - expectExactMatchResult('1'), // result for obj 1 - ]); - }); + const result = await internalBulkResolve(params); + expectBulkArgs(expectedNamespaceString, ['1']); + expectMgetArgs(namespace, ['1']); + expect(result.resolved_objects).toEqual([ + expectExactMatchResult({ id: '1' }), // result for obj 1 + ]); + }); - it('returns a mix of results and increments the usage stats counter correctly', async () => { - const objects = [ - { type: UNSUPPORTED_TYPE, id: '1' }, // unsupported type error - { type: OBJ_TYPE, id: '2' }, // not found error - { type: OBJ_TYPE, id: '3' }, // exactMatch outcome - { type: OBJ_TYPE, id: '4' }, // aliasMatch outcome - { type: OBJ_TYPE, id: '5' }, // conflict outcome - ]; - const params = setup(objects, { namespace: 'space-x' }); - mockBulkResults( - // does not attempt to fetch alias for obj 1, because that is an unsupported type - { found: false }, // fetch alias for obj 2 - { found: false }, // fetch alias for obj 3 - { found: true, targetId: '4-newId' }, // fetch alias for obj 4 - { found: true, targetId: '5-newId' } // fetch alias for obj 5 - ); - mockMgetResults( - { found: false }, // fetch obj 2 - { found: true }, // fetch obj 3 - { found: false }, // fetch obj 4 - { found: true }, // fetch obj 4-newId - { found: true }, // fetch obj 5 - { found: true } // fetch obj 5-newId - ); + it('returns a mix of results and increments the usage stats counter correctly', async () => { + const objects = [ + { type: UNSUPPORTED_TYPE, id: '1' }, // unsupported type error + { type: OBJ_TYPE, id: '2' }, // not found error + { type: OBJ_TYPE, id: '3' }, // exactMatch outcome + { type: OBJ_TYPE, id: '4' }, // aliasMatch outcome + { type: OBJ_TYPE, id: '5' }, // aliasMatch outcome with purpose + { type: OBJ_TYPE, id: '6' }, // conflict outcome + { type: OBJ_TYPE, id: '7' }, // conflict outcome with purpose + ]; + const params = setup(objects, { namespace }); + mockBulkResults( + // does not attempt to fetch alias for obj 1, because that is an unsupported type + { found: false }, // fetch alias for obj 2 + { found: false }, // fetch alias for obj 3 + { found: true, targetId: '4-newId' }, // fetch alias for obj 4 + { found: true, targetId: '5-newId', purpose: 'x' }, // fetch alias for obj 5 + { found: true, targetId: '6-newId' }, // fetch alias for obj 6 + { found: true, targetId: '7-newId', purpose: 'y' } // fetch alias for obj 7 + ); + mockMgetResults( + { found: false }, // fetch obj 2 + { found: true }, // fetch obj 3 + { found: false }, // fetch obj 4 + { found: true }, // fetch obj 4-newId + { found: false }, // fetch obj 5 + { found: true }, // fetch obj 5-newId + { found: true }, // fetch obj 6 + { found: true }, // fetch obj 6-newId + { found: true }, // fetch obj 7 + { found: true } // fetch obj 7-newId + ); - const result = await internalBulkResolve(params); - expectBulkArgs('space-x', ['2', '3', '4', '5']); - expectMgetArgs('space-x', ['2', '3', '4', '4-newId', '5', '5-newId']); - expect(result.resolved_objects).toEqual([ - expectUnsupportedTypeError('1'), - expectNotFoundError('2'), - expectExactMatchResult('3'), - expectAliasMatchResult('4-newId'), - expectConflictResult('5', '5-newId'), - ]); - }); + const result = await internalBulkResolve(params); + const bulkIds = ['2', '3', '4', '5', '6', '7']; + expectBulkArgs(expectedNamespaceString, bulkIds); + const mgetIds = ['2', '3', '4', '4-newId', '5', '5-newId', '6', '6-newId', '7', '7-newId']; + expectMgetArgs(namespace, mgetIds); + expect(result.resolved_objects).toEqual([ + expectUnsupportedTypeError({ id: '1' }), + expectNotFoundError({ id: '2' }), + expectExactMatchResult({ id: '3' }), + expectAliasMatchResult({ id: '4-newId' }), + expectAliasMatchResult({ id: '5-newId', alias_purpose: 'x' }), + expectConflictResult({ id: '6', alias_target_id: '6-newId' }), + expectConflictResult({ id: '7', alias_target_id: '7-newId', alias_purpose: 'y' }), + ]); + }); + } }); diff --git a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts index e032e769b0220..3a59ab8a8d9f3 100644 --- a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts +++ b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts @@ -80,6 +80,8 @@ export interface InternalBulkResolveError { error: DecoratedError; } +type AliasInfo = Pick; + export async function internalBulkResolve( params: InternalBulkResolveParams ): Promise> { @@ -102,14 +104,17 @@ export async function internalBulkResolve( const validObjects = allObjects.filter(isRight); const namespace = normalizeNamespace(options.namespace); - const requiresAliasCheck = namespace !== undefined; - const aliasDocs = requiresAliasCheck - ? await fetchAndUpdateAliases(validObjects, client, serializer, getIndexForType, namespace) - : []; + const aliasDocs = await fetchAndUpdateAliases( + validObjects, + client, + serializer, + getIndexForType, + namespace + ); const docsToBulkGet: Array<{ _id: string; _index: string }> = []; - const aliasTargetIds: Array = []; + const aliasInfoArray: Array = []; validObjects.forEach(({ value: { type, id } }, i) => { const objectIndex = getIndexForType(type); docsToBulkGet.push({ @@ -117,22 +122,21 @@ export async function internalBulkResolve( _id: serializer.generateRawId(namespace, type, id), _index: objectIndex, }); - if (requiresAliasCheck) { - const aliasDoc = aliasDocs[i]; - if (aliasDoc?.found) { - const legacyUrlAlias: LegacyUrlAlias = aliasDoc._source[LEGACY_URL_ALIAS_TYPE]; - if (!legacyUrlAlias.disabled) { - docsToBulkGet.push({ - // also attempt to find a match for the legacy URL alias target ID - _id: serializer.generateRawId(namespace, type, legacyUrlAlias.targetId), - _index: objectIndex, - }); - aliasTargetIds.push(legacyUrlAlias.targetId); - return; - } + const aliasDoc = aliasDocs[i]; + if (aliasDoc?.found) { + const legacyUrlAlias: LegacyUrlAlias = aliasDoc._source[LEGACY_URL_ALIAS_TYPE]; + if (!legacyUrlAlias.disabled) { + docsToBulkGet.push({ + // also attempt to find a match for the legacy URL alias target ID + _id: serializer.generateRawId(namespace, type, legacyUrlAlias.targetId), + _index: objectIndex, + }); + const { targetId, purpose } = legacyUrlAlias; + aliasInfoArray.push({ targetId, purpose }); + return; } } - aliasTargetIds.push(undefined); + aliasInfoArray.push(undefined); }); const bulkGetResponse = docsToBulkGet.length @@ -153,7 +157,7 @@ export async function internalBulkResolve( } let getResponseIndex = 0; - let aliasTargetIndex = 0; + let aliasInfoIndex = 0; const resolveCounter = new ResolveCounter(); const resolvedObjects = allObjects.map | InternalBulkResolveError>( (either) => { @@ -162,8 +166,8 @@ export async function internalBulkResolve( } const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; let aliasMatchDoc: MgetResponseItem | undefined; - const aliasTargetId = aliasTargetIds[aliasTargetIndex++]; - if (aliasTargetId !== undefined) { + const aliasInfo = aliasInfoArray[aliasInfoIndex++]; + if (aliasInfo !== undefined) { aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; } const foundExactMatch = @@ -180,7 +184,8 @@ export async function internalBulkResolve( // @ts-expect-error MultiGetHit._source is optional saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc), outcome: 'conflict', - alias_target_id: aliasTargetId!, + alias_target_id: aliasInfo!.targetId, + alias_purpose: aliasInfo!.purpose, }; resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); } else if (foundExactMatch) { @@ -192,10 +197,16 @@ export async function internalBulkResolve( resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); } else if (foundAliasMatch) { result = { - // @ts-expect-error MultiGetHit._source is optional - saved_object: getSavedObjectFromSource(registry, type, aliasTargetId!, aliasMatchDoc), + saved_object: getSavedObjectFromSource( + registry, + type, + aliasInfo!.targetId, + // @ts-expect-error MultiGetHit._source is optional + aliasMatchDoc! + ), outcome: 'aliasMatch', - alias_target_id: aliasTargetId, + alias_target_id: aliasInfo!.targetId, + alias_purpose: aliasInfo!.purpose, }; resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); } @@ -259,7 +270,7 @@ async function fetchAndUpdateAliases( .map(({ value: { type, id } }) => [ { update: { - _id: serializer.generateRawLegacyUrlAliasId(namespace!, type, id), + _id: serializer.generateRawLegacyUrlAliasId(namespace, type, id), _index: getIndexForType(LEGACY_URL_ALIAS_TYPE), _source: true, }, diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts index 4e7ce652bf4bf..7ccacffb9a4d2 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts @@ -69,15 +69,15 @@ describe('deleteLegacyUrlAliases', () => { describe('deleteBehavior "inclusive"', () => { const deleteBehavior = 'inclusive' as const; - it('when filtered namespaces is not empty, returns early', async () => { - const namespaces = ['default']; + it('when namespaces is empty, returns early', async () => { + const namespaces: string[] = []; const params = setup({ type, id, namespaces, deleteBehavior }); await deleteLegacyUrlAliases(params); expect(params.client.updateByQuery).not.toHaveBeenCalled(); }); - it('when filtered namespaces is not empty, calls updateByQuery with expected script params', async () => { + it('when namespaces is not empty, calls updateByQuery with expected script params', async () => { const namespaces = ['space-a', 'default', 'space-b']; const params = setup({ type, id, namespaces, deleteBehavior }); @@ -88,7 +88,7 @@ describe('deleteLegacyUrlAliases', () => { body: expect.objectContaining({ script: expect.objectContaining({ params: { - namespaces: ['space-a', 'space-b'], // 'default' is filtered out + namespaces, matchTargetNamespaceOp: 'delete', notMatchTargetNamespaceOp: 'noop', }, @@ -103,8 +103,7 @@ describe('deleteLegacyUrlAliases', () => { describe('deleteBehavior "exclusive"', () => { const deleteBehavior = 'exclusive' as const; - it('when filtered namespaces is empty, calls updateByQuery with expected script params', async () => { - const namespaces = ['default']; + async function doTest(namespaces: string[]) { const params = setup({ type, id, namespaces, deleteBehavior }); await deleteLegacyUrlAliases(params); @@ -114,7 +113,7 @@ describe('deleteLegacyUrlAliases', () => { body: expect.objectContaining({ script: expect.objectContaining({ params: { - namespaces: [], // 'default' is filtered out + namespaces, matchTargetNamespaceOp: 'noop', notMatchTargetNamespaceOp: 'delete', }, @@ -123,28 +122,16 @@ describe('deleteLegacyUrlAliases', () => { }), expect.anything() ); + } + + it('when namespaces is empty, calls updateByQuery with expected script params', async () => { + const namespaces: string[] = []; + await doTest(namespaces); }); - it('when filtered namespaces is not empty, calls updateByQuery with expected script params', async () => { + it('when namespaces is not empty, calls updateByQuery with expected script params', async () => { const namespaces = ['space-a', 'default', 'space-b']; - const params = setup({ type, id, namespaces, deleteBehavior }); - - await deleteLegacyUrlAliases(params); - expect(params.client.updateByQuery).toHaveBeenCalledTimes(1); - expect(params.client.updateByQuery).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - script: expect.objectContaining({ - params: { - namespaces: ['space-a', 'space-b'], // 'default' is filtered out - matchTargetNamespaceOp: 'noop', - notMatchTargetNamespaceOp: 'delete', - }, - }), - }), - }), - expect.anything() - ); + await doTest(namespaces); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts index 59c73d1f781a2..4d38afeac6eaa 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts @@ -14,7 +14,7 @@ import type { IndexMapping } from '../../../mappings'; import { LEGACY_URL_ALIAS_TYPE } from '../../../object_types'; import type { RepositoryEsClient } from '../repository_es_client'; import { getSearchDsl } from '../search_dsl'; -import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; +import { ALL_NAMESPACES_STRING } from '../utils'; /** @internal */ export interface DeleteLegacyUrlAliasesParams { @@ -57,11 +57,7 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam throwError(type, id, '"namespaces" cannot include the * string'); } - // Legacy URL aliases cannot exist in the default space; filter that out - const filteredNamespaces = namespaces.filter( - (namespace) => namespace !== DEFAULT_NAMESPACE_STRING - ); - if (!filteredNamespaces.length && deleteBehavior === 'inclusive') { + if (!namespaces.length && deleteBehavior === 'inclusive') { // nothing to do, return early return; } @@ -91,7 +87,7 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam } `, params: { - namespaces: filteredNamespaces, + namespaces, matchTargetNamespaceOp: deleteBehavior === 'inclusive' ? 'delete' : 'noop', notMatchTargetNamespaceOp: deleteBehavior === 'inclusive' ? 'noop' : 'delete', }, diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index de788c8e65985..7ebea2d8ff26e 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -354,9 +354,20 @@ export interface SavedObjectsResolveResponse { */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; /** - * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + * The ID of the object that the legacy URL alias points to. + * + * **Note:** this field is *only* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). */ alias_target_id?: string; + /** + * The reason this alias was created. + * + * Currently this is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias + * was created because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + * + * **Note:** this field is *only* included when an alias was found (in other words, when the outcome is `'aliasMatch'` or `'conflict'`). + */ + alias_purpose?: 'savedObjectConversion' | 'savedObjectImport'; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index f5516649804a2..82b4012703be8 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2741,6 +2741,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + alias_purpose?: 'savedObjectConversion' | 'savedObjectImport'; alias_target_id?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; saved_object: SavedObject; @@ -2751,7 +2752,7 @@ export class SavedObjectsSerializer { // @internal constructor(registry: ISavedObjectTypeRegistry); generateRawId(namespace: string | undefined, type: string, id: string): string; - generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; + generateRawLegacyUrlAliasId(namespace: string | undefined, type: string, id: string): string; isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean; rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 26641dc52e3d5..eb5beeb858226 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -159,10 +159,11 @@ export const useDashboardAppState = ({ savedDashboard.id, savedDashboard.aliasId ); + const aliasPurpose = savedDashboard.aliasPurpose; if (screenshotModeService?.isScreenshotMode()) { scopedHistory().replace(path); } else { - await spacesService?.ui.redirectLegacyUrl(path); + await spacesService?.ui.redirectLegacyUrl({ path, aliasPurpose }); } // Return so we don't run any more of the hook and let it rerun after the redirect that just happened return; diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 661a4dc8144fb..32d80c4016bec 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -8,6 +8,7 @@ import { assign, cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/public'; +import type { ResolvedSimpleSavedObject } from 'src/core/public'; import { EmbeddableStart } from '../services/embeddable'; import { SavedObject, SavedObjectsStart } from '../services/saved_objects'; import { Filter, ISearchSource, Query, RefreshInterval } from '../services/data'; @@ -35,8 +36,9 @@ export interface DashboardSavedObject extends SavedObject { getQuery(): Query; getFilters(): Filter[]; getFullEditPath: (editMode?: boolean) => string; - outcome?: string; - aliasId?: string; + outcome?: ResolvedSimpleSavedObject['outcome']; + aliasId?: ResolvedSimpleSavedObject['alias_target_id']; + aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose']; controlGroupInput?: Omit; } @@ -101,8 +103,9 @@ export function createSavedDashboardClass( public static searchSource = true; public showInRecentlyAccessed = true; - public outcome?: string; - public aliasId?: string; + public outcome?: ResolvedSimpleSavedObject['outcome']; + public aliasId?: ResolvedSimpleSavedObject['alias_target_id']; + public aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose']; constructor(arg: { id: string; useResolve: boolean } | string) { super({ @@ -153,6 +156,7 @@ export function createSavedDashboardClass( const { outcome, alias_target_id: aliasId, + alias_purpose: aliasPurpose, saved_object: resp, } = await savedObjectsClient.resolve(esType, id); @@ -166,6 +170,7 @@ export function createSavedDashboardClass( this.outcome = outcome; this.aliasId = aliasId; + this.aliasPurpose = aliasPurpose; await this.applyESResp(respMapped); return this; diff --git a/src/plugins/discover/public/services/saved_searches/get_saved_searches.test.ts b/src/plugins/discover/public/services/saved_searches/get_saved_searches.test.ts index 13254aa7b077e..e189eda2e1b2b 100644 --- a/src/plugins/discover/public/services/saved_searches/get_saved_searches.test.ts +++ b/src/plugins/discover/public/services/saved_searches/get_saved_searches.test.ts @@ -129,6 +129,7 @@ describe('getSavedSearch', () => { "setParent": [MockFunction], }, "sharingSavedObjectProps": Object { + "aliasPurpose": undefined, "aliasTargetId": undefined, "errorJSON": undefined, "outcome": "exactMatch", diff --git a/src/plugins/discover/public/services/saved_searches/get_saved_searches.ts b/src/plugins/discover/public/services/saved_searches/get_saved_searches.ts index b4b9ecc89acf5..3947b9311c423 100644 --- a/src/plugins/discover/public/services/saved_searches/get_saved_searches.ts +++ b/src/plugins/discover/public/services/saved_searches/get_saved_searches.ts @@ -62,6 +62,7 @@ const findSavedSearch = async ( { outcome: so.outcome, aliasTargetId: so.alias_target_id, + aliasPurpose: so.alias_purpose, errorJSON: so.outcome === 'conflict' && spaces ? JSON.stringify({ diff --git a/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.test.ts b/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.test.ts index 51f1131122553..a99813795a0d9 100644 --- a/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.test.ts +++ b/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.test.ts @@ -31,18 +31,21 @@ describe('useSavedSearchAliasMatchRedirect', () => { test('should redirect in case of aliasMatch', () => { const savedSearch = { id: 'id', + title: 'my-title', sharingSavedObjectProps: { outcome: 'aliasMatch', aliasTargetId: 'aliasTargetId', + aliasPurpose: 'savedObjectConversion', }, } as SavedSearch; renderHook(() => useSavedSearchAliasMatchRedirect({ spaces, savedSearch, history })); - expect(spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith( - '#/view/aliasTargetId?_g=foo', - ' search' - ); + expect(spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith({ + path: '#/view/aliasTargetId?_g=foo', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'my-title search', + }); }); test('should not redirect if outcome !== aliasMatch', () => { diff --git a/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.ts b/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.ts index 2d49ebeb8b2de..6e80d68839451 100644 --- a/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.ts +++ b/src/plugins/discover/public/services/saved_searches/saved_search_alias_match_redirect.ts @@ -28,18 +28,20 @@ export const useSavedSearchAliasMatchRedirect = ({ useEffect(() => { async function aliasMatchRedirect() { if (savedSearch) { - const { aliasTargetId, outcome } = savedSearch.sharingSavedObjectProps ?? {}; + const sharingSavedObjectProps = savedSearch.sharingSavedObjectProps ?? {}; + const { outcome, aliasPurpose, aliasTargetId } = sharingSavedObjectProps; if (spaces && aliasTargetId && outcome === 'aliasMatch') { - await spaces.ui.redirectLegacyUrl( - `${getSavedSearchUrl(aliasTargetId)}${history().location.search}`, - i18n.translate('discover.savedSearchAliasMatchRedirect.objectNoun', { + await spaces.ui.redirectLegacyUrl({ + path: `${getSavedSearchUrl(aliasTargetId)}${history().location.search}`, + aliasPurpose, + objectNoun: i18n.translate('discover.savedSearchAliasMatchRedirect.objectNoun', { defaultMessage: '{savedSearch} search', values: { savedSearch: savedSearch.title, }, - }) - ); + }), + }); } } } diff --git a/src/plugins/discover/public/services/saved_searches/types.ts b/src/plugins/discover/public/services/saved_searches/types.ts index 9fa579048d4af..b7cd7f4a63f7b 100644 --- a/src/plugins/discover/public/services/saved_searches/types.ts +++ b/src/plugins/discover/public/services/saved_searches/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { SavedObjectsResolveResponse } from 'src/core/public'; import type { ISearchSource } from '../../../../data/public'; import { DiscoverGridSettingsColumn } from '../../components/discover_grid/types'; import { VIEW_MODE } from '../../components/view_mode_toggle'; @@ -44,8 +45,9 @@ export interface SavedSearch { }; hideChart?: boolean; sharingSavedObjectProps?: { - outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; - aliasTargetId?: string; + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; errorJSON?: string; }; viewMode?: VIEW_MODE; diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index c323feb6800d8..abc62e711cdc9 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { SavedObjectsMigrationVersion } from 'kibana/public'; +import type { SavedObjectsMigrationVersion, SavedObjectsResolveResponse } from 'src/core/public'; import { IAggConfigs, SerializedSearchSourceFields, @@ -38,8 +38,9 @@ export interface ISavedVis { savedSearchRefName?: string; savedSearchId?: string; sharingSavedObjectProps?: { - outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; - aliasTargetId?: string; + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; errorJSON?: string; }; } diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index f221fa6a208b8..875211a39757d 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -233,6 +233,7 @@ export async function getSavedVisualization( saved_object: resp, outcome, alias_target_id: aliasTargetId, + alias_purpose: aliasPurpose, } = await services.savedObjectsClient.resolve(SAVED_VIS_TYPE, id); if (!resp._version) { @@ -254,6 +255,7 @@ export async function getSavedVisualization( savedObject.sharingSavedObjectProps = { aliasTargetId, outcome, + aliasPurpose, errorJSON: outcome === 'conflict' && services.spaces ? JSON.stringify({ diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx index 81f0bd8d99909..e2d169d263e45 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx @@ -100,6 +100,7 @@ describe('VisualizeEditorCommon', () => { sharingSavedObjectProps: { outcome: 'aliasMatch', aliasTargetId: 'alias_id', + aliasPurpose: 'savedObjectConversion', }, }, vis: { @@ -111,10 +112,11 @@ describe('VisualizeEditorCommon', () => { } /> ); - expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( - '#/edit/alias_id?_g=test', - 'TSVB visualization' - ); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith({ + path: '#/edit/alias_id?_g=test', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'TSVB visualization', + }); }); it('should display a warning callout for new heatmap implementation with split aggs', async () => { diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx index c76515072a1e2..922a55609c1e3 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx @@ -75,15 +75,16 @@ export const VisualizeEditorCommon = ({ // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' const newPath = `${urlFor(newObjectId!)}${services.history.location.search}`; - await services.spaces.ui.redirectLegacyUrl( - newPath, - i18n.translate('visualizations.legacyUrlConflict.objectNoun', { + await services.spaces.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: sharingSavedObjectProps.aliasPurpose, + objectNoun: i18n.translate('visualizations.legacyUrlConflict.objectNoun', { defaultMessage: '{visName} visualization', values: { visName: visInstance?.vis?.type.title, }, - }) - ); + }), + }); return; } } diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx index 3ad696b2b207b..0d73fe49601c8 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx @@ -113,12 +113,17 @@ describe('useWorkpad', () => { outcome: 'aliasMatch', workpad: workpadResponse, aliasId, + aliasPurpose: 'savedObjectConversion', }); const { waitFor, unmount } = renderHook(() => useWorkpad(workpadId, true, getRedirectPath)); try { await waitFor(() => expect(mockRedirectLegacyUrl).toHaveBeenCalled()); - expect(mockRedirectLegacyUrl).toBeCalledWith(`#${aliasId}`, 'Workpad'); + expect(mockRedirectLegacyUrl).toBeCalledWith({ + path: `#${aliasId}`, + aliasPurpose: 'savedObjectConversion', + objectNoun: 'Workpad', + }); } catch (e) { throw e; } finally { diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts index f117998bbd3eb..61908d96828fd 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts @@ -16,12 +16,17 @@ import { setAssets } from '../../../state/actions/assets'; // @ts-expect-error import { setZoomScale } from '../../../state/actions/transient'; import { CanvasWorkpad } from '../../../../types'; +import type { ResolveWorkpadResponse } from '../../../services/workpad'; const getWorkpadLabel = () => i18n.translate('xpack.canvas.workpadResolve.redirectLabel', { defaultMessage: 'Workpad', }); +interface ResolveInfo extends Omit { + id: string; +} + export const useWorkpad = ( workpadId: string, loadPages: boolean = true, @@ -34,24 +39,21 @@ export const useWorkpad = ( const storedWorkpad = useSelector(getWorkpad); const [error, setError] = useState(undefined); - const [resolveInfo, setResolveInfo] = useState< - { id: string; aliasId: string | undefined; outcome: string } | undefined - >(undefined); + const [resolveInfo, setResolveInfo] = useState(undefined); useEffect(() => { (async () => { try { const { - outcome, - aliasId, workpad: { assets, ...workpad }, + ...resolveProps } = await workpadResolve(workpadId); - setResolveInfo({ aliasId, outcome, id: workpadId }); + setResolveInfo({ id: workpadId, ...resolveProps }); // If it's an alias match, we know we are going to redirect so don't even dispatch that we got the workpad - if (storedWorkpad.id !== workpadId && outcome !== 'aliasMatch') { - workpad.aliasId = aliasId; + if (storedWorkpad.id !== workpadId && resolveProps.outcome !== 'aliasMatch') { + workpad.aliasId = resolveProps.aliasId; dispatch(setAssets(assets)); dispatch(setWorkpad(workpad, { loadPages })); @@ -72,10 +74,14 @@ export const useWorkpad = ( (async () => { if (!resolveInfo) return; - const { aliasId, outcome } = resolveInfo; + const { aliasId, outcome, aliasPurpose } = resolveInfo; if (outcome === 'aliasMatch' && platformService.redirectLegacyUrl && aliasId) { const redirectPath = getRedirectPath(aliasId); - await platformService.redirectLegacyUrl(`#${redirectPath}`, getWorkpadLabel()); + await platformService.redirectLegacyUrl({ + path: `#${redirectPath}`, + aliasPurpose, + objectNoun: getWorkpadLabel(), + }); } })(); }, [workpadId, resolveInfo, getRedirectPath, platformService]); diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index c0ef1097555a6..7399c12d96a86 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -84,13 +84,12 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, }; }, resolve: async (id: string) => { - const { workpad, outcome, aliasId } = await coreStart.http.get( + const { workpad, ...resolveProps } = await coreStart.http.get( `${getApiPath()}/resolve/${id}` ); return { - outcome, - aliasId, + ...resolveProps, workpad: { // @ts-ignore: Shimming legacy workpads that might not have CSS css: DEFAULT_WORKPAD_CSS, diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 233b1a70ff7f6..36780a5df1dfd 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -24,6 +24,7 @@ export interface ResolveWorkpadResponse { workpad: CanvasWorkpad; outcome: SavedObjectsResolveResponse['outcome']; aliasId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; } export interface CanvasWorkpadService { diff --git a/x-pack/plugins/canvas/server/routes/workpad/resolve.ts b/x-pack/plugins/canvas/server/routes/workpad/resolve.ts index 7c21ecf9ed055..e5643a9a81353 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/resolve.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/resolve.ts @@ -36,6 +36,9 @@ export function initializeResolveWorkpadRoute(deps: RouteInitializerDeps) { }, outcome: resolved.outcome, aliasId: resolved.alias_target_id, + ...(resolved.alias_purpose !== undefined && { + aliasPurpose: resolved.alias_purpose, + }), }, }); }) diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 061a720bdf9c3..6d3a0b524f890 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -216,6 +216,7 @@ export const CaseResolveResponseRt = rt.intersection([ }), rt.partial({ alias_target_id: rt.string, + alias_purpose: rt.union([rt.literal('savedObjectConversion'), rt.literal('savedObjectImport')]), }), ]); diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3b32475f33dde..bc40077873eae 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SavedObjectsResolveResponse } from 'src/core/public'; import { CaseAttributes, CaseConnector, @@ -86,8 +87,9 @@ export interface Case extends BasicCase { export interface ResolvedCase { case: Case; - outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; - aliasTargetId?: string; + outcome: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; } export interface QueryParams { 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 4f112b09d0ea2..9d41e3ec0f7fc 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 @@ -217,7 +217,8 @@ describe('CaseView', () => { it('should redirect case view when resolves to alias match', async () => { const resolveAliasId = `${defaultGetCase.data.id}_2`; - mockGetCase({ resolveOutcome: 'aliasMatch', resolveAliasId }); + const resolveAliasPurpose = 'savedObjectConversion' as const; + mockGetCase({ resolveOutcome: 'aliasMatch', resolveAliasId, resolveAliasPurpose }); const wrapper = mount( @@ -226,10 +227,11 @@ describe('CaseView', () => { await waitFor(() => { expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy(); expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled(); - expect(spacesUiApiMock.redirectLegacyUrl).toHaveBeenCalledWith( - `/cases/${resolveAliasId}`, - 'case' - ); + expect(spacesUiApiMock.redirectLegacyUrl).toHaveBeenCalledWith({ + path: `/cases/${resolveAliasId}`, + aliasPurpose: resolveAliasPurpose, + objectNoun: 'case', + }); }); }); 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 25c578e8e3be2..fcfcd2df721c2 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -44,17 +44,27 @@ export const CaseView = React.memo( const { spaces: spacesApi } = useKibana().services; const { detailName: caseId } = useCaseViewParams(); const { basePath } = useCasesContext(); - const { data, resolveOutcome, resolveAliasId, isLoading, isError, fetchCase, updateCase } = - useGetCase(caseId); + const { + data, + resolveOutcome, + resolveAliasId, + resolveAliasPurpose, + isLoading, + isError, + fetchCase, + updateCase, + } = useGetCase(caseId); useEffect(() => { if (spacesApi && resolveOutcome === 'aliasMatch' && resolveAliasId != null) { - const newPath = `${basePath}${generateCaseViewPath({ detailName: resolveAliasId })}${ - window.location.search - }${window.location.hash}`; - spacesApi.ui.redirectLegacyUrl(newPath, i18n.CASE); + const newPath = `${basePath}${generateCaseViewPath({ detailName: resolveAliasId })}`; + spacesApi.ui.redirectLegacyUrl({ + path: `${newPath}${window.location.search}${window.location.hash}`, + aliasPurpose: resolveAliasPurpose, + objectNoun: i18n.CASE, + }); } - }, [resolveOutcome, resolveAliasId, basePath, spacesApi]); + }, [resolveOutcome, resolveAliasId, resolveAliasPurpose, basePath, spacesApi]); const getLegacyUrlConflictCallout = useCallback(() => { // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario diff --git a/x-pack/plugins/cases/public/containers/use_get_case.tsx b/x-pack/plugins/cases/public/containers/use_get_case.tsx index c7d2a79a53256..42ab0c6f824f7 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.tsx @@ -15,7 +15,8 @@ import { resolveCase } from './api'; interface CaseState { data: Case | null; resolveOutcome: ResolvedCase['outcome'] | null; - resolveAliasId?: string; + resolveAliasId?: ResolvedCase['aliasTargetId']; + resolveAliasPurpose?: ResolvedCase['aliasPurpose']; isLoading: boolean; isError: boolean; } @@ -45,6 +46,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { data: action.payload.case, resolveOutcome: action.payload.outcome, resolveAliasId: action.payload.aliasTargetId, + resolveAliasPurpose: action.payload.aliasPurpose, }; case 'FETCH_FAILURE': return { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 6e37bfcf41d1a..03091fd1fdc0d 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -133,6 +133,7 @@ export async function getSavedWorkspace( const sharingSavedObjectProps = { outcome: resolveResult.outcome, aliasTargetId: resolveResult.alias_target_id, + aliasPurpose: resolveResult.alias_purpose, }; return { diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx b/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx index 066186b585e1c..db23a59f8e902 100644 --- a/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx @@ -68,6 +68,7 @@ describe('use_workspace_loader', () => { saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } }, outcome: 'aliasMatch', alias_target_id: 'aliasTargetId', + alias_purpose: 'savedObjectConversion', }), }, } as unknown as UseWorkspaceLoaderProps; @@ -78,9 +79,10 @@ describe('use_workspace_loader', () => { props as RenderHookOptions ); }); - expect(props.spaces?.ui.redirectLegacyUrl).toHaveBeenCalledWith( - '#/workspace/aliasTargetId?query={}', - 'Graph' - ); + expect(props.spaces?.ui.redirectLegacyUrl).toHaveBeenCalledWith({ + path: '#/workspace/aliasTargetId?query={}', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'Graph', + }); }); }); diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts index e0c49bcfe36f7..07fc58c9ffbdd 100644 --- a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts @@ -6,6 +6,7 @@ */ import { SavedObjectsClientContract } from 'kibana/public'; +import type { SavedObjectsResolveResponse } from 'src/core/public'; import { useEffect, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; @@ -27,8 +28,9 @@ interface WorkspaceUrlParams { id?: string; } export interface SharingSavedObjectProps { - outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; - aliasTargetId?: string; + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; } interface WorkspaceLoadedState { @@ -138,14 +140,15 @@ export const useWorkspaceLoader = ({ if (spaces && fetchedSharingSavedObjectProps?.outcome === 'aliasMatch') { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash - const newObjectId = fetchedSharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'aliasMatch' + const newObjectId = fetchedSharingSavedObjectProps.aliasTargetId!; // This is always defined if outcome === 'aliasMatch' const newPath = getEditUrl(coreStart.http.basePath.prepend, { id: newObjectId }) + search; - spaces.ui.redirectLegacyUrl( - newPath, - i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', { + spaces.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: fetchedSharingSavedObjectProps.aliasPurpose, + objectNoun: i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', { defaultMessage: 'Graph', - }) - ); + }), + }); return null; } diff --git a/x-pack/plugins/lens/public/lens_attribute_service.ts b/x-pack/plugins/lens/public/lens_attribute_service.ts index d0539b99b8eab..93e7a5886ac32 100644 --- a/x-pack/plugins/lens/public/lens_attribute_service.ts +++ b/x-pack/plugins/lens/public/lens_attribute_service.ts @@ -50,6 +50,7 @@ export function getLensAttributeService( saved_object: savedObject, outcome, alias_target_id: aliasTargetId, + alias_purpose: aliasPurpose, } = await savedObjectStore.load(savedObjectId); const { attributes, references, id } = savedObject; const document = { @@ -60,6 +61,7 @@ export function getLensAttributeService( const sharingSavedObjectProps = { aliasTargetId, outcome, + aliasPurpose, sourceId: id, }; diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 709577594ceae..c9009ab395e7c 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -49,16 +49,17 @@ export const getPersisted = async ({ const sharingSavedObjectProps = metaInfo?.sharingSavedObjectProps; if (spaces && sharingSavedObjectProps?.outcome === 'aliasMatch' && history) { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash - const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' + const newObjectId = sharingSavedObjectProps.aliasTargetId!; // This is always defined if outcome === 'aliasMatch' const newPath = lensServices.http.basePath.prepend( `${getEditPath(newObjectId)}${history.location.search}` ); - await spaces.ui.redirectLegacyUrl( - newPath, - i18n.translate('xpack.lens.legacyUrlConflict.objectNoun', { + await spaces.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: sharingSavedObjectProps.aliasPurpose, + objectNoun: i18n.translate('xpack.lens.legacyUrlConflict.objectNoun', { defaultMessage: 'Lens visualization', - }) - ); + }), + }); } doc = { ...initialInput, diff --git a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx index 31053349157e3..cc57a44fc21ca 100644 --- a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx +++ b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx @@ -290,6 +290,7 @@ describe('Initializing the store', () => { sharingSavedObjectProps: { outcome: 'aliasMatch', aliasTargetId: 'id2', + aliasPurpose: 'savedObjectConversion', }, }, }); @@ -301,10 +302,11 @@ describe('Initializing the store', () => { expect(deps.lensServices.attributeService.unwrapAttributes).toHaveBeenCalledWith({ savedObjectId: defaultSavedObjectId, }); - expect(deps.lensServices.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith( - '#/edit/id2?search', - 'Lens visualization' - ); + expect(deps.lensServices.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith({ + path: '#/edit/id2?search', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'Lens visualization', + }); }); it('adds to the recently accessed list on load', async () => { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 0104a75dd99ab..9bea94bd723d3 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -6,7 +6,7 @@ */ import { Ast } from '@kbn/interpreter'; import type { IconType } from '@elastic/eui/src/components/icon/icon'; -import type { CoreSetup, SavedObjectReference } from 'kibana/public'; +import type { CoreSetup, SavedObjectReference, SavedObjectsResolveResponse } from 'kibana/public'; import type { PaletteOutput } from 'src/plugins/charts/public'; import type { TopNavMenuData } from 'src/plugins/navigation/public'; import type { MutableRefObject } from 'react'; @@ -980,8 +980,9 @@ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandle } export interface SharingSavedObjectProps { - outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; - aliasTargetId?: string; + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; sourceId?: string; } diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index 6f610d8a6a2ff..4abf85fc5eaf1 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -6,6 +6,7 @@ */ import { SavedObjectReference } from 'src/core/types'; +import type { SavedObjectsResolveResponse } from 'src/core/public'; import { AttributeService } from '../../../../src/plugins/embeddable/public'; import { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; @@ -16,8 +17,9 @@ import { extractReferences, injectReferences } from '../common/migrations/refere import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; export interface SharingSavedObjectProps { - outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; - aliasTargetId?: string; + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; sourceId?: string; } @@ -84,6 +86,7 @@ export function getMapAttributeService(): MapAttributeService { saved_object: savedObject, outcome, alias_target_id: aliasTargetId, + alias_purpose: aliasPurpose, } = await getSavedObjectsClient().resolve( MAP_SAVED_OBJECT_TYPE, savedObjectId @@ -103,6 +106,7 @@ export function getMapAttributeService(): MapAttributeService { sharingSavedObjectProps: { aliasTargetId, outcome, + aliasPurpose, sourceId: savedObjectId, }, }, diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index a341246f748f3..a354cc5c399dc 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -348,9 +348,13 @@ export class MapApp extends React.Component { const spaces = getSpacesApi(); if (spaces && sharingSavedObjectProps?.outcome === 'aliasMatch') { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash - const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' + const newObjectId = sharingSavedObjectProps.aliasTargetId!; // This is always defined if outcome === 'aliasMatch' const newPath = `${getEditPath(newObjectId)}${this.props.history.location.hash}`; - await spaces.ui.redirectLegacyUrl(newPath, getMapEmbeddableDisplayName()); + await spaces.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: sharingSavedObjectProps.aliasPurpose, + objectNoun: getMapEmbeddableDisplayName(), + }); return; } diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 9765f890e7474..a47107cd068eb 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -47,6 +47,12 @@ export type Outcome = t.TypeOf; export const alias_target_id = t.string; export type AliasTargetId = t.TypeOf; +export const alias_purpose = t.union([ + t.literal('savedObjectConversion'), + t.literal('savedObjectImport'), +]); +export type AliasPurpose = t.TypeOf; + export const enabled = t.boolean; export type Enabled = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 04d482c847138..950460580925e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -60,6 +60,7 @@ import { enabled, outcome, alias_target_id, + alias_purpose, updated_at, updated_by, created_at, @@ -149,6 +150,7 @@ const baseParams = { license, outcome, alias_target_id, + alias_purpose, output_index, timeline_id, timeline_title, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index 1a6c86854f6ec..327b091e7133a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -66,6 +66,7 @@ import { meta, outcome, alias_target_id, + alias_purpose, note, building_block_type, license, @@ -167,6 +168,7 @@ export const partialRulesSchema = t.partial({ meta, outcome, alias_target_id, + alias_purpose, index, namespace, note, diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index a8a47d0ca9719..ab60d87973983 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -16,6 +16,8 @@ import { PinnedEvent, } from './pinned_event'; import { + alias_purpose as savedObjectResolveAliasPurpose, + outcome as savedObjectResolveOutcome, success, success_count as successCount, } from '../../detection_engine/schemas/common/schemas'; @@ -366,14 +368,11 @@ export type SingleTimelineResponse = runtimeTypes.TypeOf { waitForAlertsToPopulate(500); }); - it('Mark one alert as acknowledged when more than one open alerts are selected', () => { + // See https://github.com/elastic/kibana/pull/125960#issuecomment-1072675903 + it.skip('Mark one alert as acknowledged when more than one open alerts are selected', () => { cy.get(ALERTS_COUNT) .invoke('text') .then((alertNumberString) => { diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts index c9a0eedefd0af..1f1e089f9249d 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts @@ -88,13 +88,15 @@ describe('useResolveRedirect', () => { resolveTimelineConfig: { outcome: 'aliasMatch', alias_target_id: 'new-id', + alias_purpose: 'savedObjectConversion', }, })); renderHook(() => useResolveRedirect()); - expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( - 'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29', - 'timeline' - ); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith({ + path: 'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'timeline', + }); }); describe('rison is unable to be decoded', () => { @@ -110,6 +112,7 @@ describe('useResolveRedirect', () => { resolveTimelineConfig: { outcome: 'aliasMatch', alias_target_id: 'new-id', + alias_purpose: 'savedObjectConversion', }, savedObjectId: 'current-saved-object-id', activeTab: 'some-tab', @@ -117,10 +120,11 @@ describe('useResolveRedirect', () => { show: false, })); renderHook(() => useResolveRedirect()); - expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( - 'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29', - 'timeline' - ); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith({ + path: 'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'timeline', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts index a6ba0b24828e7..52b22829e6a61 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts @@ -59,7 +59,7 @@ export const useResolveRedirect = () => { } // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash - const newObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'aliasMatch' + const newObjectId = resolveTimelineConfig.alias_target_id ?? ''; // This is always defined if outcome === 'aliasMatch' const newTimelineSearch = { ...timelineSearch, id: newObjectId, @@ -67,7 +67,11 @@ export const useResolveRedirect = () => { const newTimelineRison = encodeRisonUrlState(newTimelineSearch); searchQuery.set(CONSTANTS.timeline, newTimelineRison); const newPath = `${pathname}?${searchQuery.toString()}`; - spaces.ui.redirectLegacyUrl(newPath, CONSTANTS.timeline); + spaces.ui.redirectLegacyUrl({ + path: newPath, + aliasPurpose: resolveTimelineConfig.alias_purpose, + objectNoun: CONSTANTS.timeline, + }); // Prevent the effect from being called again as the url change takes place in location rather than a true redirect updateHasRedirected(true); }, [ @@ -75,8 +79,7 @@ export const useResolveRedirect = () => { graphEventId, hasRedirected, pathname, - resolveTimelineConfig?.outcome, - resolveTimelineConfig?.alias_target_id, + resolveTimelineConfig, savedObjectId, search, show, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index af5e4fd568a64..85df24ec0258e 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -22,6 +22,8 @@ import { severity, } from '@kbn/securitysolution-io-ts-alerting-types'; import { + alias_purpose as savedObjectResolveAliasPurpose, + outcome as savedObjectResolveOutcome, SortOrder, author, building_block_type, @@ -115,8 +117,9 @@ export const RuleSchema = t.intersection([ throttle: t.union([t.string, t.null]), }), t.partial({ - outcome: t.union([t.literal('exactMatch'), t.literal('aliasMatch'), t.literal('conflict')]), + outcome: savedObjectResolveOutcome, alias_target_id: t.string, + alias_purpose: savedObjectResolveAliasPurpose, building_block_type, anomaly_threshold: t.number, filters: t.array(t.unknown), diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index 21666ae3ad3d1..84e6d6879bb8b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -141,6 +141,8 @@ describe('RuleDetailsPageComponent', () => { }); async function setup() { + mockRedirectLegacyUrl.mockReset(); + mockGetLegacyUrlConflict.mockReset(); const useKibanaMock = useKibana as jest.Mocked; // eslint-disable-next-line react-hooks/rules-of-hooks @@ -166,6 +168,7 @@ describe('RuleDetailsPageComponent', () => { await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); }); }); @@ -189,6 +192,7 @@ describe('RuleDetailsPageComponent', () => { await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); }); }); @@ -199,7 +203,7 @@ describe('RuleDetailsPageComponent', () => { loading: false, isExistingRule: true, refresh: jest.fn(), - rule: { ...mockRule, outcome: 'aliasMatch' }, + rule: { ...mockRule, outcome: 'aliasMatch', alias_purpose: 'savedObjectConversion' }, }); const wrapper = mount( @@ -210,7 +214,12 @@ describe('RuleDetailsPageComponent', () => { ); await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); - expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(`rules/id/myfakeruleid`, `rule`); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith({ + path: 'rules/id/myfakeruleid', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'rule', + }); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); }); }); @@ -221,7 +230,12 @@ describe('RuleDetailsPageComponent', () => { loading: false, isExistingRule: true, refresh: jest.fn(), - rule: { ...mockRule, outcome: 'conflict', alias_target_id: 'aliased_rule_id' }, + rule: { + ...mockRule, + outcome: 'conflict', + alias_target_id: 'aliased_rule_id', + alias_purpose: 'savedObjectConversion', + }, }); const wrapper = mount( @@ -232,7 +246,7 @@ describe('RuleDetailsPageComponent', () => { ); await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); - expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(`rules/id/myfakeruleid`, `rule`); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ currentObjectId: 'myfakeruleid', objectNoun: 'rule', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 6b247ba17c199..4f91b9208ab6c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -271,15 +271,14 @@ const RuleDetailsPageComponent: React.FC = ({ if (spacesApi && outcome === 'aliasMatch') { // This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast. const path = `rules/id/${rule.id}${window.location.search}${window.location.hash}`; - spacesApi.ui.redirectLegacyUrl( + spacesApi.ui.redirectLegacyUrl({ path, - i18nTranslate.translate( + aliasPurpose: rule.alias_purpose, + objectNoun: i18nTranslate.translate( 'xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun', - { - defaultMessage: 'rule', - } - ) - ); + { defaultMessage: 'rule' } + ), + }); } } }, [rule, spacesApi]); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 3ce6186d37a33..31748f40a4b2a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -375,6 +375,7 @@ export const queryTimelineById = ({ resolveTimelineConfig: { outcome: data.outcome, alias_target_id: data.alias_target_id, + alias_purpose: data.alias_purpose, }, timeline: { ...timeline, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 4c9ce991252dc..67131e68fe1b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -198,6 +198,7 @@ export interface OpenTimelineProps { export interface ResolveTimelineConfig { alias_target_id: SingleTimelineResolveResponse['data']['alias_target_id']; outcome: SingleTimelineResolveResponse['data']['outcome']; + alias_purpose: SingleTimelineResolveResponse['data']['alias_purpose']; } export interface UpdateTimeline { duplicate: boolean; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index fff8147da8672..fbfab6304a4aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -294,6 +294,7 @@ export const internalRuleToAPIResponse = ( // saved object properties outcome: isResolvedRule(rule) ? rule.outcome : undefined, alias_target_id: isResolvedRule(rule) ? rule.alias_target_id : undefined, + alias_purpose: isResolvedRule(rule) ? rule.alias_purpose : undefined, // Alerting framework params id: rule.id, updated_at: rule.updatedAt.toISOString(), diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index 6571e2e22fb75..7c5f5798f42b5 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -23,7 +23,11 @@ export type { CopySavedObjectsToSpaceResponse, } from './copy_saved_objects_to_space'; -export type { LegacyUrlConflictProps, EmbeddableLegacyUrlConflictProps } from './legacy_urls'; +export type { + LegacyUrlConflictProps, + EmbeddableLegacyUrlConflictProps, + RedirectLegacyUrlParams, +} from './legacy_urls'; export type { ShareToSpaceFlyoutProps, diff --git a/x-pack/plugins/spaces/public/legacy_urls/index.ts b/x-pack/plugins/spaces/public/legacy_urls/index.ts index b79f65075ce56..0a48e6323479a 100644 --- a/x-pack/plugins/spaces/public/legacy_urls/index.ts +++ b/x-pack/plugins/spaces/public/legacy_urls/index.ts @@ -8,4 +8,8 @@ export { getEmbeddableLegacyUrlConflict, getLegacyUrlConflict } from './components'; export { createRedirectLegacyUrl } from './redirect_legacy_url'; -export type { EmbeddableLegacyUrlConflictProps, LegacyUrlConflictProps } from './types'; +export type { + EmbeddableLegacyUrlConflictProps, + LegacyUrlConflictProps, + RedirectLegacyUrlParams, +} from './types'; diff --git a/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.test.ts b/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.test.ts index aaf92098f0b09..4b117cabaeaed 100644 --- a/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.test.ts +++ b/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.test.ts @@ -29,14 +29,26 @@ describe('#redirectLegacyUrl', () => { return { redirectLegacyUrl, toasts, application }; }; - it('creates a toast and redirects to the given path in the current app', async () => { + it('redirects to the given path in the current app and creates a toast when aliasPurpose is "savedObjectConversion"', async () => { const { redirectLegacyUrl, toasts, application } = setup(); const path = '/foo?bar#baz'; - await redirectLegacyUrl(path); + await redirectLegacyUrl({ path, aliasPurpose: 'savedObjectConversion' }); expect(toasts.addInfo).toHaveBeenCalledTimes(1); expect(application.navigateToApp).toHaveBeenCalledTimes(1); expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); }); + + it('redirects to the given path in the current app and does not create a toast when aliasPurpose is not "savedObjectConversion"', async () => { + const { redirectLegacyUrl, toasts, application } = setup(); + + const path = '/foo?bar#baz'; + await redirectLegacyUrl({ path, aliasPurpose: undefined }); + await redirectLegacyUrl({ path, aliasPurpose: 'savedObjectImport' }); + + expect(toasts.addInfo).not.toHaveBeenCalled(); + expect(application.navigateToApp).toHaveBeenCalledTimes(2); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); + }); }); diff --git a/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.ts b/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.ts index dbc3d68a4dde9..5918e678d9bf6 100644 --- a/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.ts +++ b/x-pack/plugins/spaces/public/legacy_urls/redirect_legacy_url.ts @@ -13,23 +13,31 @@ import type { StartServicesAccessor } from 'src/core/public'; import { DEFAULT_OBJECT_NOUN } from '../constants'; import type { PluginsStart } from '../plugin'; import type { SpacesApiUi } from '../ui_api'; +import type { RedirectLegacyUrlParams } from './types'; export function createRedirectLegacyUrl( getStartServices: StartServicesAccessor ): SpacesApiUi['redirectLegacyUrl'] { - return async function (path: string, objectNoun: string = DEFAULT_OBJECT_NOUN) { + return async function ({ + path, + aliasPurpose, + objectNoun = DEFAULT_OBJECT_NOUN, + }: RedirectLegacyUrlParams) { const [{ notifications, application }] = await getStartServices(); const { currentAppId$, navigateToApp } = application; const appId = await currentAppId$.pipe(first()).toPromise(); // retrieve the most recent value from the BehaviorSubject - const title = i18n.translate('xpack.spaces.redirectLegacyUrlToast.title', { - defaultMessage: `We redirected you to a new URL`, - }); - const text = i18n.translate('xpack.spaces.redirectLegacyUrlToast.text', { - defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, - values: { objectNoun }, - }); - notifications.toasts.addInfo({ title, text }); + if (aliasPurpose === 'savedObjectConversion') { + const title = i18n.translate('xpack.spaces.redirectLegacyUrlToast.title', { + defaultMessage: `We redirected you to a new URL`, + }); + const text = i18n.translate('xpack.spaces.redirectLegacyUrlToast.text', { + defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, + values: { objectNoun }, + }); + notifications.toasts.addInfo({ title, text }); + } + await navigateToApp(appId!, { replace: true, path }); }; } diff --git a/x-pack/plugins/spaces/public/legacy_urls/types.ts b/x-pack/plugins/spaces/public/legacy_urls/types.ts index b3a80627b5b48..9c6f8518107b0 100644 --- a/x-pack/plugins/spaces/public/legacy_urls/types.ts +++ b/x-pack/plugins/spaces/public/legacy_urls/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { SavedObjectsResolveResponse } from 'src/core/public'; + /** * Properties for the LegacyUrlConflict component. */ @@ -44,3 +46,25 @@ export interface EmbeddableLegacyUrlConflictProps { */ sourceId: string; } + +/** + * Parameters for the redirectLegacyUrl function. + */ +export interface RedirectLegacyUrlParams { + /** + * The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + path: string; + /** + * The reason the resolved alias was created. + * + * This is used to determine whether or not a toast should be shown when a user is redirected from a legacy URL; if the alias was created + * because of saved object conversion, then we will display a toast telling the user that the object has a new URL. + */ + aliasPurpose: SavedObjectsResolveResponse['alias_purpose']; + /** + * The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new location_. + * Default value is 'object'. + */ + objectNoun?: string; +} diff --git a/x-pack/plugins/spaces/public/ui_api/types.ts b/x-pack/plugins/spaces/public/ui_api/types.ts index eb2aefd5dd534..4fbba0c0af3e3 100644 --- a/x-pack/plugins/spaces/public/ui_api/types.ts +++ b/x-pack/plugins/spaces/public/ui_api/types.ts @@ -10,7 +10,11 @@ import type { ReactElement } from 'react'; import type { CoreStart } from 'src/core/public'; import type { CopyToSpaceFlyoutProps } from '../copy_saved_objects_to_space'; -import type { EmbeddableLegacyUrlConflictProps, LegacyUrlConflictProps } from '../legacy_urls'; +import type { + EmbeddableLegacyUrlConflictProps, + LegacyUrlConflictProps, + RedirectLegacyUrlParams, +} from '../legacy_urls'; import type { ShareToSpaceFlyoutProps } from '../share_saved_objects_to_space'; import type { SpaceAvatarProps } from '../space_avatar'; import type { SpaceListProps } from '../space_list'; @@ -44,12 +48,8 @@ export interface SpacesApiUi { * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` * * The protocol, hostname, port, base path, and app path are automatically included. - * - * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. - * @param objectNoun The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new - * location_. Default value is 'object'. */ - redirectLegacyUrl: (path: string, objectNoun?: string) => Promise; + redirectLegacyUrl: (params: RedirectLegacyUrlParams) => Promise; /** * Helper function to easily access the Spaces React Context provider. */ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index bf2a0662490ae..1a3f6a5ae2b86 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -65,12 +65,15 @@ export const transformRule: RewriteRequestCase = ({ export const transformResolvedRule: RewriteRequestCase = ({ // eslint-disable-next-line @typescript-eslint/naming-convention alias_target_id, + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_purpose, outcome, ...rest }: any) => { return { ...transformRule(rest), alias_target_id, + alias_purpose, outcome, }; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/resolve_rule.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/resolve_rule.test.ts index fe9ce240b50e5..669a3bbe02a97 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/resolve_rule.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/resolve_rule.test.ts @@ -55,6 +55,7 @@ describe('resolveRule', () => { ], outcome: 'aliasMatch', alias_target_id: '2', + alias_purpose: 'savedObjectConversion', }; http.get.mockResolvedValueOnce(resolvedValue); @@ -98,6 +99,7 @@ describe('resolveRule', () => { ], outcome: 'aliasMatch', alias_target_id: '2', + alias_purpose: 'savedObjectConversion', }); expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${ruleIdEncoded}/_resolve`); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx index 796b2e107a6fe..1289b81eb8169 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx @@ -51,6 +51,7 @@ describe('rule_details_route', () => { id: 'new_id', outcome: 'aliasMatch', alias_target_id: rule.id, + alias_purpose: 'savedObjectConversion', })); const wrapper = mountWithIntl( @@ -60,10 +61,11 @@ describe('rule_details_route', () => { wrapper.update(); }); expect(resolveRule).toHaveBeenCalledWith(rule.id); - expect((spacesMock as any).ui.redirectLegacyUrl).toHaveBeenCalledWith( - `insightsAndAlerting/triggersActions/rule/new_id`, - `rule` - ); + expect((spacesMock as any).ui.redirectLegacyUrl).toHaveBeenCalledWith({ + path: 'insightsAndAlerting/triggersActions/rule/new_id', + aliasPurpose: 'savedObjectConversion', + objectNoun: 'rule', + }); }); it('shows warning callout if fetched rule is a conflict', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.tsx index c5cdff71506f3..cc7dca2a2c9c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.tsx @@ -69,16 +69,17 @@ export const RuleDetailsRoute: React.FunctionComponent = if (spacesApi && outcome === 'aliasMatch') { // This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast. const path = basePath.prepend(`insightsAndAlerting/triggersActions/rule/${rule.id}`); - spacesApi.ui.redirectLegacyUrl( + spacesApi.ui.redirectLegacyUrl({ path, - i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun', { - defaultMessage: 'rule', - }) - ); + aliasPurpose: (rule as ResolvedRule).alias_purpose, + objectNoun: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun', + { defaultMessage: 'rule' } + ), + }); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, spacesApi, basePath]); const getLegacyUrlConflictCallout = () => { const outcome = (rule as ResolvedRule).outcome; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts index a7333ef8716f2..b3469c89dd459 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts @@ -42,6 +42,7 @@ export default ({ getService }: FtrProviderContext) => { const URL = `/s/${spaceId}${DETECTION_ENGINE_RULES_URL}?id=90e3ca0e-71f7-513a-b60a-ac678efd8887`; const readRulesAliasMatchRes = await supertest.get(URL).set('kbn-xsrf', 'true').send(); expect(readRulesAliasMatchRes.body.outcome).to.eql('aliasMatch'); + expect(readRulesAliasMatchRes.body.alias_purpose).to.eql('savedObjectConversion'); // now that we have the migrated alias_target_id, let's attempt an 'exactMatch' query // the result of which should have the outcome as undefined when querying the read rules api. diff --git a/x-pack/test/functional/apps/canvas/saved_object_resolve.ts b/x-pack/test/functional/apps/canvas/saved_object_resolve.ts index 107701b6c42f4..d0739c0d2f1b7 100644 --- a/x-pack/test/functional/apps/canvas/saved_object_resolve.ts +++ b/x-pack/test/functional/apps/canvas/saved_object_resolve.ts @@ -41,8 +41,10 @@ export default function canvasFiltersTest({ getService, getPageObjects }: FtrPro targetId: 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31-new-id', targetNamespace: 'custom_space', sourceId: 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31-old-id', + purpose: 'savedObjectConversion', }, references: [], + migrationVersion: { 'legacy-url-alias': '8.2.0' }, }); // Create conflict match @@ -55,8 +57,10 @@ export default function canvasFiltersTest({ getService, getPageObjects }: FtrPro targetId: 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31-conflict-new', targetNamespace: 'custom_space', sourceId: 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31-conflict-old', + purpose: 'savedObjectConversion', }, references: [], + migrationVersion: { 'legacy-url-alias': '8.2.0' }, }); }); diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 13beed353051a..ad130efa1f4d8 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -537,6 +537,24 @@ } } +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:default:resolvetype:alias-match", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "alias-match", + "targetNamespace": "default", + "targetType": "resolvetype", + "targetId": "alias-match-newid" + } + } + } +} + { "type": "doc", "value": { @@ -571,6 +589,25 @@ } } +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:default:resolvetype:disabled", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "disabled", + "targetNamespace": "default", + "targetType": "resolvetype", + "targetId": "disabled-newid", + "disabled": true + } + } + } +} + { "type": "doc", "value": { @@ -659,6 +696,24 @@ } } +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:default:resolvetype:conflict", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "conflict", + "targetNamespace": "default", + "targetType": "resolvetype", + "targetId": "conflict-newid" + } + } + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index d53afdbddd6ed..454f88e1d8b02 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -112,10 +112,14 @@ export function bulkCreateTestSuiteFactory(esArchiver: any, supertest: SuperTest let expectedMetadata; if (testCase.fail409Param === 'unresolvableConflict') { expectedMetadata = { isNotOverwritable: true }; + } else if (testCase.fail409Param === 'aliasConflictDefaultSpace') { + expectedMetadata = { spacesWithConflictingAliases: ['default'] }; } else if (testCase.fail409Param === 'aliasConflictSpace1') { expectedMetadata = { spacesWithConflictingAliases: ['space_1'] }; } else if (testCase.fail409Param === 'aliasConflictAllSpaces') { - expectedMetadata = { spacesWithConflictingAliases: ['space_1', 'space_x'] }; + expectedMetadata = { + spacesWithConflictingAliases: ['default', 'space_1', 'space_x'], + }; } const expectedError = SavedObjectsErrorHelpers.createConflictError(type, id).output .payload; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 4dbd7901a05c4..68d84de8bb0e5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -23,8 +23,8 @@ export interface DeleteTestCase extends TestCase { failure?: 400 | 403 | 404; } -const ALIAS_DELETE_INCLUSIVE = Object.freeze({ type: 'resolvetype', id: 'alias-match-newid' }); // exists in three specific spaces; deleting this should also delete the alias that targets it in space 1 -const ALIAS_DELETE_EXCLUSIVE = Object.freeze({ type: 'resolvetype', id: 'all_spaces' }); // exists in all spaces; deleting this should also delete the alias that targets it in space 1 +const ALIAS_DELETE_INCLUSIVE = Object.freeze({ type: 'resolvetype', id: 'alias-match-newid' }); // exists in three specific spaces; deleting this should also delete the aliases that target it in the default space and space_1 +const ALIAS_DELETE_EXCLUSIVE = Object.freeze({ type: 'resolvetype', id: 'all_spaces' }); // exists in all spaces; deleting this should also delete the aliases that target it in the default space and space_1 const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); export const TEST_CASES: Record = Object.freeze({ ...CASES, @@ -68,9 +68,9 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S ({ type, id }) => testCase.type === type && testCase.id === id ); expect((searchResponse.hits.total as SearchTotalHits).value).to.eql( - // Five aliases exist but only one should be deleted in each case (for the "inclusive" case, this asserts that the aliases + // Eight aliases exist but only two should be deleted in each case (for the "inclusive" case, this asserts that the aliases // targeting that object in space x and space y were *not* deleted) - expectAliasWasDeleted ? 4 : 5 + expectAliasWasDeleted ? 6 : 8 ); } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 8f04f9a40e27e..9be10e92438a9 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -55,7 +55,7 @@ export const TEST_CASES = Object.freeze({ type: 'resolvetype', id: 'conflict', expectedNamespaces: EACH_SPACE, - expectedOutcome: 'conflict' as const, // only in space 1, where the alias exists + expectedOutcome: 'conflict' as const, // only in the default space and space 1, where the alias exists expectedId: 'conflict', expectedAliasTargetId: 'conflict-newid', }), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index ce10b5e609324..76e9869a61376 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -74,10 +74,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - // We test the alias conflict preflight check error case twice; once by checking the alias with "find" and once by using "bulk-get". + // We test the alias conflict preflight check error case twice; once by checking the alias with "bulk_get" (here) and once by using "find" (below). { ...CASES.ALIAS_CONFLICT_OBJ, - ...(spaceId === SPACE_1_ID ? { ...fail409(), fail409Param: 'aliasConflictSpace1' } : {}), // first try fails if this is space_1 because an alias exists in space_1 + // first try fails if this is the default space or space_1, because an alias exists in those spaces + ...(spaceId === DEFAULT_SPACE_ID + ? { ...fail409(), fail409Param: 'aliasConflictDefaultSpace' } + : {}), + ...(spaceId === SPACE_1_ID ? { ...fail409(), fail409Param: 'aliasConflictSpace1' } : {}), expectedNamespaces, }, ]; @@ -86,7 +90,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409(), - fail409Param: 'aliasConflictAllSpaces', // second try fails because an alias exists in space_x and space_1 (but not space_y because that alias is disabled) + fail409Param: 'aliasConflictAllSpaces', // second try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) // note that if an object was successfully created with this type/ID in the first try, that won't change this outcome, because an alias conflict supersedes all other types of conflicts }, { diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts index 48f4a2b202241..ef4c54110a20a 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts @@ -16,7 +16,7 @@ import { } from '../../common/suites/bulk_resolve'; const { - SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail404 } = testCaseFailures; @@ -25,10 +25,12 @@ const createTestCases = (spaceId: string) => { // to receive an error; otherwise, we expect to receive a success result const normalTypes = [ CASES.EXACT_MATCH, - { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId === SPACE_2_ID) }, // the alias exists in the default space and space_1, but not space_2 { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + // the default expectedOutcome for this case is 'conflict'; the alias exists in the default space and space_1, but not space_2 + // if we are testing in space_2, the expectedOutcome should be 'exactMatch' instead + ...(spaceId === SPACE_2_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 88cfa496f0130..cdef08e820416 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -62,20 +62,10 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, // We test the alias conflict preflight check error case twice; once by checking the alias with "find" and once by using "bulk-get". - { - ...CASES.ALIAS_CONFLICT_OBJ, - ...(spaceId === SPACE_1_ID ? { ...fail409(), fail409Param: 'aliasConflictSpace1' } : {}), // first try fails if this is space_1 because an alias exists in space_1 - expectedNamespaces, - }, + { ...CASES.ALIAS_CONFLICT_OBJ, ...fail409(spaceId !== SPACE_2_ID), expectedNamespaces }, // first try fails if this is the default space or space_1, because an alias exists in those spaces ]; const crossNamespace = [ - { - ...CASES.ALIAS_CONFLICT_OBJ, - initialNamespaces: ['*'], - ...fail409(), - fail409Param: 'aliasConflictAllSpaces', // second try fails because an alias exists in space_x and space_1 (but not space_y because that alias is disabled) - // note that if an object was successfully created with this type/ID in the first try, that won't change this outcome, because an alias conflict supersedes all other types of conflicts - }, + { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409() }, // second try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) { ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, initialNamespaces: ['x', 'y'], diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts index 1e9adda1ddf95..eecc2e39f608d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts @@ -16,7 +16,7 @@ import { } from '../../common/suites/resolve'; const { - SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail404 } = testCaseFailures; @@ -25,10 +25,12 @@ const createTestCases = (spaceId: string) => { // to receive an error; otherwise, we expect to receive a success result const normalTypes = [ CASES.EXACT_MATCH, - { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId === SPACE_2_ID) }, // the alias exists in the default space and space_1, but not space_2 { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + // the default expectedOutcome for this case is 'conflict'; the alias exists in the default space and space_1, but not space_2 + // if we are testing in space_2, the expectedOutcome should be 'exactMatch' instead + ...(spaceId === SPACE_2_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 89aec5152205e..1f5cbe892a5f5 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -43,7 +43,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.ALIAS_CONFLICT_OBJ, upsert: false, ...fail404() }, - { ...CASES.ALIAS_CONFLICT_OBJ, upsert: true, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.ALIAS_CONFLICT_OBJ, upsert: true, ...fail409(spaceId !== SPACE_2_ID) }, // upsert fails if this is the default space or space_1, because an alias exists in those spaces { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index c2fcbbf570830..4e5059933dd74 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -89,11 +89,15 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409(), - fail409Param: 'aliasConflictAllSpaces', // first try fails because an alias exists in space_x and space_1 (but not space_y because that alias is disabled) + fail409Param: 'aliasConflictAllSpaces', // first try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) }, { ...CASES.ALIAS_CONFLICT_OBJ, - ...(spaceId === SPACE_1_ID ? { ...fail409(), fail409Param: 'aliasConflictSpace1' } : {}), // second try fails if this is space_1 because an alias exists in space_1 + // second try fails if this is the default space or space_1, because an alias exists in those spaces + ...(spaceId === DEFAULT_SPACE_ID + ? { ...fail409(), fail409Param: 'aliasConflictDefaultSpace' } + : {}), + ...(spaceId === SPACE_1_ID ? { ...fail409(), fail409Param: 'aliasConflictSpace1' } : {}), expectedNamespaces, }, ]; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts index 4f755e6165c75..0d9eb92ab578f 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; import { bulkResolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_resolve'; const { - SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail404 } = testCaseFailures; @@ -19,10 +19,12 @@ const createTestCases = (spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result CASES.EXACT_MATCH, - { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId === SPACE_2_ID) }, // the alias exists in the default space and space_1, but not space_2 { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + // the default expectedOutcome for this case is 'conflict'; the alias exists in the default space and space_1, but not space_2 + // if we are testing in space_2, the expectedOutcome should be 'exactMatch' instead + ...(spaceId === SPACE_2_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.HIDDEN, ...fail400() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index a46baeb261c8a..3ef83729fe108 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -72,8 +72,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, // We test the alias conflict preflight check error case twice; once by checking the alias with "find" and once by using "bulk-get". - { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409() }, // first try fails because an alias exists in space_x and space_1 (but not space_y because that alias is disabled) - { ...CASES.ALIAS_CONFLICT_OBJ, ...fail409(spaceId === SPACE_1_ID), expectedNamespaces }, // second try fails if this is space_1 because an alias exists in space_1 + { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409() }, // first try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) + { ...CASES.ALIAS_CONFLICT_OBJ, ...fail409(spaceId !== SPACE_2_ID), expectedNamespaces }, // second try fails if this is the default space or space_1, because an alias exists in those spaces ]; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts index 21f23bf3f6d9b..1f5e1d3cbf3a0 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/resolve'; const { - SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail404 } = testCaseFailures; @@ -19,10 +19,12 @@ const createTestCases = (spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result CASES.EXACT_MATCH, - { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId === SPACE_2_ID) }, // the alias exists in the default space and space_1, but not space_2 { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + // the default expectedOutcome for this case is 'conflict'; the alias exists in the default space and space_1, but not space_2 + // if we are testing in space_2, the expectedOutcome should be 'exactMatch' instead + ...(spaceId === SPACE_2_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.HIDDEN, ...fail400() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 02a89ef8aae99..951499f0c944a 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -38,7 +38,7 @@ const createTestCases = (spaceId: string) => [ CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.ALIAS_CONFLICT_OBJ, upsert: false, ...fail404() }, - { ...CASES.ALIAS_CONFLICT_OBJ, upsert: true, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.ALIAS_CONFLICT_OBJ, upsert: true, ...fail409(spaceId !== SPACE_2_ID) }, // upsert fails if this is the default space or space_1, because an alias exists in those spaces { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index ab7118c132f1b..beb6e94e5dced 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -396,6 +396,24 @@ } } +{ + "type": "doc", + "value": { + "id": "legacy-url-alias:default:sharedtype:space_1_only", + "index": ".kibana", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "space_1_only", + "targetNamespace": "default", + "targetType": "sharedtype", + "targetId": "default_only" + } + } + } +} + { "type": "doc", "value": { @@ -416,24 +434,6 @@ } } -{ - "type": "doc", - "value": { - "id": "legacy-url-alias:space_1:sharedtype:default_only", - "index": ".kibana", - "source": { - "type": "legacy-url-alias", - "updated_at": "2017-09-21T18:51:23.794Z", - "legacy-url-alias": { - "sourceId": "default_only", - "targetNamespace": "space_1", - "targetType": "sharedtype", - "targetId": "space_1_only" - } - } - } -} - { "type": "doc", "value": { @@ -457,13 +457,13 @@ { "type": "doc", "value": { - "id": "legacy-url-alias:space_2:sharedtype:default_only", + "id": "legacy-url-alias:space_2:sharedtype:space_1_only", "index": ".kibana", "source": { "type": "legacy-url-alias", "updated_at": "2017-09-21T18:51:23.794Z", "legacy-url-alias": { - "sourceId": "default_only", + "sourceId": "space_1_only", "targetNamespace": "space_2", "targetType": "sharedtype", "targetId": "space_2_only" @@ -602,14 +602,14 @@ { "type": "doc", "value": { - "id": "legacy-url-alias:space_1:sharedtype:doesnt-matter", + "id": "legacy-url-alias:default:sharedtype:doesnt-matter", "index": ".kibana", "source": { "type": "legacy-url-alias", "updated_at": "2017-09-21T18:51:23.794Z", "legacy-url-alias": { "sourceId": "doesnt-matter", - "targetNamespace": "space_1", + "targetNamespace": "default", "targetType": "sharedtype", "targetId": "alias_delete_inclusive" } diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index 84f899bb911e5..d6c429b441341 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -54,37 +54,37 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S const buckets = response.aggregations?.count.buckets; // The test fixture contains six legacy URL aliases: - // (1) two for "space_1", (2) two for "space_2", and (3) two for "other_space", which is a non-existent space. + // (1) two for "default", (2) two for "space_2", and (3) two for "other_space", which is a non-existent space. // Each test deletes "space_2", so the agg buckets should reflect that aliases (1) and (3) still exist afterwards. // Space 2 deleted, all others should exist const expectedBuckets = [ { key: 'default', - doc_count: 7, + doc_count: 9, countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'visualization', doc_count: 3 }, + { key: 'legacy-url-alias', doc_count: 2 }, // aliases (1) { key: 'space', doc_count: 2 }, // since space objects are namespace-agnostic, they appear in the "default" agg bucket { key: 'dashboard', doc_count: 1 }, { key: 'index-pattern', doc_count: 1 }, - // legacy-url-alias objects cannot exist for the default space ], }, }, { - doc_count: 7, + doc_count: 5, key: 'space_1', countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'visualization', doc_count: 3 }, - { key: 'legacy-url-alias', doc_count: 2 }, // aliases (1) { key: 'dashboard', doc_count: 1 }, { key: 'index-pattern', doc_count: 1 }, + // no legacy url alias objects exist in space_1 ], }, }, diff --git a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts index 3374bfe647d10..e99d9b46b8963 100644 --- a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts +++ b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts @@ -27,6 +27,7 @@ export interface DisableLegacyUrlAliasesTestCase { targetSpace: string; targetType: string; sourceId: string; + expectFound: boolean; } const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; @@ -35,9 +36,9 @@ interface RawLegacyUrlAlias { } export const TEST_CASE_TARGET_TYPE = 'sharedtype'; -export const TEST_CASE_SOURCE_ID = 'default_only'; // two aliases exist for default_only: one in space_1, and one in space_2 -const createRequest = (alias: DisableLegacyUrlAliasesTestCase) => ({ - aliases: [alias], +export const TEST_CASE_SOURCE_ID = 'space_1_only'; // two aliases exist for space_1_only: one in the default spacd=e, and one in space_2 +const createRequest = ({ targetSpace, targetType, sourceId }: DisableLegacyUrlAliasesTestCase) => ({ + aliases: [{ targetSpace, targetType, sourceId }], }); const getTestTitle = ({ targetSpace, targetType, sourceId }: DisableLegacyUrlAliasesTestCase) => { return `for alias '${targetSpace}:${targetType}:${sourceId}'`; @@ -51,21 +52,29 @@ export function disableLegacyUrlAliasesTestSuiteFactory( const expectResponseBody = (testCase: DisableLegacyUrlAliasesTestCase, statusCode: 204 | 403): ExpectResponseBody => async (response: Record) => { + const { targetSpace, targetType, sourceId, expectFound } = testCase; if (statusCode === 403) { expect(response.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to disable aliases for ${testCase.targetType}`, + message: `Unable to disable aliases for ${targetType}`, }); } - const { targetSpace, targetType, sourceId } = testCase; - const esResponse = await es.get({ - index: '.kibana', - id: `${LEGACY_URL_ALIAS_TYPE}:${targetSpace}:${targetType}:${sourceId}`, - }); - const doc = esResponse._source!; - expect(doc).not.to.be(undefined); - expect(doc[LEGACY_URL_ALIAS_TYPE].disabled).to.be(statusCode === 204 ? true : undefined); + const esResponse = await es.get( + { + index: '.kibana', + id: `${LEGACY_URL_ALIAS_TYPE}:${targetSpace}:${targetType}:${sourceId}`, + }, + { ignore: [404] } + ); + if (expectFound) { + expect(esResponse.found).to.be(true); + const doc = esResponse._source!; + expect(doc).not.to.be(undefined); + expect(doc[LEGACY_URL_ALIAS_TYPE].disabled).to.be(statusCode === 204 ? true : undefined); + } else { + expect(esResponse.found).to.be(false); + } }; const createTestDefinitions = ( testCases: DisableLegacyUrlAliasesTestCase | DisableLegacyUrlAliasesTestCase[], diff --git a/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts b/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts index 883c1230f5d10..65afaf38f48a9 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts @@ -65,7 +65,6 @@ export const EXPECTED_RESULTS: Record id: CASES.DEFAULT_ONLY.id, spaces: [DEFAULT_SPACE_ID], inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], - spacesWithMatchingAliases: [SPACE_1_ID, SPACE_2_ID], // aliases with a matching targetType and sourceId exist in two other spaces }, { type: 'sharedtype', @@ -113,6 +112,7 @@ export const EXPECTED_RESULTS: Record id: CASES.SPACE_1_ONLY.id, spaces: [SPACE_1_ID], inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + spacesWithMatchingAliases: [DEFAULT_SPACE_ID, SPACE_2_ID], // aliases with a matching targetType and sourceId exist in two other spaces }, { type: 'sharedtype', diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts index c89bc519468ad..cd00a3d8b7ee6 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts @@ -10,7 +10,6 @@ import { getTestScenarios } from '../../../saved_object_api_integration/common/l import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; import { disableLegacyUrlAliasesTestSuiteFactory, - DisableLegacyUrlAliasesTestCase, TEST_CASE_TARGET_TYPE, TEST_CASE_SOURCE_ID, DisableLegacyUrlAliasesTestDefinition, @@ -18,16 +17,18 @@ import { import { FtrProviderContext } from '../../common/ftr_provider_context'; const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => { - return spaceIds.map((targetSpace) => ({ - targetSpace, - targetType: TEST_CASE_TARGET_TYPE, - sourceId: TEST_CASE_SOURCE_ID, - })); +const createTestCases = () => { + const baseCase = { targetType: TEST_CASE_TARGET_TYPE, sourceId: TEST_CASE_SOURCE_ID }; + return { + [DEFAULT_SPACE_ID]: { ...baseCase, targetSpace: DEFAULT_SPACE_ID, expectFound: true }, // alias exists in the default space and should have been disabled + [SPACE_1_ID]: { ...baseCase, targetSpace: SPACE_1_ID, expectFound: false }, // alias does not exist in space_1 + [SPACE_2_ID]: { ...baseCase, targetSpace: SPACE_2_ID, expectFound: true }, // alias exists in space_2 and should have been disabled + }; }; // eslint-disable-next-line import/no-default-export @@ -49,30 +50,34 @@ export default function ({ getService }: FtrProviderContext) { getTestScenarios().security.forEach(({ users }) => { // We are intentionally using "security" test scenarios here, *not* "securityAndSpaces", because of how these tests are structured. + const testCases = createTestCases(); + [ users.noAccess, users.legacyAll, users.dualRead, users.readGlobally, - users.allAtDefaultSpace, users.readAtDefaultSpace, users.readAtSpace1, ].forEach((user) => { - const unauthorized = createTestDefinitions(createTestCases(SPACE_1_ID, SPACE_2_ID), true); + const unauthorized = createTestDefinitions(Object.values(testCases), true); _addTests(user, unauthorized); }); + const authorizedDefaultSpace = [ + ...createTestDefinitions(testCases[DEFAULT_SPACE_ID], false), + ...createTestDefinitions([testCases[SPACE_1_ID], testCases[SPACE_2_ID]], true), + ]; + _addTests(users.allAtDefaultSpace, authorizedDefaultSpace); + const authorizedSpace1 = [ - ...createTestDefinitions(createTestCases(SPACE_1_ID), false), - ...createTestDefinitions(createTestCases(SPACE_2_ID), true), + ...createTestDefinitions(testCases[SPACE_1_ID], false), + ...createTestDefinitions([testCases[DEFAULT_SPACE_ID], testCases[SPACE_2_ID]], true), ]; _addTests(users.allAtSpace1, authorizedSpace1); [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { - const authorizedGlobally = createTestDefinitions( - createTestCases(SPACE_1_ID, SPACE_2_ID), - false - ); + const authorizedGlobally = createTestDefinitions(Object.values(testCases), false); _addTests(user, authorizedGlobally); }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts index 4cb73a7849b43..32e774e2de636 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts @@ -15,16 +15,18 @@ import { import { FtrProviderContext } from '../../common/ftr_provider_context'; const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => { - return spaceIds.map((targetSpace) => ({ - targetSpace, - targetType: TEST_CASE_TARGET_TYPE, - sourceId: TEST_CASE_SOURCE_ID, - })); +const createTestCases = (): DisableLegacyUrlAliasesTestCase[] => { + const baseCase = { targetType: TEST_CASE_TARGET_TYPE, sourceId: TEST_CASE_SOURCE_ID }; + return [ + { ...baseCase, targetSpace: DEFAULT_SPACE_ID, expectFound: true }, // alias exists in the default space and should have been disabled + { ...baseCase, targetSpace: SPACE_1_ID, expectFound: false }, // alias does not exist in space_1 + { ...baseCase, targetSpace: SPACE_2_ID, expectFound: true }, // alias exists in space_2 and should have been disabled + ]; }; // eslint-disable-next-line import/no-default-export @@ -39,7 +41,7 @@ export default function ({ getService }: FtrProviderContext) { supertest ); - const testCases = createTestCases(SPACE_1_ID, SPACE_2_ID); + const testCases = createTestCases(); const tests = createTestDefinitions(testCases, false); addTests(`_disable_legacy_url_aliases`, { tests }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts index fc95f513f5519..a8c2bdce2a3a5 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts @@ -71,7 +71,7 @@ const createMultiPartTestCases = () => { { id: CASES.ALIAS_DELETE_INCLUSIVE.id, existingNamespaces: [DEFAULT_SPACE_ID, SPACE_1_ID], - expectAliasDifference: -2, // one alias should have been deleted from space_1 + expectAliasDifference: -1, // no aliases should have been deleted from space_1 }, ], spacesToAdd: [], @@ -82,7 +82,7 @@ const createMultiPartTestCases = () => { { id: CASES.ALIAS_DELETE_INCLUSIVE.id, existingNamespaces: [DEFAULT_SPACE_ID], - expectAliasDifference: -2, // no aliases can exist in the default space, so no aliases were deleted + expectAliasDifference: -2, // one alias should have been deleted from the default space }, ], spacesToAdd: [], From 45c4e7dac1390df210d211fab57a27415be7d93e Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Fri, 18 Mar 2022 21:19:26 +0100 Subject: [PATCH 02/38] [Alerting] Limit the executable actions per Rule execution (#128079) --- docs/settings/alert-action-settings.asciidoc | 5 +- .../resources/base/bin/kibana-docker | 1 + x-pack/plugins/alerting/common/alert.ts | 17 +- .../alerting/common/rule_task_instance.ts | 14 +- x-pack/plugins/alerting/server/config.test.ts | 7 + x-pack/plugins/alerting/server/config.ts | 18 + .../alerting/server/constants/translations.ts | 22 + .../server/lib/get_rules_config.test.ts | 45 + .../alerting/server/lib/get_rules_config.ts | 28 + .../server/lib/rule_execution_status.test.ts | 57 +- .../server/lib/rule_execution_status.ts | 39 +- x-pack/plugins/alerting/server/plugin.test.ts | 253 +++-- x-pack/plugins/alerting/server/plugin.ts | 9 +- .../server/rule_type_registry.test.ts | 955 ++++++++++-------- .../server/rules_client/rules_client.ts | 1 + .../rules_client/tests/aggregate.test.ts | 2 + .../server/rules_client/tests/create.test.ts | 31 + .../server/rules_client/tests/enable.test.ts | 3 + .../tests/get_alert_summary.test.ts | 1 + .../alerting/server/rules_client/tests/lib.ts | 5 + .../rules_client_conflict_retries.test.ts | 2 +- .../server/saved_objects/mappings.json | 10 + .../server/saved_objects/migrations.ts | 4 +- .../create_execution_handler.test.ts | 109 +- .../task_runner/create_execution_handler.ts | 78 +- .../alerting/server/task_runner/fixtures.ts | 43 +- .../server/task_runner/task_runner.test.ts | 203 +++- .../server/task_runner/task_runner.ts | 125 ++- .../task_runner/task_runner_cancel.test.ts | 6 + .../alerting/server/task_runner/types.ts | 62 +- x-pack/plugins/alerting/server/types.ts | 9 +- .../public/pages/rules/config.ts | 2 + .../public/pages/rules/translations.ts | 7 + .../components/rule_details.test.tsx | 23 + .../rule_details/components/rule_details.tsx | 55 +- .../components/rule_status_filter.tsx | 2 + .../rules_list/components/rules_list.test.tsx | 62 +- .../rules_list/components/rules_list.tsx | 11 + .../sections/rules_list/translations.ts | 27 + .../spaces_only/tests/alerting/aggregate.ts | 3 + 40 files changed, 1614 insertions(+), 742 deletions(-) create mode 100644 x-pack/plugins/alerting/server/constants/translations.ts create mode 100644 x-pack/plugins/alerting/server/lib/get_rules_config.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/get_rules_config.ts diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 21f339788883d..c64aef3978e6e 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -200,4 +200,7 @@ Specifies the minimum interval allowed for the all rules. This minimum is enforc + `[s,m,h,d]` + -For example, `20m`, `24h`, `7d`. Default: `1m`. \ No newline at end of file +For example, `20m`, `24h`, `7d`. Default: `1m`. + +`xpack.alerting.rules.execution.actions.max` +Specifies the maximum number of actions that a rule can trigger each time detection checks run. \ No newline at end of file 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 c46a41eb5c3a0..41bfbf8bdd8cf 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 @@ -200,6 +200,7 @@ kibana_vars=( xpack.alerting.invalidateApiKeysTask.removalDelay xpack.alerting.defaultRuleTaskTimeout xpack.alerting.cancelAlertsOnRuleTimeout + xpack.alerting.rules.execution.actions.max xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval xpack.alerts.invalidateApiKeysTask.removalDelay diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 7ca88c83af9d4..dd85fadb49878 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -22,7 +22,14 @@ export interface IntervalSchedule extends SavedObjectAttributes { // for the `typeof ThingValues[number]` types below, become string types that // only accept the values in the associated string arrays -export const AlertExecutionStatusValues = ['ok', 'active', 'error', 'pending', 'unknown'] as const; +export const AlertExecutionStatusValues = [ + 'ok', + 'active', + 'error', + 'pending', + 'unknown', + 'warning', +] as const; export type AlertExecutionStatuses = typeof AlertExecutionStatusValues[number]; export enum AlertExecutionStatusErrorReasons { @@ -35,6 +42,10 @@ export enum AlertExecutionStatusErrorReasons { Disabled = 'disabled', } +export enum AlertExecutionStatusWarningReasons { + MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions', +} + export interface AlertExecutionStatus { status: AlertExecutionStatuses; numberOfTriggeredActions?: number; @@ -45,6 +56,10 @@ export interface AlertExecutionStatus { reason: AlertExecutionStatusErrorReasons; message: string; }; + warning?: { + reason: AlertExecutionStatusWarningReasons; + message: string; + }; } export type AlertActionParams = SavedObjectAttributes; diff --git a/x-pack/plugins/alerting/common/rule_task_instance.ts b/x-pack/plugins/alerting/common/rule_task_instance.ts index 32437338b9c13..aa45618b33f24 100644 --- a/x-pack/plugins/alerting/common/rule_task_instance.ts +++ b/x-pack/plugins/alerting/common/rule_task_instance.ts @@ -10,13 +10,6 @@ import { rawAlertInstance } from './alert_instance'; import { DateFromString } from './date_from_string'; import { IntervalSchedule, RuleMonitoring } from './alert'; -const actionSchema = t.partial({ - group: t.string, - id: t.string, - actionTypeId: t.string, - params: t.record(t.string, t.unknown), -}); - export const ruleStateSchema = t.partial({ alertTypeState: t.record(t.string, t.unknown), alertInstances: t.record(t.string, rawAlertInstance), @@ -29,11 +22,16 @@ const ruleExecutionMetricsSchema = t.partial({ esSearchDurationMs: t.number, }); +const alertExecutionStore = t.partial({ + numberOfTriggeredActions: t.number, + triggeredActionsStatus: t.string, +}); + export type RuleExecutionMetrics = t.TypeOf; export type RuleTaskState = t.TypeOf; export type RuleExecutionState = RuleTaskState & { metrics: RuleExecutionMetrics; - triggeredActions: Array>; + alertExecutionStore: t.TypeOf; }; export const ruleParamsSchema = t.intersection([ diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index 3e3b2569dac2f..c475e0267fd3f 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -23,6 +23,13 @@ describe('config validation', () => { }, "maxEphemeralActionsPerAlert": 10, "minimumScheduleInterval": "1m", + "rules": Object { + "execution": Object { + "actions": Object { + "max": 100000, + }, + }, + }, } `); }); diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index 9da15273850c4..a638c4176d4af 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -8,6 +8,21 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validateDurationSchema } from './lib'; +const ruleTypeSchema = schema.object({ + id: schema.string(), + timeout: schema.maybe(schema.string({ validate: validateDurationSchema })), +}); + +const rulesSchema = schema.object({ + execution: schema.object({ + timeout: schema.maybe(schema.string({ validate: validateDurationSchema })), + actions: schema.object({ + max: schema.number({ defaultValue: 100000 }), + }), + ruleTypeOverrides: schema.maybe(schema.arrayOf(ruleTypeSchema)), + }), +}); + export const DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT = 10; export const configSchema = schema.object({ healthCheck: schema.object({ @@ -23,7 +38,10 @@ export const configSchema = schema.object({ defaultRuleTaskTimeout: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), cancelAlertsOnRuleTimeout: schema.boolean({ defaultValue: true }), minimumScheduleInterval: schema.string({ validate: validateDurationSchema, defaultValue: '1m' }), + rules: rulesSchema, }); export type AlertingConfig = TypeOf; export type PublicAlertingConfig = Pick; +export type RulesConfig = TypeOf; +export type RuleTypeConfig = Omit; diff --git a/x-pack/plugins/alerting/server/constants/translations.ts b/x-pack/plugins/alerting/server/constants/translations.ts new file mode 100644 index 0000000000000..ee1d711b56f8d --- /dev/null +++ b/x-pack/plugins/alerting/server/constants/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const translations = { + taskRunner: { + warning: { + maxExecutableActions: i18n.translate( + 'xpack.alerting.taskRunner.warning.maxExecutableActions', + { + defaultMessage: + 'The maximum number of actions for this rule type was reached; excess actions were not triggered.', + } + ), + }, + }, +}; diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts new file mode 100644 index 0000000000000..75c0d23a9f925 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRulesConfig } from './get_rules_config'; +import { RulesConfig } from '../config'; + +const ruleTypeId = 'test-rule-type-id'; +const config = { + execution: { + timeout: '1m', + actions: { max: 1000 }, + }, +} as RulesConfig; + +const configWithRuleType = { + execution: { + ...config.execution, + ruleTypeOverrides: [ + { + id: ruleTypeId, + actions: { max: 20 }, + }, + ], + }, +}; + +describe('get rules config', () => { + test('returns the rule type specific config and keeps the default values that are not overwritten', () => { + expect(getRulesConfig({ config: configWithRuleType, ruleTypeId })).toEqual({ + execution: { + id: ruleTypeId, + timeout: '1m', + actions: { max: 20 }, + }, + }); + }); + + test('returns the default config when there is no rule type specific config', () => { + expect(getRulesConfig({ config, ruleTypeId })).toEqual(config); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.ts new file mode 100644 index 0000000000000..1c6ca71b1f848 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_rules_config.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash'; +import { RulesConfig, RuleTypeConfig } from '../config'; + +export const getRulesConfig = ({ + config, + ruleTypeId, +}: { + config: RulesConfig; + ruleTypeId: string; +}): RuleTypeConfig => { + const ruleTypeConfig = config.execution.ruleTypeOverrides?.find( + (ruleType) => ruleType.id === ruleTypeId + ); + + return { + execution: { + ...omit(config.execution, 'ruleTypeOverrides'), + ...ruleTypeConfig, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts index be1ce11bad132..c6746ed0eac93 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts @@ -6,7 +6,11 @@ */ import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { AlertAction, AlertExecutionStatusErrorReasons, RuleExecutionState } from '../types'; +import { + AlertExecutionStatusErrorReasons, + AlertExecutionStatusWarningReasons, + RuleExecutionState, +} from '../types'; import { executionStatusFromState, executionStatusFromError, @@ -14,6 +18,8 @@ import { ruleExecutionStatusFromRaw, } from './rule_execution_status'; import { ErrorWithReason } from './error_with_reason'; +import { translations } from '../constants/translations'; +import { ActionsCompletion } from '../task_runner/types'; const MockLogger = loggingSystemMock.create().get(); const metrics = { numSearches: 1, esSearchDurationMs: 10, totalSearchDurationMs: 20 }; @@ -25,42 +31,59 @@ describe('RuleExecutionStatus', () => { describe('executionStatusFromState()', () => { test('empty task state', () => { - const status = executionStatusFromState({} as RuleExecutionState); + const status = executionStatusFromState({ + alertExecutionStore: { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, + } as RuleExecutionState); checkDateIsNearNow(status.lastExecutionDate); expect(status.numberOfTriggeredActions).toBe(0); expect(status.status).toBe('ok'); expect(status.error).toBe(undefined); + expect(status.warning).toBe(undefined); }); test('task state with no instances', () => { const status = executionStatusFromState({ alertInstances: {}, - triggeredActions: [], + alertExecutionStore: { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, metrics, }); checkDateIsNearNow(status.lastExecutionDate); expect(status.numberOfTriggeredActions).toBe(0); expect(status.status).toBe('ok'); expect(status.error).toBe(undefined); + expect(status.warning).toBe(undefined); expect(status.metrics).toBe(metrics); }); test('task state with one instance', () => { const status = executionStatusFromState({ alertInstances: { a: {} }, - triggeredActions: [], + alertExecutionStore: { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, metrics, }); checkDateIsNearNow(status.lastExecutionDate); expect(status.numberOfTriggeredActions).toBe(0); expect(status.status).toBe('active'); expect(status.error).toBe(undefined); + expect(status.warning).toBe(undefined); expect(status.metrics).toBe(metrics); }); test('task state with numberOfTriggeredActions', () => { const status = executionStatusFromState({ - triggeredActions: [{ group: '1' } as AlertAction], + alertExecutionStore: { + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, alertInstances: { a: {} }, metrics, }); @@ -68,8 +91,27 @@ describe('RuleExecutionStatus', () => { expect(status.numberOfTriggeredActions).toBe(1); expect(status.status).toBe('active'); expect(status.error).toBe(undefined); + expect(status.warning).toBe(undefined); expect(status.metrics).toBe(metrics); }); + + test('task state with warning', () => { + const status = executionStatusFromState({ + alertInstances: { a: {} }, + alertExecutionStore: { + numberOfTriggeredActions: 3, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }, + metrics, + }); + checkDateIsNearNow(status.lastExecutionDate); + expect(status.warning).toEqual({ + message: translations.taskRunner.warning.maxExecutableActions, + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + }); + expect(status.status).toBe('warning'); + expect(status.error).toBe(undefined); + }); }); describe('executionStatusFromError()', () => { @@ -111,6 +153,7 @@ describe('RuleExecutionStatus', () => { "lastDuration": 0, "lastExecutionDate": "2020-09-03T16:26:58.000Z", "status": "ok", + "warning": null, } `); }); @@ -126,6 +169,7 @@ describe('RuleExecutionStatus', () => { "lastDuration": 0, "lastExecutionDate": "2020-09-03T16:26:58.000Z", "status": "ok", + "warning": null, } `); }); @@ -138,6 +182,7 @@ describe('RuleExecutionStatus', () => { "lastDuration": 1234, "lastExecutionDate": "2020-09-03T16:26:58.000Z", "status": "ok", + "warning": null, } `); }); @@ -151,6 +196,7 @@ describe('RuleExecutionStatus', () => { "lastDuration": 0, "lastExecutionDate": "2020-09-03T16:26:58.000Z", "status": "ok", + "warning": null, } `); }); @@ -184,6 +230,7 @@ describe('RuleExecutionStatus', () => { checkDateIsNearNow(result.lastExecutionDate); expect(result.status).toBe('unknown'); expect(result.error).toBe(undefined); + expect(result.warning).toBe(undefined); expect(MockLogger.debug).toBeCalledWith( 'invalid ruleExecutionStatus lastExecutionDate "an invalid date" in raw rule rule-id' ); diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts index 129da2b8478aa..364975f37614d 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts @@ -6,18 +6,43 @@ */ import { Logger } from 'src/core/server'; -import { AlertExecutionStatus, RawRuleExecutionStatus, RuleExecutionState } from '../types'; +import { + AlertExecutionStatus, + AlertExecutionStatusValues, + AlertExecutionStatusWarningReasons, + RawRuleExecutionStatus, + RuleExecutionState, +} from '../types'; import { getReasonFromError } from './error_with_reason'; import { getEsErrorMessage } from './errors'; import { AlertExecutionStatuses } from '../../common'; +import { translations } from '../constants/translations'; +import { ActionsCompletion } from '../task_runner/types'; export function executionStatusFromState(state: RuleExecutionState): AlertExecutionStatus { const alertIds = Object.keys(state.alertInstances ?? {}); + + const hasIncompleteAlertExecution = + state.alertExecutionStore.triggeredActionsStatus === ActionsCompletion.PARTIAL; + + let status: AlertExecutionStatuses = + alertIds.length === 0 ? AlertExecutionStatusValues[0] : AlertExecutionStatusValues[1]; + + if (hasIncompleteAlertExecution) { + status = AlertExecutionStatusValues[5]; + } + return { metrics: state.metrics, - numberOfTriggeredActions: state.triggeredActions?.length ?? 0, + numberOfTriggeredActions: state.alertExecutionStore.numberOfTriggeredActions, lastExecutionDate: new Date(), - status: alertIds.length === 0 ? 'ok' : 'active', + status, + ...(hasIncompleteAlertExecution && { + warning: { + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: translations.taskRunner.warning.maxExecutableActions, + }, + }), }; } @@ -37,6 +62,7 @@ export function ruleExecutionStatusToRaw({ lastDuration, status, error, + warning, }: AlertExecutionStatus): RawRuleExecutionStatus { return { lastExecutionDate: lastExecutionDate.toISOString(), @@ -44,6 +70,7 @@ export function ruleExecutionStatusToRaw({ status, // explicitly setting to null (in case undefined) due to partial update concerns error: error ?? null, + warning: warning ?? null, }; } @@ -60,6 +87,7 @@ export function ruleExecutionStatusFromRaw( numberOfTriggeredActions, status = 'unknown', error, + warning, } = rawRuleExecutionStatus; let parsedDateMillis = lastExecutionDate ? Date.parse(lastExecutionDate) : Date.now(); @@ -87,6 +115,10 @@ export function ruleExecutionStatusFromRaw( executionStatus.error = error; } + if (warning) { + executionStatus.warning = warning; + } + return executionStatus; } @@ -94,4 +126,5 @@ export const getRuleExecutionStatusPending = (lastExecutionDate: string) => ({ status: 'pending' as AlertExecutionStatuses, lastExecutionDate, error: null, + warning: null, }); diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 7bf559ecde844..959642f153ee6 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AlertingPlugin, AlertingPluginsSetup, PluginSetupContract } from './plugin'; +import { AlertingPlugin, PluginSetupContract } from './plugin'; import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { coreMock, statusServiceMock } from '../../../../src/core/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; @@ -20,42 +20,70 @@ import { RuleType } from './types'; import { eventLogMock } from '../../event_log/server/mocks'; import { actionsMock } from '../../actions/server/mocks'; +const generateAlertingConfig = (): AlertingConfig => ({ + healthCheck: { + interval: '5m', + }, + invalidateApiKeysTask: { + interval: '5m', + removalDelay: '1h', + }, + maxEphemeralActionsPerAlert: 10, + defaultRuleTaskTimeout: '5m', + cancelAlertsOnRuleTimeout: true, + minimumScheduleInterval: '1m', + rules: { + execution: { + actions: { + max: 1000, + }, + }, + }, +}); + +const sampleRuleType: RuleType = { + id: 'test', + name: 'test', + minimumLicenseRequired: 'basic', + isExportable: true, + actionGroups: [], + defaultActionGroupId: 'default', + producer: 'test', + config: { + execution: { + actions: { + max: 1000, + }, + }, + }, + async executor() {}, +}; + describe('Alerting Plugin', () => { describe('setup()', () => { + const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); + const setupMocks = coreMock.createSetup(); + const mockPlugins = { + licensing: licensingMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsSetup, + taskManager: taskManagerMock.createSetup(), + eventLog: eventLogServiceMock.create(), + actions: actionsMock.createSetup(), + statusService: statusServiceMock.createSetupContract(), + }; + let plugin: AlertingPlugin; - let coreSetup: ReturnType; - let pluginsSetup: jest.Mocked; beforeEach(() => jest.clearAllMocks()); it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 10, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', - }); + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); plugin = new AlertingPlugin(context); - const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); - - const setupMocks = coreMock.createSetup(); // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() - await plugin.setup(setupMocks, { - licensing: licensingMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsSetup, - taskManager: taskManagerMock.createSetup(), - eventLog: eventLogServiceMock.create(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), - }); + await plugin.setup(setupMocks, mockPlugins); expect(setupMocks.status.set).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false); @@ -65,93 +93,88 @@ describe('Alerting Plugin', () => { }); it('should create usage counter if usageCollection plugin is defined', async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 10, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', - }); + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); plugin = new AlertingPlugin(context); - const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); const usageCollectionSetup = createUsageCollectionSetupMock(); - const setupMocks = coreMock.createSetup(); // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() - await plugin.setup(setupMocks, { - licensing: licensingMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsSetup, - taskManager: taskManagerMock.createSetup(), - eventLog: eventLogServiceMock.create(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), - usageCollection: usageCollectionSetup, - }); + await plugin.setup(setupMocks, { ...mockPlugins, usageCollection: usageCollectionSetup }); expect(usageCollectionSetup.createUsageCounter).toHaveBeenCalled(); expect(usageCollectionSetup.registerCollector).toHaveBeenCalled(); }); it(`exposes configured minimumScheduleInterval()`, async () => { + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); + plugin = new AlertingPlugin(context); + + const setupContract = await plugin.setup(setupMocks, mockPlugins); + + expect(setupContract.getConfig()).toEqual({ minimumScheduleInterval: '1m' }); + }); + + it(`applies the default config if there is no rule type specific config `, async () => { const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', + ...generateAlertingConfig(), + rules: { + execution: { + actions: { + max: 123, + }, + }, }, - maxEphemeralActionsPerAlert: 100, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', }); plugin = new AlertingPlugin(context); - const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); - const setupContract = plugin.setup(coreMock.createSetup(), { - licensing: licensingMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsSetup, - taskManager: taskManagerMock.createSetup(), - eventLog: eventLogServiceMock.create(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), + const setupContract = await plugin.setup(setupMocks, mockPlugins); + + const ruleType = { ...sampleRuleType }; + setupContract.registerType(ruleType); + + expect(ruleType.config).toEqual({ + execution: { + actions: { max: 123 }, + }, }); + }); - expect(setupContract.getConfig()).toEqual({ minimumScheduleInterval: '1m' }); + it(`applies rule type specific config if defined in config`, async () => { + const context = coreMock.createPluginInitializerContext({ + ...generateAlertingConfig(), + rules: { + execution: { + actions: { max: 123 }, + ruleTypeOverrides: [{ id: sampleRuleType.id, timeout: '1d' }], + }, + }, + }); + plugin = new AlertingPlugin(context); + + const setupContract = await plugin.setup(setupMocks, mockPlugins); + + const ruleType = { ...sampleRuleType }; + setupContract.registerType(ruleType); + + expect(ruleType.config).toEqual({ + execution: { + id: sampleRuleType.id, + actions: { + max: 123, + }, + timeout: '1d', + }, + }); }); describe('registerType()', () => { let setup: PluginSetupContract; - const sampleRuleType: RuleType = { - id: 'test', - name: 'test', - minimumLicenseRequired: 'basic', - isExportable: true, - actionGroups: [], - defaultActionGroupId: 'default', - producer: 'test', - async executor() {}, - }; - beforeEach(async () => { - coreSetup = coreMock.createSetup(); - pluginsSetup = { - taskManager: taskManagerMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), - licensing: licensingMock.createSetup(), - eventLog: eventLogMock.createSetup(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), - }; - setup = plugin.setup(coreSetup, pluginsSetup); + setup = await plugin.setup(setupMocks, mockPlugins); }); it('should throw error when license type is invalid', async () => { @@ -221,19 +244,9 @@ describe('Alerting Plugin', () => { describe('start()', () => { describe('getRulesClientWithRequest()', () => { it('throws error when encryptedSavedObjects plugin is missing encryption key', async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 10, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', - }); + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); const plugin = new AlertingPlugin(context); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); @@ -264,19 +277,9 @@ describe('Alerting Plugin', () => { }); it(`doesn't throw error when encryptedSavedObjects plugin has encryption key`, async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 10, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', - }); + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); const plugin = new AlertingPlugin(context); const encryptedSavedObjectsSetup = { @@ -321,19 +324,9 @@ describe('Alerting Plugin', () => { }); test(`exposes getAlertingAuthorizationWithRequest()`, async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 100, - defaultRuleTaskTimeout: '5m', - cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', - }); + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); const plugin = new AlertingPlugin(context); const encryptedSavedObjectsSetup = { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 939068e23e2b4..9e5aad8fc3e27 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -62,6 +62,7 @@ import { getHealth } from './health/get_health'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; import { AlertingAuthorization } from './authorization'; import { getSecurityHealth, SecurityHealth } from './lib/get_security_health'; +import { getRulesConfig } from './lib/get_rules_config'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -262,7 +263,8 @@ export class AlertingPlugin { encryptedSavedObjects: plugins.encryptedSavedObjects, }); - const alertingConfig = this.config; + const alertingConfig: AlertingConfig = this.config; + return { registerType< Params extends AlertTypeParams = AlertTypeParams, @@ -286,7 +288,10 @@ export class AlertingPlugin { if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { throw new Error(`"${ruleType.minimumLicenseRequired}" is not a valid license type`); } - + ruleType.config = getRulesConfig({ + config: alertingConfig.rules, + ruleTypeId: ruleType.id, + }); ruleType.ruleTaskTimeout = ruleType.ruleTaskTimeout ?? alertingConfig.defaultRuleTaskTimeout; ruleType.cancelAlertsOnRuleTimeout = diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index 9efbcff691108..163a6bbed9d33 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -29,224 +29,265 @@ beforeEach(() => { }; }); -describe('has()', () => { - test('returns false for unregistered rule types', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(registry.has('foo')).toEqual(false); - }); +describe('Create Lifecycle', () => { + describe('has()', () => { + test('returns false for unregistered rule types', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + expect(registry.has('foo')).toEqual(false); + }); - test('returns true for registered rule types', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register({ - id: 'foo', - name: 'Foo', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('returns true for registered rule types', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', + }); + expect(registry.has('foo')).toEqual(true); }); - expect(registry.has('foo')).toEqual(true); }); -}); -describe('register()', () => { - test('throws if RuleType Id contains invalid characters', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + describe('register()', () => { + test('throws if RuleType Id contains invalid characters', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - const invalidCharacters = [' ', ':', '*', '*', '/']; - for (const char of invalidCharacters) { - expect(() => registry.register({ ...ruleType, id: `${ruleType.id}${char}` })).toThrowError( - new Error(`expected RuleType Id not to include invalid character: ${char}`) + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + const invalidCharacters = [' ', ':', '*', '*', '/']; + for (const char of invalidCharacters) { + expect(() => registry.register({ ...ruleType, id: `${ruleType.id}${char}` })).toThrowError( + new Error(`expected RuleType Id not to include invalid character: ${char}`) + ); + } + + const [first, second] = invalidCharacters; + expect(() => + registry.register({ ...ruleType, id: `${first}${ruleType.id}${second}` }) + ).toThrowError( + new Error(`expected RuleType Id not to include invalid characters: ${first}, ${second}`) ); - } - - const [first, second] = invalidCharacters; - expect(() => - registry.register({ ...ruleType, id: `${first}${ruleType.id}${second}` }) - ).toThrowError( - new Error(`expected RuleType Id not to include invalid characters: ${first}, ${second}`) - ); - }); + }); - test('throws if RuleType Id isnt a string', () => { - const ruleType: RuleType = { - id: 123 as unknown as string, - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('throws if RuleType Id isnt a string', () => { + const ruleType: RuleType = { + id: 123 as unknown as string, + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error(`expected value of type [string] but got [number]`) - ); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(ruleType)).toThrowError( + new Error(`expected value of type [string] but got [number]`) + ); + }); - test('throws if RuleType ruleTaskTimeout is not a valid duration', () => { - const ruleType: RuleType = { - id: '123', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('throws if RuleType ruleTaskTimeout is not a valid duration', () => { + const ruleType: RuleType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + ruleTaskTimeout: '23 milisec', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - ruleTaskTimeout: '23 milisec', - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error( - `Rule type \"123\" has invalid timeout: string is not a valid duration: 23 milisec.` - ) - ); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - test('throws if defaultScheduleInterval isnt valid', () => { - const ruleType: RuleType = { - id: '123', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + expect(() => registry.register(ruleType)).toThrowError( + new Error( + `Rule type \"123\" has invalid timeout: string is not a valid duration: 23 milisec.` + ) + ); + }); + + test('throws if defaultScheduleInterval isnt valid', () => { + const ruleType: RuleType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + defaultScheduleInterval: 'foobar', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - defaultScheduleInterval: 'foobar', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error( - `Rule type \"123\" has invalid default interval: string is not a valid duration: foobar.` - ) - ); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(ruleType)).toThrowError( + new Error( + `Rule type \"123\" has invalid default interval: string is not a valid duration: foobar.` + ) + ); + }); + + test('throws if defaultScheduleInterval is less than configured minimumScheduleInterval', () => { + const ruleType: RuleType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], - test('throws if defaultScheduleInterval is less than configured minimumScheduleInterval', () => { - const ruleType: RuleType = { - id: '123', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + defaultScheduleInterval: '10s', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - defaultScheduleInterval: '10s', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error(`Rule type \"123\" cannot specify a default interval less than 1m.`) - ); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - test('throws if RuleType action groups contains reserved group id', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + expect(() => registry.register(ruleType)).toThrowError( + new Error(`Rule type \"123\" cannot specify a default interval less than 1m.`) + ); + }); + + test('throws if RuleType action groups contains reserved group id', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + /** + * The type system will ensure you can't use the `recovered` action group + * but we also want to ensure this at runtime + */ + { + id: 'recovered', + name: 'Recovered', + } as unknown as ActionGroup<'NotReserved'>, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - /** - * The type system will ensure you can't use the `recovered` action group - * but we also want to ensure this at runtime - */ - { - id: 'recovered', - name: 'Recovered', - } as unknown as ActionGroup<'NotReserved'>, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error( - `Rule type [id="${ruleType.id}"] cannot be registered. Action groups [recovered] are reserved by the framework.` - ) - ); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(ruleType)).toThrowError( + new Error( + `Rule type [id="${ruleType.id}"] cannot be registered. Action groups [recovered] are reserved by the framework.` + ) + ); + }); - test('allows an RuleType to specify a custom recovery group', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('allows an RuleType to specify a custom recovery group', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + recoveryActionGroup: { + id: 'backToAwesome', + name: 'Back To Awesome', }, - ], - defaultActionGroupId: 'default', - recoveryActionGroup: { - id: 'backToAwesome', - name: 'Back To Awesome', - }, - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleType); - expect(registry.get('test').actionGroups).toMatchInlineSnapshot(` + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + config: { + execution: { + actions: { max: 1000 }, + }, + }, + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleType); + expect(registry.get('test').actionGroups).toMatchInlineSnapshot(` Array [ Object { "id": "default", @@ -258,92 +299,107 @@ describe('register()', () => { }, ] `); - }); + }); - test('allows an RuleType to specify a custom rule task timeout', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('allows an RuleType to specify a custom rule task timeout', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + ruleTaskTimeout: '13m', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - ruleTaskTimeout: '13m', - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleType); - expect(registry.get('test').ruleTaskTimeout).toBe('13m'); - }); + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleType); + expect(registry.get('test').ruleTaskTimeout).toBe('13m'); + }); - test('throws if the custom recovery group is contained in the RuleType action groups', () => { - const ruleType: RuleType< - never, - never, - never, - never, - never, - 'default' | 'backToAwesome', - 'backToAwesome' - > = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', - }, - { + test('throws if the custom recovery group is contained in the RuleType action groups', () => { + const ruleType: RuleType< + never, + never, + never, + never, + never, + 'default' | 'backToAwesome', + 'backToAwesome' + > = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'backToAwesome', + name: 'Back To Awesome', + }, + ], + recoveryActionGroup: { id: 'backToAwesome', name: 'Back To Awesome', }, - ], - recoveryActionGroup: { - id: 'backToAwesome', - name: 'Back To Awesome', - }, - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - - expect(() => registry.register(ruleType)).toThrowError( - new Error( - `Rule type [id="${ruleType.id}"] cannot be registered. Action group [backToAwesome] cannot be used as both a recovery and an active action group.` - ) - ); - }); + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(ruleType)).toThrowError( + new Error( + `Rule type [id="${ruleType.id}"] cannot be registered. Action group [backToAwesome] cannot be used as both a recovery and an active action group.` + ) + ); + }); - test('registers the executor with the task manager', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('registers the executor with the task manager', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + ruleTaskTimeout: '20m', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - ruleTaskTimeout: '20m', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleType); - expect(taskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); - expect(taskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleType); + expect(taskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(taskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "alerting:test": Object { @@ -354,48 +410,37 @@ describe('register()', () => { }, ] `); - }); - - test('shallow clones the given rule type', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', - }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }; - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleType); - ruleType.name = 'Changed'; - expect(registry.get('test').name).toEqual('Test'); - }); + }); - test('should throw an error if type is already registered', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register({ - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + test('shallow clones the given rule type', () => { + const ruleType: RuleType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleType); + ruleType.name = 'Changed'; + expect(registry.get('test').name).toEqual('Test'); }); - expect(() => + + test('should throw an error if type is already registered', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register({ id: 'test', name: 'Test', @@ -410,31 +455,62 @@ describe('register()', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - }) - ).toThrowErrorMatchingInlineSnapshot(`"Rule type \\"test\\" is already registered."`); + config: { + execution: { + actions: { max: 1000 }, + }, + }, + }); + expect(() => + registry.register({ + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Rule type \\"test\\" is already registered."`); + }); }); -}); -describe('get()', () => { - test('should return registered type', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register({ - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + describe('get()', () => { + test('should return registered type', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: jest.fn(), - producer: 'alerts', - }); - const ruleType = registry.get('test'); - expect(ruleType).toMatchInlineSnapshot(` + }); + const ruleType = registry.get('test'); + expect(ruleType).toMatchInlineSnapshot(` Object { "actionGroups": Array [ Object { @@ -451,6 +527,13 @@ describe('get()', () => { "params": Array [], "state": Array [], }, + "config": Object { + "execution": Object { + "actions": Object { + "max": 1000, + }, + }, + }, "defaultActionGroupId": "default", "executor": [MockFunction], "id": "test", @@ -464,44 +547,49 @@ describe('get()', () => { }, } `); - }); + }); - test(`should throw an error if type isn't registered`, () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.get('test')).toThrowErrorMatchingInlineSnapshot( - `"Rule type \\"test\\" is not registered."` - ); + test(`should throw an error if type isn't registered`, () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + expect(() => registry.get('test')).toThrowErrorMatchingInlineSnapshot( + `"Rule type \\"test\\" is not registered."` + ); + }); }); -}); -describe('list()', () => { - test('should return empty when nothing is registered', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - const result = registry.list(); - expect(result).toMatchInlineSnapshot(`Set {}`); - }); + describe('list()', () => { + test('should return empty when nothing is registered', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + const result = registry.list(); + expect(result).toMatchInlineSnapshot(`Set {}`); + }); - test('should return registered types', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register({ - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'testActionGroup', - name: 'Test Action Group', + test('should return registered types', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + doesSetRecoveryContext: false, + isExportable: true, + ruleTaskTimeout: '20m', + minimumLicenseRequired: 'basic', + executor: jest.fn(), + producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'testActionGroup', - doesSetRecoveryContext: false, - isExportable: true, - ruleTaskTimeout: '20m', - minimumLicenseRequired: 'basic', - executor: jest.fn(), - producer: 'alerts', - }); - const result = registry.list(); - expect(result).toMatchInlineSnapshot(` + }); + const result = registry.list(); + expect(result).toMatchInlineSnapshot(` Set { Object { "actionGroups": Array [ @@ -536,78 +624,84 @@ describe('list()', () => { }, } `); - }); + }); - test('should return action variables state and empty context', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleTypeWithVariables('x', '', 's')); - const ruleType = registry.get('x'); - expect(ruleType.actionVariables).toBeTruthy(); + test('should return action variables state and empty context', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleTypeWithVariables('x', '', 's')); + const ruleType = registry.get('x'); + expect(ruleType.actionVariables).toBeTruthy(); - const context = ruleType.actionVariables!.context; - const state = ruleType.actionVariables!.state; + const context = ruleType.actionVariables!.context; + const state = ruleType.actionVariables!.state; - expect(context).toBeTruthy(); - expect(context!.length).toBe(0); + expect(context).toBeTruthy(); + expect(context!.length).toBe(0); - expect(state).toBeTruthy(); - expect(state!.length).toBe(1); - expect(state![0]).toEqual({ name: 's', description: 'x state' }); - }); + expect(state).toBeTruthy(); + expect(state!.length).toBe(1); + expect(state![0]).toEqual({ name: 's', description: 'x state' }); + }); - test('should return action variables context and empty state', () => { - const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(ruleTypeWithVariables('x', 'c', '')); - const ruleType = registry.get('x'); - expect(ruleType.actionVariables).toBeTruthy(); + test('should return action variables context and empty state', () => { + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleTypeWithVariables('x', 'c', '')); + const ruleType = registry.get('x'); + expect(ruleType.actionVariables).toBeTruthy(); - const context = ruleType.actionVariables!.context; - const state = ruleType.actionVariables!.state; + const context = ruleType.actionVariables!.context; + const state = ruleType.actionVariables!.state; - expect(state).toBeTruthy(); - expect(state!.length).toBe(0); + expect(state).toBeTruthy(); + expect(state!.length).toBe(0); - expect(context).toBeTruthy(); - expect(context!.length).toBe(1); - expect(context![0]).toEqual({ name: 'c', description: 'x context' }); + expect(context).toBeTruthy(); + expect(context!.length).toBe(1); + expect(context![0]).toEqual({ name: 'c', description: 'x context' }); + }); }); -}); -describe('ensureRuleTypeEnabled', () => { - let ruleTypeRegistry: RuleTypeRegistry; - - beforeEach(() => { - ruleTypeRegistry = new RuleTypeRegistry(ruleTypeRegistryParams); - ruleTypeRegistry.register({ - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + describe('ensureRuleTypeEnabled', () => { + let ruleTypeRegistry: RuleTypeRegistry; + + beforeEach(() => { + ruleTypeRegistry = new RuleTypeRegistry(ruleTypeRegistryParams); + ruleTypeRegistry.register({ + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + isExportable: true, + minimumLicenseRequired: 'basic', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + config: { + execution: { + actions: { max: 1000 }, + }, }, - ], - defaultActionGroupId: 'default', - executor: jest.fn(), - producer: 'alerts', - isExportable: true, - minimumLicenseRequired: 'basic', - recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + }); }); - }); - test('should call ensureLicenseForAlertType on the license state', async () => { - ruleTypeRegistry.ensureRuleTypeEnabled('test'); - expect(mockedLicenseState.ensureLicenseForRuleType).toHaveBeenCalled(); - }); + test('should call ensureLicenseForAlertType on the license state', async () => { + ruleTypeRegistry.ensureRuleTypeEnabled('test'); + expect(mockedLicenseState.ensureLicenseForRuleType).toHaveBeenCalled(); + }); - test('should throw when ensureLicenseForAlertType throws', async () => { - mockedLicenseState.ensureLicenseForRuleType.mockImplementation(() => { - throw new Error('Fail'); + test('should throw when ensureLicenseForAlertType throws', async () => { + mockedLicenseState.ensureLicenseForRuleType.mockImplementation(() => { + throw new Error('Fail'); + }); + expect(() => + ruleTypeRegistry.ensureRuleTypeEnabled('test') + ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); }); - expect(() => ruleTypeRegistry.ensureRuleTypeEnabled('test')).toThrowErrorMatchingInlineSnapshot( - `"Fail"` - ); }); }); @@ -625,6 +719,11 @@ function ruleTypeWithVariables( minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, }; if (!context && !state) return baseAlert; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 921a3a31e2df9..4208c0d76d5ff 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1314,6 +1314,7 @@ export class RulesClient { lastDuration: 0, lastExecutionDate: new Date().toISOString(), error: null, + warning: null, }, }); try { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index 3a11bbc53bcd3..cdcc143fa9e9f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -86,6 +86,7 @@ describe('aggregate()', () => { { key: 'ok', doc_count: 10 }, { key: 'pending', doc_count: 4 }, { key: 'unknown', doc_count: 2 }, + { key: 'warning', doc_count: 1 }, ], }, enabled: { @@ -135,6 +136,7 @@ describe('aggregate()', () => { "ok": 10, "pending": 4, "unknown": 2, + "warning": 1, }, "ruleEnabledStatus": Object { "disabled": 2, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 17b384e4f18b2..2310d98d3b02d 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -404,6 +404,7 @@ describe('create()', () => { "error": null, "lastExecutionDate": "2019-02-12T21:01:22.479Z", "status": "pending", + "warning": null, }, "legacyId": null, "meta": Object { @@ -608,6 +609,7 @@ describe('create()', () => { "error": null, "lastExecutionDate": "2019-02-12T21:01:22.479Z", "status": "pending", + "warning": null, }, "legacyId": "123", "meta": Object { @@ -1035,6 +1037,7 @@ describe('create()', () => { error: null, lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', + warning: null, }, monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, @@ -1162,6 +1165,11 @@ describe('create()', () => { extractReferences: extractReferencesFn, injectReferences: injectReferencesFn, }, + config: { + execution: { + actions: { max: 1000 }, + }, + }, })); const data = getMockData({ params: ruleParams, @@ -1233,6 +1241,7 @@ describe('create()', () => { error: null, lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', + warning: null, }, monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, @@ -1329,6 +1338,11 @@ describe('create()', () => { extractReferences: extractReferencesFn, injectReferences: injectReferencesFn, }, + config: { + execution: { + actions: { max: 1000 }, + }, + }, })); const data = getMockData({ params: ruleParams, @@ -1400,6 +1414,7 @@ describe('create()', () => { error: null, lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', + warning: null, }, monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, @@ -1577,6 +1592,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', error: null, + warning: null, }, monitoring: getDefaultRuleMonitoring(), }, @@ -1708,6 +1724,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', error: null, + warning: null, }, monitoring: getDefaultRuleMonitoring(), }, @@ -1839,6 +1856,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', error: null, + warning: null, }, monitoring: getDefaultRuleMonitoring(), }, @@ -1985,6 +2003,7 @@ describe('create()', () => { status: 'pending', lastExecutionDate: '2019-02-12T21:01:22.479Z', error: null, + warning: null, }, monitoring: { execution: { @@ -2078,6 +2097,11 @@ describe('create()', () => { isExportable: true, async executor() {}, producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"params invalid: [param1]: expected value of type [string] but got [undefined]"` @@ -2355,6 +2379,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', error: null, + warning: null, }, monitoring: getDefaultRuleMonitoring(), }, @@ -2456,6 +2481,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', error: null, + warning: null, }, monitoring: getDefaultRuleMonitoring(), }, @@ -2535,6 +2561,11 @@ describe('create()', () => { extractReferences: jest.fn(), injectReferences: jest.fn(), }, + config: { + execution: { + actions: { max: 1000 }, + }, + }, })); const data = getMockData({ schedule: { interval: '1s' } }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index af96097e41f07..c932c845c3638 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -242,6 +242,7 @@ describe('enable()', () => { lastDuration: 0, lastExecutionDate: '2019-02-12T21:01:22.479Z', error: null, + warning: null, }, }, { @@ -353,6 +354,7 @@ describe('enable()', () => { lastDuration: 0, lastExecutionDate: '2019-02-12T21:01:22.479Z', error: null, + warning: null, }, }, { @@ -521,6 +523,7 @@ describe('enable()', () => { lastDuration: 0, lastExecutionDate: '2019-02-12T21:01:22.479Z', error: null, + warning: null, }, }, { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index fcf90bc350362..68d05c80faab1 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -92,6 +92,7 @@ const BaseRuleSavedObject: SavedObject = { status: 'unknown', lastExecutionDate: '2020-08-20T19:23:38Z', error: null, + warning: null, }, }, references: [], diff --git a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts index c45faf770ac67..489dbb27b9e18 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts @@ -91,6 +91,11 @@ export function getBeforeSetup( isExportable: true, async executor() {}, producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, })); rulesClientParams.getEventLogClient.mockResolvedValue( eventLogClient ?? eventLogClientMock.create() diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index 5e1bc99e9378e..f6b058f5bdb66 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -295,7 +295,7 @@ function setupRawAlertMocks( // splitting this out as it's easier to set a breakpoint :-) // eslint-disable-next-line prettier/prettier - unsecuredSavedObjectsClient.get.mockImplementation(async () => + unsecuredSavedObjectsClient.get.mockImplementation(async () => cloneDeep(rawAlert) ); diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json index e6eedced78914..a027dd389575e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json @@ -163,6 +163,16 @@ "type": "keyword" } } + }, + "warning": { + "properties": { + "reason": { + "type": "keyword" + }, + "message": { + "type": "keyword" + } + } } } }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 09d505aec0f0c..0e3dc072366c2 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -17,7 +17,7 @@ import { SavedObjectAttribute, SavedObjectReference, } from '../../../../../src/core/server'; -import { RawRule, RawAlertAction } from '../types'; +import { RawRule, RawAlertAction, RawRuleExecutionStatus } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; @@ -280,7 +280,7 @@ function initializeExecutionStatus( status: 'pending', lastExecutionDate: new Date().toISOString(), error: null, - }, + } as RawRuleExecutionStatus, }, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 5ef1d67298b4e..11e50c55f5735 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { createExecutionHandler, CreateExecutionHandlerOptions } from './create_execution_handler'; +import { createExecutionHandler } from './create_execution_handler'; +import { ActionsCompletion, AlertExecutionStore, CreateExecutionHandlerOptions } from './types'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { - actionsMock, actionsClientMock, + actionsMock, renderActionParameterTemplatesDefault, } from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; @@ -18,10 +19,10 @@ import { asSavedObjectExecutionSource } from '../../../actions/server'; import { InjectActionParamsOpts } from './inject_action_params'; import { NormalizedRuleType } from '../rule_type_registry'; import { + AlertInstanceContext, + AlertInstanceState, AlertTypeParams, AlertTypeState, - AlertInstanceState, - AlertInstanceContext, } from '../types'; jest.mock('./inject_action_params', () => ({ @@ -52,6 +53,11 @@ const ruleType: NormalizedRuleType< }, executor: jest.fn(), producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, }; const actionsClient = actionsClientMock.create(); @@ -102,6 +108,7 @@ const createExecutionHandlerParams: jest.Mocked< supportsEphemeralTasks: false, maxEphemeralActionsPerRule: 10, }; +let alertExecutionStore: AlertExecutionStore; describe('Create Execution Handler', () => { beforeEach(() => { @@ -117,17 +124,22 @@ describe('Create Execution Handler', () => { mockActionsPlugin.renderActionParameterTemplates.mockImplementation( renderActionParameterTemplatesDefault ); + alertExecutionStore = { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }; }); test('enqueues execution per selected action', async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); - const result = await executionHandler({ + await executionHandler({ actionGroup: 'default', state: {}, context: {}, alertId: '2', + alertExecutionStore, }); - expect(result).toHaveLength(1); + expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith( createExecutionHandlerParams.request ); @@ -228,6 +240,8 @@ describe('Create Execution Handler', () => { stateVal: 'My goes here', }, }); + + expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.COMPLETE); }); test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { @@ -256,7 +270,9 @@ describe('Create Execution Handler', () => { state: {}, context: {}, alertId: '2', + alertExecutionStore, }); + expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({ id: '2', @@ -304,13 +320,14 @@ describe('Create Execution Handler', () => { ], }); - const result = await executionHandler({ + await executionHandler({ actionGroup: 'default', state: {}, context: {}, alertId: '2', + alertExecutionStore, }); - expect(result).toEqual([]); + expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); mockActionsPlugin.isActionExecutable.mockImplementation(() => true); @@ -323,31 +340,34 @@ describe('Create Execution Handler', () => { state: {}, context: {}, alertId: '2', + alertExecutionStore, }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); }); test('limits actionsPlugin.execute per action group', async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); - const result = await executionHandler({ + await executionHandler({ actionGroup: 'other-group', state: {}, context: {}, alertId: '2', + alertExecutionStore, }); - expect(result).toEqual([]); + expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); expect(actionsClient.enqueueExecution).not.toHaveBeenCalled(); }); test('context attribute gets parameterized', async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); - const result = await executionHandler({ + await executionHandler({ actionGroup: 'default', context: { value: 'context-val' }, state: {}, alertId: '2', + alertExecutionStore, }); - expect(result).toHaveLength(1); + expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -384,13 +404,13 @@ describe('Create Execution Handler', () => { test('state attribute gets parameterized', async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); - const result = await executionHandler({ + await executionHandler({ actionGroup: 'default', context: {}, state: { value: 'state-val' }, alertId: '2', + alertExecutionStore, }); - expect(result).toHaveLength(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -427,17 +447,74 @@ describe('Create Execution Handler', () => { test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); - const result = await executionHandler({ + await executionHandler({ // we have to trick the compiler as this is an invalid type and this test checks whether we // enforce this at runtime as well as compile time actionGroup: 'invalid-group' as 'default' | 'other-group', context: {}, state: {}, alertId: '2', + alertExecutionStore, }); - expect(result).toEqual([]); expect(createExecutionHandlerParams.logger.error).toHaveBeenCalledWith( 'Invalid action group "invalid-group" for rule "test".' ); + + expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); + expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.COMPLETE); + }); + + test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { + const executionHandler = createExecutionHandler({ + ...createExecutionHandlerParams, + ruleType: { + ...ruleType, + config: { + execution: { + actions: { max: 2 }, + }, + }, + }, + actions: [ + ...createExecutionHandlerParams.actions, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test3', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }); + + alertExecutionStore = { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }; + + await executionHandler({ + actionGroup: 'default', + context: {}, + state: { value: 'state-val' }, + alertId: '2', + alertExecutionStore, + }); + + expect(alertExecutionStore.numberOfTriggeredActions).toBe(2); + expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.PARTIAL); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index febae5a092a32..c4cfc66c9acbb 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -4,73 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { transformActionParams } from './transform_action_params'; -import { - asSavedObjectExecutionSource, - PluginStartContract as ActionsPluginStartContract, -} from '../../../actions/server'; -import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; +import { asSavedObjectExecutionSource } from '../../../actions/server'; +import { SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { injectActionParams } from './inject_action_params'; import { - AlertAction, + AlertInstanceContext, + AlertInstanceState, AlertTypeParams, AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - RawRule, } from '../types'; -import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; + +import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { isEphemeralTaskRejectedDueToCapacityError } from '../../../task_manager/server'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; - -export interface CreateExecutionHandlerOptions< - Params extends AlertTypeParams, - ExtractedParams extends AlertTypeParams, - State extends AlertTypeState, - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string -> { - ruleId: string; - ruleName: string; - executionId: string; - tags?: string[]; - actionsPlugin: ActionsPluginStartContract; - actions: AlertAction[]; - spaceId: string; - apiKey: RawRule['apiKey']; - kibanaBaseUrl: string | undefined; - ruleType: NormalizedRuleType< - Params, - ExtractedParams, - State, - InstanceState, - InstanceContext, - ActionGroupIds, - RecoveryActionGroupId - >; - logger: Logger; - eventLogger: IEventLogger; - request: KibanaRequest; - ruleParams: AlertTypeParams; - supportsEphemeralTasks: boolean; - maxEphemeralActionsPerRule: number; -} - -interface ExecutionHandlerOptions { - actionGroup: ActionGroupIds; - actionSubgroup?: string; - alertId: string; - context: AlertInstanceContext; - state: AlertInstanceState; -} +import { ActionsCompletion, CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; export type ExecutionHandler = ( options: ExecutionHandlerOptions -) => Promise; +) => Promise; export function createExecutionHandler< Params extends AlertTypeParams, @@ -114,13 +67,14 @@ export function createExecutionHandler< actionSubgroup, context, state, + alertExecutionStore, alertId, }: ExecutionHandlerOptions) => { - const triggeredActions: AlertAction[] = []; if (!ruleTypeActionGroups.has(actionGroup)) { logger.error(`Invalid action group "${actionGroup}" for rule "${ruleType.id}".`); - return triggeredActions; + return; } + const actions = ruleActions .filter(({ group }) => group === actionGroup) .map((action) => { @@ -163,6 +117,11 @@ export function createExecutionHandler< let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; for (const action of actions) { + if (alertExecutionStore.numberOfTriggeredActions >= ruleType.config!.execution.actions.max) { + alertExecutionStore.triggeredActionsStatus = ActionsCompletion.PARTIAL; + break; + } + if ( !actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true }) ) { @@ -205,11 +164,11 @@ export function createExecutionHandler< await actionsClient.enqueueExecution(enqueueOptions); } } finally { - triggeredActions.push(action); + alertExecutionStore.numberOfTriggeredActions++; } } else { await actionsClient.enqueueExecution(enqueueOptions); - triggeredActions.push(action); + alertExecutionStore.numberOfTriggeredActions++; } const event = createAlertEventLogRecordObject({ @@ -244,6 +203,5 @@ export function createExecutionHandler< eventLogger.logEvent(event); } - return triggeredActions; }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 07f2487de20fb..1a20ab28dfe13 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -53,7 +53,15 @@ export const RULE_ACTIONS = [ }, ]; -export const SAVED_OBJECT_UPDATE_PARAMS = [ +export const generateSavedObjectParams = ({ + error = null, + warning = null, + status = 'ok', +}: { + error?: null | { reason: string; message: string }; + warning?: null | { reason: string; message: string }; + status?: string; +}) => [ 'alert', '1', { @@ -71,10 +79,11 @@ export const SAVED_OBJECT_UPDATE_PARAMS = [ }, }, executionStatus: { - error: null, + error, lastDuration: 0, lastExecutionDate: '1970-01-01T00:00:00.000Z', - status: 'ok', + status, + warning, }, }, { refresh: false, namespace: undefined }, @@ -92,6 +101,11 @@ export const ruleType: jest.Mocked = { recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', + config: { + execution: { + actions: { max: 1000 }, + }, + }, }; export const mockRunNowResponse = { @@ -189,6 +203,7 @@ export const generateEventLog = ({ instanceId, actionSubgroup, actionGroupId, + actionId, status, numberOfTriggeredActions, savedObjects = [generateAlertSO('1')], @@ -236,11 +251,19 @@ export const generateEventLog = ({ ...(task && { task: { schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', + scheduled: DATE_1970, }, }), }, - message: generateMessage({ action, instanceId, actionGroupId, actionSubgroup, reason, status }), + message: generateMessage({ + action, + instanceId, + actionGroupId, + actionSubgroup, + reason, + status, + actionId, + }), rule: { category: 'test', id: '1', @@ -255,6 +278,7 @@ const generateMessage = ({ instanceId, actionGroupId, actionSubgroup, + actionId, reason, status, }: GeneratorParams) => { @@ -279,9 +303,9 @@ const generateMessage = ({ if (action === EVENT_LOG_ACTIONS.executeAction) { if (actionSubgroup) { - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${instanceId}`; + return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${actionId}`; } - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${instanceId}`; + return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; } if (action === EVENT_LOG_ACTIONS.execute) { @@ -292,7 +316,7 @@ const generateMessage = ({ return `${RULE_TYPE_ID}:${RULE_ID}: execution failed`; } if (actionGroupId === 'recovered') { - return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${instanceId}`; + return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; } return `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; } @@ -310,6 +334,7 @@ export const generateRunnerResult = ({ history = Array(false), state = false, interval = '10s', + alertInstances = {}, }: GeneratorParams = {}) => { return { monitoring: { @@ -325,7 +350,7 @@ export const generateRunnerResult = ({ interval, }, state: { - ...(state && { alertInstances: {} }), + ...(state && { alertInstances }), ...(state && { alertTypeState: undefined }), ...(state && { previousStartedAt: new Date('1970-01-01T00:00:00.000Z') }), }, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index bdebc66911e94..81e6f50b91aaa 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -14,6 +14,7 @@ import { AlertTypeState, AlertInstanceState, AlertInstanceContext, + AlertExecutionStatusWarningReasons, } from '../types'; import { ConcreteTaskInstance, @@ -55,7 +56,7 @@ import { generateRunnerResult, RULE_ACTIONS, generateEnqueueFunctionInput, - SAVED_OBJECT_UPDATE_PARAMS, + generateSavedObjectParams, mockTaskInstance, GENERIC_ERROR_MESSAGE, generateAlertInstance, @@ -65,6 +66,7 @@ import { DATE_1970_5_MIN, } from './fixtures'; import { EVENT_LOG_ACTIONS } from '../plugin'; +import { translations } from '../constants/translations'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -240,7 +242,7 @@ describe('Task Runner', () => { expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...SAVED_OBJECT_UPDATE_PARAMS); + ).toHaveBeenCalledWith(...generateSavedObjectParams({})); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toHaveBeenCalledWith( @@ -345,6 +347,7 @@ describe('Task Runner', () => { instanceId: '1', actionSubgroup: 'subDefault', savedObjects: [generateAlertSO('1'), generateActionSO('1')], + actionId: '1', }) ); expect(eventLogger.logEvent).toHaveBeenNthCalledWith( @@ -916,6 +919,7 @@ describe('Task Runner', () => { action: EVENT_LOG_ACTIONS.executeAction, actionGroupId: 'default', instanceId: '1', + actionId: '1', savedObjects: [generateAlertSO('1'), generateActionSO('1')], }) ); @@ -1043,9 +1047,10 @@ describe('Task Runner', () => { 4, generateEventLog({ action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'recovered', - instanceId: '2', + savedObjects: [generateAlertSO('1'), generateActionSO('1')], + actionGroupId: 'default', + instanceId: '1', + actionId: '1', }) ); @@ -1053,9 +1058,10 @@ describe('Task Runner', () => { 5, generateEventLog({ action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', + savedObjects: [generateAlertSO('1'), generateActionSO('2')], + actionGroupId: 'recovered', + instanceId: '2', + actionId: '2', }) ); expect(eventLogger.logEvent).toHaveBeenNthCalledWith( @@ -1142,8 +1148,8 @@ describe('Task Runner', () => { const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); expect(enqueueFunction).toHaveBeenCalledTimes(2); - expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('1'); - expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('2'); + expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('2'); + expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('1'); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -2315,7 +2321,7 @@ describe('Task Runner', () => { ); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...SAVED_OBJECT_UPDATE_PARAMS); + ).toHaveBeenCalledWith(...generateSavedObjectParams({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2449,4 +2455,179 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult.monitoring?.execution.history.length).toBe(200); }); + + test('Actions circuit breaker kicked in, should set status as warning and log a message in event log', async () => { + const ruleTypeWithConfig = { + ...ruleType, + config: { + execution: { + actions: { max: 3 }, + }, + }, + }; + + const warning = { + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: translations.taskRunner.warning.maxExecutableActions, + }; + + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + ruleType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertFactory.create('1').scheduleActions('default'); + } + ); + + rulesClient.get.mockResolvedValue({ + ...mockedRuleTypeSavedObject, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: 'action', + }, + { + group: 'default', + id: '2', + actionTypeId: 'action', + }, + { + group: 'default', + id: '3', + actionTypeId: 'action', + }, + { + group: 'default', + id: '4', + actionTypeId: 'action', + }, + { + group: 'default', + id: '5', + actionTypeId: 'action', + }, + ], + } as jest.ResolvedValue); + ruleTypeRegistry.get.mockReturnValue(ruleTypeWithConfig); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); + + const taskRunner = new TaskRunner( + ruleTypeWithConfig, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + const runnerResult = await taskRunner.run(); + + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes( + ruleTypeWithConfig.config.execution.actions.max + ); + + expect( + taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update + ).toHaveBeenCalledWith(...generateSavedObjectParams({ status: 'warning', warning })); + + expect(runnerResult).toEqual( + generateRunnerResult({ + state: true, + history: [true], + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + date: new Date(DATE_1970), + group: 'default', + }, + }, + state: { + duration: 0, + start: '1970-01-01T00:00:00.000Z', + }, + }, + }, + }) + ); + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(7); + + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 1, + generateEventLog({ + task: true, + action: EVENT_LOG_ACTIONS.executeStart, + }) + ); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 2, + generateEventLog({ + duration: 0, + start: DATE_1970, + action: EVENT_LOG_ACTIONS.newInstance, + actionGroupId: 'default', + instanceId: '1', + }) + ); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 3, + generateEventLog({ + duration: 0, + start: DATE_1970, + action: EVENT_LOG_ACTIONS.activeInstance, + actionGroupId: 'default', + instanceId: '1', + }) + ); + + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 4, + generateEventLog({ + action: EVENT_LOG_ACTIONS.executeAction, + savedObjects: [generateAlertSO('1'), generateActionSO('1')], + actionGroupId: 'default', + instanceId: '1', + actionId: '1', + }) + ); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 5, + generateEventLog({ + action: EVENT_LOG_ACTIONS.executeAction, + savedObjects: [generateAlertSO('1'), generateActionSO('2')], + actionGroupId: 'default', + instanceId: '1', + actionId: '2', + }) + ); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 6, + generateEventLog({ + action: EVENT_LOG_ACTIONS.executeAction, + savedObjects: [generateAlertSO('1'), generateActionSO('3')], + actionGroupId: 'default', + instanceId: '1', + actionId: '3', + }) + ); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + 7, + generateEventLog({ + action: EVENT_LOG_ACTIONS.execute, + outcome: 'success', + status: 'warning', + numberOfTriggeredActions: ruleTypeWithConfig.config.execution.actions.max, + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + task: true, + }) + ); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index dce662373fc96..595400b1fad16 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -5,56 +5,57 @@ * 2.0. */ import apm from 'elastic-apm-node'; -import { pickBy, mapValues, without, cloneDeep, concat, set, omit } from 'lodash'; +import { cloneDeep, mapValues, omit, pickBy, set, without } from 'lodash'; import type { Request } from '@hapi/hapi'; import { UsageCounter } from 'src/plugins/usage_collection/server'; import uuid from 'uuid'; import { addSpaceIdToPath } from '../../../spaces/server'; -import { Logger, KibanaRequest } from '../../../../../src/core/server'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_manager/server'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert as CreatedAlert, createAlertFactory } from '../alert'; import { - validateRuleTypeParams, - executionStatusFromState, + createWrappedScopedClusterClientFactory, + ElasticsearchError, + ErrorWithReason, executionStatusFromError, + executionStatusFromState, + getRecoveredAlerts, ruleExecutionStatusToRaw, - ErrorWithReason, - ElasticsearchError, + validateRuleTypeParams, } from '../lib'; import { - RawRule, - IntervalSchedule, - RawAlertInstance, - RuleTaskState, Alert, - SanitizedAlert, AlertExecutionStatus, AlertExecutionStatusErrorReasons, - RuleTypeRegistry, - RuleMonitoring, - RuleMonitoringHistory, + IntervalSchedule, + RawAlertInstance, + RawRule, RawRuleExecutionStatus, - AlertAction, - RuleExecutionState, RuleExecutionRunResult, + RuleExecutionState, + RuleMonitoring, + RuleMonitoringHistory, + RuleTaskState, + RuleTypeRegistry, + SanitizedAlert, } from '../types'; -import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; -import { getExecutionSuccessRatio, getExecutionDurationPercentiles } from '../lib/monitoring'; +import { asErr, asOk, map, promiseResult, resolveErr, Resultable } from '../lib/result_type'; +import { getExecutionDurationPercentiles, getExecutionSuccessRatio } from '../lib/monitoring'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { isAlertSavedObjectNotFoundError, isEsUnavailableError } from '../lib/is_alerting_error'; import { partiallyUpdateAlert } from '../saved_objects'; import { + AlertInstanceContext, + AlertInstanceState, AlertTypeParams, AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - WithoutReservedActionGroups, - parseDuration, MONITORING_HISTORY_LIMIT, + parseDuration, + WithoutReservedActionGroups, } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; @@ -62,9 +63,9 @@ import { createAlertEventLogRecordObject, Event, } from '../lib/create_alert_event_log_record_object'; -import { createWrappedScopedClusterClientFactory } from '../lib'; -import { getRecoveredAlerts } from '../lib'; import { + ActionsCompletion, + AlertExecutionStore, GenerateNewAndRecoveredAlertEventsParams, LogActiveAndRecoveredAlertsParams, RuleTaskInstance, @@ -269,7 +270,8 @@ export class TaskRunner< private async executeAlert( alertId: string, alert: CreatedAlert, - executionHandler: ExecutionHandler + executionHandler: ExecutionHandler, + alertExecutionStore: AlertExecutionStore ) { const { actionGroup, @@ -279,7 +281,14 @@ export class TaskRunner< } = alert.getScheduledActionOptions()!; alert.updateLastScheduledActions(actionGroup, actionSubgroup); alert.unscheduleActions(); - return executionHandler({ actionGroup, actionSubgroup, context, state, alertId }); + return executionHandler({ + actionGroup, + actionSubgroup, + context, + state, + alertId, + alertExecutionStore, + }); } private async executeAlerts( @@ -462,26 +471,15 @@ export class TaskRunner< }); } - let triggeredActions: AlertAction[] = []; + const alertExecutionStore: AlertExecutionStore = { + numberOfTriggeredActions: 0, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }; + if (!muteAll && this.shouldLogAndScheduleActionsForAlerts()) { const mutedAlertIdsSet = new Set(mutedInstanceIds); - const scheduledActionsForRecoveredAlerts = await scheduleActionsForRecoveredAlerts< - InstanceState, - InstanceContext, - RecoveryActionGroupId - >({ - recoveryActionGroup: this.ruleType.recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger: this.logger, - ruleLabel, - }); - - triggeredActions = concat(triggeredActions, scheduledActionsForRecoveredAlerts); - - const alertsToExecute = Object.entries(alertsWithScheduledActions).filter( + const alertsWithExecutableActions = Object.entries(alertsWithScheduledActions).filter( ([alertName, alert]: [string, CreatedAlert]) => { const throttled = alert.isThrottled(throttle); const muted = mutedAlertIdsSet.has(alertName); @@ -508,14 +506,26 @@ export class TaskRunner< } ); - const allTriggeredActions = await Promise.all( - alertsToExecute.map( + await Promise.all( + alertsWithExecutableActions.map( ([alertId, alert]: [string, CreatedAlert]) => - this.executeAlert(alertId, alert, executionHandler) + this.executeAlert(alertId, alert, executionHandler, alertExecutionStore) ) ); - triggeredActions = concat(triggeredActions, ...allTriggeredActions); + await scheduleActionsForRecoveredAlerts< + InstanceState, + InstanceContext, + RecoveryActionGroupId + >({ + recoveryActionGroup: this.ruleType.recoveryActionGroup, + recoveredAlerts, + executionHandler, + mutedAlertIdsSet, + logger: this.logger, + ruleLabel, + alertExecutionStore, + }); } else { if (muteAll) { this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is muted.`); @@ -535,7 +545,7 @@ export class TaskRunner< return { metrics: searchMetrics, - triggeredActions, + alertExecutionStore, alertTypeState: updatedRuleTypeState || undefined, alertInstances: mapValues< Record>, @@ -639,7 +649,7 @@ export class TaskRunner< ), schedule: asOk( // fetch the rule again to ensure we return the correct schedule as it may have - // cahnged during the task execution + // changed during the task execution (await rulesClient.get({ id: ruleId })).schedule ), }; @@ -714,7 +724,6 @@ export class TaskRunner< (ruleExecutionState) => executionStatusFromState(ruleExecutionState), (err: ElasticsearchError) => executionStatusFromError(err) ); - // set the executionStatus date to same as event, if it's set if (event.event?.start) { executionStatus.lastExecutionDate = new Date(event.event.start); @@ -757,6 +766,10 @@ export class TaskRunner< } monitoringHistory.success = false; } else { + if (executionStatus.warning) { + set(event, 'event.reason', executionStatus.warning?.reason || 'unknown'); + set(event, 'message', event?.message || executionStatus.warning.message); + } set( event, 'kibana.alert.rule.execution.metrics.number_of_triggered_actions', @@ -807,7 +820,7 @@ export class TaskRunner< executionState: RuleExecutionState ): RuleTaskState => { return { - ...omit(executionState, ['triggeredActions', 'metrics']), + ...omit(executionState, ['alertExecutionStore', 'metrics']), previousStartedAt: startedAt, }; }; @@ -1110,7 +1123,7 @@ async function scheduleActionsForRecoveredAlerts< InstanceContext, RecoveryActionGroupId > -): Promise { +): Promise { const { logger, recoveryActionGroup, @@ -1118,9 +1131,10 @@ async function scheduleActionsForRecoveredAlerts< executionHandler, mutedAlertIdsSet, ruleLabel, + alertExecutionStore, } = params; const recoveredIds = Object.keys(recoveredAlerts); - let triggeredActions: AlertAction[] = []; + for (const id of recoveredIds) { if (mutedAlertIdsSet.has(id)) { logger.debug( @@ -1130,17 +1144,16 @@ async function scheduleActionsForRecoveredAlerts< const alert = recoveredAlerts[id]; alert.updateLastScheduledActions(recoveryActionGroup.id); alert.unscheduleActions(); - const triggeredActionsForRecoveredAlert = await executionHandler({ + await executionHandler({ actionGroup: recoveryActionGroup.id, context: alert.getContext(), state: {}, alertId: id, + alertExecutionStore, }); alert.scheduleActions(recoveryActionGroup.id); - triggeredActions = concat(triggeredActions, triggeredActionsForRecoveredAlert); } } - return triggeredActions; } function logActiveAndRecoveredAlerts< diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index add8d7a24912d..c297d4739e11c 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -55,6 +55,11 @@ const ruleType: jest.Mocked = { producer: 'alerts', cancelAlertsOnRuleTimeout: true, ruleTaskTimeout: '5m', + config: { + execution: { + actions: { max: 1000 }, + }, + }, }; let fakeTimer: sinon.SinonFakeTimers; @@ -362,6 +367,7 @@ describe('Task Runner Cancel', () => { lastDuration: 0, lastExecutionDate: '1970-01-01T00:00:00.000Z', status: 'error', + warning: null, }, }, { refresh: false, namespace: undefined } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index c14ccfbef3220..843af6b1d16d2 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -6,9 +6,10 @@ */ import { Dictionary } from 'lodash'; -import { Logger } from 'kibana/server'; +import { KibanaRequest, Logger } from 'kibana/server'; import { ActionGroup, + AlertAction, AlertInstanceContext, AlertInstanceState, AlertTypeParams, @@ -24,6 +25,8 @@ import { Alert as CreatedAlert } from '../alert'; import { IEventLogger } from '../../../event_log/server'; import { NormalizedRuleType } from '../rule_type_registry'; import { ExecutionHandler } from './create_execution_handler'; +import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; +import { RawRule } from '../types'; export interface RuleTaskRunResultWithActions { state: RuleExecutionState; @@ -89,6 +92,7 @@ export interface ScheduleActionsForRecoveredAlertsParams< executionHandler: ExecutionHandler; mutedAlertIdsSet: Set; ruleLabel: string; + alertExecutionStore: AlertExecutionStore; } export interface LogActiveAndRecoveredAlertsParams< @@ -103,3 +107,59 @@ export interface LogActiveAndRecoveredAlertsParams< ruleLabel: string; canSetRecoveryContext: boolean; } + +// / ExecutionHandler + +export interface CreateExecutionHandlerOptions< + Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, + State extends AlertTypeState, + InstanceState extends AlertInstanceState, + InstanceContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + ruleId: string; + ruleName: string; + executionId: string; + tags?: string[]; + actionsPlugin: ActionsPluginStartContract; + actions: AlertAction[]; + spaceId: string; + apiKey: RawRule['apiKey']; + kibanaBaseUrl: string | undefined; + ruleType: NormalizedRuleType< + Params, + ExtractedParams, + State, + InstanceState, + InstanceContext, + ActionGroupIds, + RecoveryActionGroupId + >; + logger: Logger; + eventLogger: IEventLogger; + request: KibanaRequest; + ruleParams: AlertTypeParams; + supportsEphemeralTasks: boolean; + maxEphemeralActionsPerRule: number; +} + +export interface ExecutionHandlerOptions { + actionGroup: ActionGroupIds; + actionSubgroup?: string; + alertId: string; + context: AlertInstanceContext; + state: AlertInstanceState; + alertExecutionStore: AlertExecutionStore; +} + +export enum ActionsCompletion { + COMPLETE = 'complete', + PARTIAL = 'partial', +} + +export interface AlertExecutionStore { + numberOfTriggeredActions: number; + triggeredActionsStatus: ActionsCompletion; +} diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index aacdcef511614..b947ed93ecc50 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -39,9 +39,10 @@ import { SanitizedRuleConfig, RuleMonitoring, MappedParams, + AlertExecutionStatusWarningReasons, } from '../common'; import { LicenseType } from '../../licensing/server'; - +import { RulesConfig } from './config'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -124,6 +125,7 @@ export type ExecutorType< export interface AlertTypeParamsValidator { validate: (object: unknown) => Params; } + export interface RuleType< Params extends AlertTypeParams = never, ExtractedParams extends AlertTypeParams = never, @@ -168,6 +170,7 @@ export interface RuleType< ruleTaskTimeout?: string; cancelAlertsOnRuleTimeout?: boolean; doesSetRecoveryContext?: boolean; + config?: RulesConfig; } export type UntypedRuleType = RuleType< AlertTypeParams, @@ -199,6 +202,10 @@ export interface RawRuleExecutionStatus extends SavedObjectAttributes { reason: AlertExecutionStatusErrorReasons; message: string; }; + warning: null | { + reason: AlertExecutionStatusWarningReasons; + message: string; + }; } export type PartialAlert = Pick, 'id'> & diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index 0296fdb73b951..afff097776e19 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -12,6 +12,7 @@ import { RULE_STATUS_ERROR, RULE_STATUS_PENDING, RULE_STATUS_UNKNOWN, + RULE_STATUS_WARNING, } from './translations'; import { AlertExecutionStatuses } from '../../../../alerting/common'; import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; @@ -50,6 +51,7 @@ export const rulesStatusesTranslationsMapping = { error: RULE_STATUS_ERROR, pending: RULE_STATUS_PENDING, unknown: RULE_STATUS_UNKNOWN, + warning: RULE_STATUS_WARNING, }; export const OBSERVABILITY_RULE_TYPES = [ diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index 98d2008fd340c..b72d03bf8e566 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -46,6 +46,13 @@ export const RULE_STATUS_UNKNOWN = i18n.translate( } ); +export const RULE_STATUS_WARNING = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusWarning', + { + defaultMessage: 'warning', + } +); + export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index 4af24e9a44602..e6b5fdbdb1883 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -23,6 +23,7 @@ import { import { ActionGroup, AlertExecutionStatusErrorReasons, + AlertExecutionStatusWarningReasons, ALERTS_FEATURE_ID, } from '../../../../../../alerting/common'; import { useKibana } from '../../../../common/lib/kibana'; @@ -119,6 +120,28 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('renders the rule warning banner with warning message, when rule status is a warning', () => { + const rule = mockRule({ + executionStatus: { + status: 'warning', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'warning message', + }, + }, + }); + expect( + shallow( + + ).containsMatchingElement( + + {'warning message'} + + ) + ).toBeTruthy(); + }); + describe('actions', () => { it('renders an rule action', () => { const rule = mockRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index becf1376c8900..de948c2fd21de 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -40,7 +40,10 @@ import { RuleRouteWithApi } from './rule_route'; import { ViewInApp } from './view_in_app'; import { RuleEdit } from '../../rule_form'; import { routeToRuleDetails } from '../../../constants'; -import { rulesErrorReasonTranslationsMapping } from '../../rules_list/translations'; +import { + rulesErrorReasonTranslationsMapping, + rulesWarningReasonTranslationsMapping, +} from '../../rules_list/translations'; import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; @@ -135,7 +138,8 @@ export const RuleDetails: React.FunctionComponent = ({ const [isMutedUpdating, setIsMutedUpdating] = useState(false); const [isMuted, setIsMuted] = useState(rule.muteAll); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); - const [dissmissRuleErrors, setDissmissRuleErrors] = useState(false); + const [dismissRuleErrors, setDismissRuleErrors] = useState(false); + const [dismissRuleWarning, setDismissRuleWarning] = useState(false); const setRule = async () => { history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); @@ -149,6 +153,14 @@ export const RuleDetails: React.FunctionComponent = ({ } }; + const getRuleStatusWarningReasonText = () => { + if (rule.executionStatus.warning && rule.executionStatus.warning.reason) { + return rulesWarningReasonTranslationsMapping[rule.executionStatus.warning.reason]; + } else { + return rulesWarningReasonTranslationsMapping.unknown; + } + }; + const rightPageHeaderButtons = hasEditButton ? [ <> @@ -294,7 +306,7 @@ export const RuleDetails: React.FunctionComponent = ({ setIsEnabled(false); await disableRule(rule); // Reset dismiss if previously clicked - setDissmissRuleErrors(false); + setDismissRuleErrors(false); } else { setIsEnabled(true); await enableRule(rule); @@ -357,7 +369,7 @@ export const RuleDetails: React.FunctionComponent = ({ - {rule.enabled && !dissmissRuleErrors && rule.executionStatus.status === 'error' ? ( + {rule.enabled && !dismissRuleErrors && rule.executionStatus.status === 'error' ? ( = ({ setDissmissRuleErrors(true)} + onClick={() => setDismissRuleErrors(true)} > = ({ ) : null} + + {rule.enabled && !dismissRuleWarning && rule.executionStatus.status === 'warning' ? ( + + + + + {rule.executionStatus.warning?.message} + + + + + setDismissRuleWarning(true)} + > + + + + + + + + ) : null} {hasActionsWithBrokenConnector && ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index 2f2b1914bef09..d7184fc6ce400 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -102,6 +102,8 @@ export function getHealthColor(status: AlertExecutionStatuses) { return 'primary'; case 'pending': return 'accent'; + case 'warning': + return 'warning'; default: return 'subdued'; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 6d6afab22dae2..b834adc905f50 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -15,6 +15,7 @@ import { RulesList, percentileFields } from './rules_list'; import { RuleTypeModel, ValidationResult, Percentiles } from '../../../../types'; import { AlertExecutionStatusErrorReasons, + AlertExecutionStatusWarningReasons, ALERTS_FEATURE_ID, parseDuration, } from '../../../../../../alerting/common'; @@ -30,6 +31,7 @@ jest.mock('../../../lib/action_connector_api', () => ({ jest.mock('../../../lib/rule_api', () => ({ loadRules: jest.fn(), loadRuleTypes: jest.fn(), + loadRuleAggregations: jest.fn(), alertingFrameworkHealth: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true, @@ -55,7 +57,8 @@ jest.mock('../../../lib/capabilities', () => ({ hasShowActionsCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), })); -const { loadRules, loadRuleTypes } = jest.requireMock('../../../lib/rule_api'); +const { loadRules, loadRuleTypes, loadRuleAggregations } = + jest.requireMock('../../../lib/rule_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); const actionTypeRegistry = actionTypeRegistryMock.create(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -331,6 +334,32 @@ describe('rules_list component with items', () => { }, }, }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, ]; async function setup(editable: boolean = true) { @@ -352,6 +381,11 @@ describe('rules_list component with items', () => { ]); loadRuleTypes.mockResolvedValue([ruleTypeFromApi]); loadAllActions.mockResolvedValue([]); + loadRuleAggregations.mockResolvedValue({ + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + }); const ruleTypeMock: RuleTypeModel = { id: 'test_rule_type', @@ -381,6 +415,7 @@ describe('rules_list component with items', () => { expect(loadRules).toHaveBeenCalled(); expect(loadActionTypes).toHaveBeenCalled(); + expect(loadRuleAggregations).toHaveBeenCalled(); } it('renders table of rules', async () => { @@ -471,6 +506,7 @@ describe('rules_list component with items', () => { expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-pending"]').length).toEqual(1); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-unknown"]').length).toEqual(0); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').length).toEqual(2); + expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-warning"]').length).toEqual(1); expect(wrapper.find('[data-test-subj="ruleStatus-error-tooltip"]').length).toEqual(2); expect( wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length @@ -728,6 +764,28 @@ describe('rules_list component with items', () => { expect(wrapper.find('[data-test-subj="ruleSidebarEditAction"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="ruleSidebarDeleteAction"]').exists()).toBeTruthy(); }); + + it('renders brief', async () => { + await setup(); + + // { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 } + expect(wrapper.find('EuiHealth[data-test-subj="totalOkRulesCount"]').text()).toEqual('Ok: 1'); + expect(wrapper.find('EuiHealth[data-test-subj="totalActiveRulesCount"]').text()).toEqual( + 'Active: 2' + ); + expect(wrapper.find('EuiHealth[data-test-subj="totalErrorRulesCount"]').text()).toEqual( + 'Error: 3' + ); + expect(wrapper.find('EuiHealth[data-test-subj="totalPendingRulesCount"]').text()).toEqual( + 'Pending: 4' + ); + expect(wrapper.find('EuiHealth[data-test-subj="totalUnknownRulesCount"]').text()).toEqual( + 'Unknown: 5' + ); + expect(wrapper.find('EuiHealth[data-test-subj="totalWarningRulesCount"]').text()).toEqual( + 'Warning: 6' + ); + }); }); describe('rules_list component empty with show only capability', () => { @@ -893,7 +951,7 @@ describe('rules_list with show only capability', () => { }); }); -describe('rules_list with disabled itmes', () => { +describe('rules_list with disabled items', () => { let wrapper: ReactWrapper; async function setup() { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 4aa98895d5d97..c55f1303120f0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -986,6 +986,17 @@ export const RulesList: React.FunctionComponent = () => { /> + + + + + Date: Fri, 18 Mar 2022 13:42:26 -0700 Subject: [PATCH 03/38] [Reporting/Test] Split API test code under "usage" (#128001) * [Reporting/Test] Split API test code under "usage" * restore metrics tests * replace long strings with objects that encode to strings * Revert "replace long strings with objects that encode to strings" This reverts commit a93c841f37ba8e6f8c7aa211cd0f07191a49f9ca. * simplify post_urls --- .../reporting_and_security/usage.ts | 273 ------------------ .../usage/_post_urls.ts | 27 ++ .../usage/archived_data.ts | 56 ++++ .../reporting_and_security/usage/index.ts | 24 ++ .../reporting_and_security/usage/initial.ts | 48 +++ .../reporting_and_security/usage/metrics.ts | 111 +++++++ .../reporting_and_security/usage/new_jobs.ts | 90 ++++++ 7 files changed, 356 insertions(+), 273 deletions(-) delete mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts deleted file mode 100644 index 69a1f82cd3a67..0000000000000 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; -import { UsageStats } from '../services/usage'; - -// These all have the domain name portion stripped out. The api infrastructure assumes it when we post to it anyhow. -const PDF_PRINT_DASHBOARD_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F2ae34a60-3dd4-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%27145ced90-3dcb-11e8-8660-4d65aa086b3c!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:visualization,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!f,title:!%27couple%2Bpanels!%27,viewMode:view)%27),title:%27couple%20panels%27)'; -const PDF_PRESERVE_DASHBOARD_FILTER_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:439,width:1362),id:preserve_layout),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F61c58ad0-3dd3-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal:(query:dog,type:phrase))))),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%2750643b60-3dd3-11e8-b2b9-5d5dc1715159!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:a16d1990-3dca-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:search,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!t,title:!%27dashboard%2Bwith%2Bfilter!%27,viewMode:view)%27),title:%27dashboard%20with%20filter%27)'; -const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:441,width:1002),id:preserve_layout),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2F3fe22200-3dcb-11e8-8660-4d65aa086b3c%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!(),linked:!!f,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:bytes,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Rendering%2BTest:%2Bpie!%27,type:pie))%27),title:%27Rendering%20Test:%20pie%27)'; -const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2Fbefdb6b0-3e59-11e8-9fc3-39e49624228e%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Filter%2BTest:%2Banimals:%2Blinked%2Bto%2Bsearch%2Bwith%2Bfilter!%27,type:pie))%27),title:%27Filter%20Test:%20animals:%20linked%20to%20search%20with%20filter%27)'; -const JOB_PARAMS_CSV_DEFAULT_SPACE = - `/api/reporting/generate/csv_searchsource?jobParams=(columns:!(order_date,category,customer_full_name,taxful_total_price,currency),objectType:search,searchSource:(fields:!((field:'*',include_unmapped:true))` + - `,filter:!((meta:(field:order_date,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,params:()),query:(range:(order_date:(format:strict_date_optional_time,gte:'2019-06-02T12:28:40.866Z'` + - `,lte:'2019-07-18T20:59:57.136Z'))))),index:aac3e500-f2c7-11ea-8250-fb138aa491e7,parent:(filter:!(),highlightAll:!t,index:aac3e500-f2c7-11ea-8250-fb138aa491e7` + - `,query:(language:kuery,query:''),version:!t),sort:!((order_date:desc)),trackTotalHits:!t))`; -const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; -const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const reportingAPI = getService('reportingAPI'); - const retry = getService('retry'); - const usageAPI = getService('usageAPI'); - - describe('Usage', () => { - const deleteAllReports = () => reportingAPI.deleteAllReports(); - beforeEach(deleteAllReports); - after(deleteAllReports); - - describe('initial state', () => { - let usage: UsageStats; - - before(async () => { - await retry.try(async () => { - // use retry for stability - usage API could return 503 - usage = (await usageAPI.getUsageStats()) as UsageStats; - }); - }); - - it('shows reporting as available and enabled', async () => { - expect(usage.reporting.available).to.be(true); - expect(usage.reporting.enabled).to.be(true); - }); - - it('all counts are 0', async () => { - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 0); - }); - }); - - describe('from archive data', () => { - it('generated from 6.2', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_2'); - const usage = await usageAPI.getUsageStats(); - - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 7); - - // These statistics weren't tracked until 6.3 - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); - - await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_2'); - }); - - it('generated from 6.3', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_3'); - const usage = await usageAPI.getUsageStats(); - - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 12); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 3); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 3); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 3); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 3); - - await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_3'); - }); - }); - - describe('from new jobs posted', () => { - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); - await esArchiver.load(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.initEcommerce(); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.teardownEcommerce(); - }); - - it('should handle csv_searchsource', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([reportingAPI.postJob(JOB_PARAMS_CSV_DEFAULT_SPACE)]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 1); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - }); - - it('should handle preserve_layout pdf', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRESERVE_DASHBOARD_FILTER_6_3), - reportingAPI.postJob(PDF_PRESERVE_PIE_VISUALIZATION_6_3), - ]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 2); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); - }); - - it('should handle print_layout pdf', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRINT_DASHBOARD_6_3), - reportingAPI.postJob(PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), - ]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); - - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2); - }); - }); - - describe(`metrics and stats`, () => { - let reporting: UsageStats['reporting']; - let last7Days: UsageStats['reporting']['last_7_days']; - - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); - await esArchiver.load(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.initEcommerce(); - - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRINT_DASHBOARD_6_3), - reportingAPI.postJob(PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), - reportingAPI.postJob(JOB_PARAMS_CSV_DEFAULT_SPACE), - ]) - ); - - ({ reporting } = await usageAPI.getUsageStats()); - ({ last_7_days: last7Days } = reporting); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.teardownEcommerce(); - }); - - it('includes report stats', async () => { - // over all time - expectSnapshot(reporting._all).toMatchInline(`undefined`); - expect(reporting.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); - expectSnapshot(reporting.status).toMatchInline(` - Object { - "completed": 3, - "failed": 0, - } - `); - - // over last 7 days - expectSnapshot(last7Days._all).toMatchInline(`undefined`); - expect(last7Days.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); - expectSnapshot(last7Days.status).toMatchInline(` - Object { - "completed": 3, - "failed": 0, - } - `); - }); - - it('includes report statuses', async () => { - expectSnapshot(reporting.statuses).toMatchInline(`Object {}`); - - expectSnapshot(last7Days.statuses).toMatchInline(`Object {}`); - }); - - it('includes report metrics (not for job types under last_7_days)', async () => { - expect(reporting.printable_pdf.sizes).keys([ - '1_0', - '25_0', - '50_0', - '5_0', - '75_0', - '95_0', - '99_0', - ]); - expectSnapshot(reporting.printable_pdf.metrics?.pdf_pages).toMatchInline(` - Object { - "values": Object { - "50_0": 1, - "75_0": 1, - "95_0": 1, - "99_0": 1, - }, - } - `); - expectSnapshot(reporting.csv_searchsource.metrics?.csv_rows).toMatchInline(` - Object { - "values": Object { - "50_0": 71, - "75_0": 71, - "95_0": 71, - "99_0": 71, - }, - } - `); - }); - }); - }); -} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts new file mode 100644 index 0000000000000..510e94cf95f0d --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// These all have the domain name portion stripped out. The api infrastructure assumes it when we post to it anyhow. +export const PDF_PRINT_DASHBOARD_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(id:print),objectType:dashboard,relativeUrls:!('/app/kibana#/dashboard/2ae34a60-3dd4-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(description:!'!',filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!'1!',w:24,x:0,y:0),id:!'145ced90-3dcb-11e8-8660-4d65aa086b3c!',panelIndex:!'1!',type:visualization,version:!'6.3.0!'),(embeddableConfig:(),gridData:(h:15,i:!'2!',w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!'2!',type:visualization,version:!'6.3.0!')),query:(language:lucene,query:!'!'),timeRestore:!!f,title:!'couple+panels!',viewMode:view)'),title:'couple panels')` +)}`; + +export const PDF_PRESERVE_DASHBOARD_FILTER_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(dimensions:(height:439,width:1362),id:preserve_layout),objectType:dashboard,relativeUrls:!('/app/kibana#/dashboard/61c58ad0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(description:!'!',filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal:(query:dog,type:phrase))))),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!'1!',w:24,x:0,y:0),id:!'50643b60-3dd3-11e8-b2b9-5d5dc1715159!',panelIndex:!'1!',type:visualization,version:!'6.3.0!'),(embeddableConfig:(),gridData:(h:15,i:!'2!',w:24,x:24,y:0),id:a16d1990-3dca-11e8-8660-4d65aa086b3c,panelIndex:!'2!',type:search,version:!'6.3.0!')),query:(language:lucene,query:!'!'),timeRestore:!!t,title:!'dashboard+with+filter!',viewMode:view)'),title:'dashboard with filter')` +)}`; + +export const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(dimensions:(height:441,width:1002),id:preserve_layout),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/3fe22200-3dcb-11e8-8660-4d65aa086b3c?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!(),linked:!!f,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:bytes,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Rendering+Test:+pie!',type:pie))'),title:'Rendering Test: pie')` +)}`; + +export const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(id:print),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')` +)}`; + +export const JOB_PARAMS_CSV_DEFAULT_SPACE = `/api/reporting/generate/csv_searchsource?jobParams=${encodeURIComponent( + `(columns:!(order_date,category,customer_full_name,taxful_total_price,currency),objectType:search,searchSource:(fields:!((field:'*',include_unmapped:true)),filter:!((meta:(field:order_date,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,params:()),query:(range:(order_date:(format:strict_date_optional_time,gte:'2019-06-02T12:28:40.866Z',lte:'2019-07-18T20:59:57.136Z'))))),index:aac3e500-f2c7-11ea-8250-fb138aa491e7,parent:(filter:!(),highlightAll:!t,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,query:(language:kuery,query:''),version:!t),sort:!((order_date:desc)),trackTotalHits:!t))` +)}`; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts new file mode 100644 index 0000000000000..f81b29cb65572 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe('from archive data', () => { + it('generated from 6.2', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + const usage = await usageAPI.getUsageStats(); + + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 7); + + // These statistics weren't tracked until 6.3 + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); + + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + }); + + it('generated from 6.3', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_3'); + const usage = await usageAPI.getUsageStats(); + + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 12); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 3); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 3); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 3); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 3); + + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_3'); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts new file mode 100644 index 0000000000000..5b6dc7cc31ab0 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Usage', () => { + const deleteAllReports = () => reportingAPI.deleteAllReports(); + beforeEach(deleteAllReports); + after(deleteAllReports); + + loadTestFile(require.resolve('./archived_data')); + loadTestFile(require.resolve('./initial')); + loadTestFile(require.resolve('./metrics')); + loadTestFile(require.resolve('./new_jobs')); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts new file mode 100644 index 0000000000000..3104de755da88 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts @@ -0,0 +1,48 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { UsageStats } from '../../services/usage'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + const retry = getService('retry'); + const usageAPI = getService('usageAPI'); + + describe('initial state', () => { + let usage: UsageStats; + + before(async () => { + await retry.try(async () => { + // use retry for stability - usage API could return 503 + usage = (await usageAPI.getUsageStats()) as UsageStats; + }); + }); + + it('shows reporting as available and enabled', async () => { + expect(usage.reporting.available).to.be(true); + expect(usage.reporting.enabled).to.be(true); + }); + + it('all counts are 0', async () => { + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 0); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts new file mode 100644 index 0000000000000..1ba9f5b55570e --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts @@ -0,0 +1,111 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { UsageStats } from '../../services/usage'; +import * as urls from './_post_urls'; + +const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; +const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe(`metrics and stats`, () => { + let reporting: UsageStats['reporting']; + let last7Days: UsageStats['reporting']['last_7_days']; + + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); + await esArchiver.load(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.initEcommerce(); + + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRINT_DASHBOARD_6_3), + reportingAPI.postJob(urls.PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), + reportingAPI.postJob(urls.JOB_PARAMS_CSV_DEFAULT_SPACE), + ]) + ); + + ({ reporting } = await usageAPI.getUsageStats()); + ({ last_7_days: last7Days } = reporting); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.teardownEcommerce(); + }); + + it('includes report stats', async () => { + // over all time + expectSnapshot(reporting._all).toMatchInline(`undefined`); + expect(reporting.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); + expectSnapshot(reporting.status).toMatchInline(` + Object { + "completed": 3, + "failed": 0, + } + `); + + // over last 7 days + expectSnapshot(last7Days._all).toMatchInline(`undefined`); + expect(last7Days.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); + expectSnapshot(last7Days.status).toMatchInline(` + Object { + "completed": 3, + "failed": 0, + } + `); + }); + + it('includes report statuses', async () => { + expectSnapshot(reporting.statuses).toMatchInline(`Object {}`); + + expectSnapshot(last7Days.statuses).toMatchInline(`Object {}`); + }); + + it('includes report metrics (not for job types under last_7_days)', async () => { + expect(reporting.printable_pdf.sizes).keys([ + '1_0', + '25_0', + '50_0', + '5_0', + '75_0', + '95_0', + '99_0', + ]); + expectSnapshot(reporting.printable_pdf.metrics?.pdf_pages).toMatchInline(` + Object { + "values": Object { + "50_0": 1, + "75_0": 1, + "95_0": 1, + "99_0": 1, + }, + } + `); + expectSnapshot(reporting.csv_searchsource.metrics?.csv_rows).toMatchInline(` + Object { + "values": Object { + "50_0": 71, + "75_0": 71, + "95_0": 71, + "99_0": 71, + }, + } + `); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts new file mode 100644 index 0000000000000..086f3373e2c71 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import * as urls from './_post_urls'; + +const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; +const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe('from new jobs posted', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); + await esArchiver.load(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.initEcommerce(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.teardownEcommerce(); + }); + + it('should handle csv_searchsource', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([reportingAPI.postJob(urls.JOB_PARAMS_CSV_DEFAULT_SPACE)]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 1); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + }); + + it('should handle preserve_layout pdf', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRESERVE_DASHBOARD_FILTER_6_3), + reportingAPI.postJob(urls.PDF_PRESERVE_PIE_VISUALIZATION_6_3), + ]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 2); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); + }); + + it('should handle print_layout pdf', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRINT_DASHBOARD_6_3), + reportingAPI.postJob(urls.PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), + ]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); + + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2); + }); + }); +} From 4fe3cbbf22bba7edb864bc0e1371dce418d5edbc Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Fri, 18 Mar 2022 17:33:17 -0400 Subject: [PATCH 04/38] [Security Solution][Investigations] - Add related cases to Flyout (#128033) --- .../event_details/event_details.tsx | 2 + .../event_details/related_cases.test.tsx | 119 ++++++++++++++++++ .../event_details/related_cases.tsx | 100 +++++++++++++++ .../use_add_to_case_actions.tsx | 4 + .../take_action_dropdown/index.test.tsx | 1 + .../components/take_action_dropdown/index.tsx | 4 + .../__snapshots__/index.test.tsx.snap | 6 + .../side_panel/event_details/footer.test.tsx | 1 + .../side_panel/event_details/footer.tsx | 3 + .../side_panel/event_details/index.tsx | 20 +-- .../timelines/containers/details/index.tsx | 11 +- 11 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/related_cases.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 5291f5a3f15a3..f10beb1c9c6ca 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -41,6 +41,7 @@ import { Reason } from './reason'; import { InvestigationGuideView } from './investigation_guide_view'; import { Overview } from './overview'; import { HostRisk } from '../../../risk_score/containers'; +import { RelatedCases } from './related_cases'; type EventViewTab = EuiTabbedContentTab; @@ -170,6 +171,7 @@ const EventDetailsComponent: React.FC = ({ /> + { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useGetUserCasesPermissions: jest.fn(), + useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: { + api: { + getRelatedCases: mockGetRelatedCases, + }, + }, + }, + }), + }; +}); + +const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a'; + +describe('Related Cases', () => { + describe('When user does not have cases read permissions', () => { + test('should not show related cases when user does not have permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + read: false, + }); + act(() => { + render( + + + + ); + }); + + expect(screen.queryByText('cases')).toBeNull(); + }); + }); + describe('When user does have case read permissions', () => { + beforeEach(() => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + read: true, + }); + }); + + describe('When related cases are unable to be retrieved', () => { + test('should show 0 related cases when there are none', async () => { + mockGetRelatedCases.mockReturnValue([]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('0 cases.')).toBeInTheDocument(); + }); + }); + + describe('When 1 related case is retrieved', () => { + test('should show 1 related case', async () => { + mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('1 case:')).toBeInTheDocument(); + expect(await screen.findByTestId('case-details-link')).toHaveTextContent('Test Case'); + }); + }); + + describe('When 2 related cases are retrieved', () => { + test('should show 2 related cases', async () => { + mockGetRelatedCases.mockReturnValue([ + { id: '789', title: 'Test Case 1' }, + { id: '456', title: 'Test Case 2' }, + ]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('2 cases:')).toBeInTheDocument(); + const cases = await screen.findAllByTestId('case-details-link'); + expect(cases).toHaveLength(2); + expect(cases[0]).toHaveTextContent('Test Case 1'); + expect(cases[1]).toHaveTextContent('Test Case 2'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx new file mode 100644 index 0000000000000..8cbf62bbf8623 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.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, { useCallback, useState, useEffect } from 'react'; +import { EuiFlexItem, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useGetUserCasesPermissions, useKibana, useToasts } from '../../lib/kibana'; +import { CaseDetailsLink } from '../links'; +import { APP_ID } from '../../../../common/constants'; + +interface Props { + eventId: string; +} + +type RelatedCaseList = Array<{ id: string; title: string }>; + +export const RelatedCases: React.FC = React.memo(({ eventId }) => { + const { + services: { cases }, + } = useKibana(); + const toasts = useToasts(); + const casePermissions = useGetUserCasesPermissions(); + const [relatedCases, setRelatedCases] = useState([]); + const [areCasesLoading, setAreCasesLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const hasCasesReadPermissions = casePermissions?.read ?? false; + + const getRelatedCases = useCallback(async () => { + let relatedCaseList: RelatedCaseList = []; + try { + if (eventId) { + relatedCaseList = + (await cases.api.getRelatedCases(eventId, { + owner: APP_ID, + })) ?? []; + } + } catch (error) { + setHasError(true); + toasts.addWarning( + i18n.translate('xpack.securitySolution.alertDetails.overview.relatedCasesFailure', { + defaultMessage: 'Unable to load related cases: "{error}"', + values: { error }, + }) + ); + } + setRelatedCases(relatedCaseList); + setAreCasesLoading(false); + }, [eventId, cases.api, toasts]); + + useEffect(() => { + getRelatedCases(); + }, [eventId, getRelatedCases]); + + if (hasError || !hasCasesReadPermissions) return null; + + return areCasesLoading ? ( + + ) : ( + <> + + + + {' '} + + + + {relatedCases?.map(({ id, title }, index) => + id && title ? ( + + {' '} + + {title} + + {relatedCases[index + 1] ? ',' : ''} + + ) : ( + <> + ) + )} + + + + ); +}); + +RelatedCases.displayName = 'RelatedCases'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 2ca4525c7e1ab..34807e368cbe4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -21,6 +21,7 @@ export interface UseAddToCaseActions { ariaLabel?: string; ecsData?: Ecs; nonEcsData?: TimelineNonEcsData[]; + onSuccess?: () => Promise; timelineId: string; } @@ -29,6 +30,7 @@ export const useAddToCaseActions = ({ ariaLabel, ecsData, nonEcsData, + onSuccess, timelineId, }: UseAddToCaseActions) => { const { cases: casesUi } = useKibana().services; @@ -52,11 +54,13 @@ export const useAddToCaseActions = ({ const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ attachments: caseAttachments, onClose: onMenuItemClick, + onSuccess, }); const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ attachments: caseAttachments, onClose: onMenuItemClick, + onRowClick: onSuccess, }); const handleAddToNewCaseClick = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index 9546ef231992c..938022b5aac5e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -76,6 +76,7 @@ describe('take action dropdown', () => { onAddExceptionTypeClick: jest.fn(), onAddIsolationStatusClick: jest.fn(), refetch: jest.fn(), + refetchFlyoutData: jest.fn(), timelineId: TimelineId.active, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index d4373501eedd0..4a35fdd6a1381 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -23,6 +23,7 @@ import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_c import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useUserPrivileges } from '../../../common/components/user_privileges'; import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions'; + interface ActionsData { alertStatus: Status; eventId: string; @@ -42,6 +43,7 @@ export interface TakeActionDropdownProps { onAddExceptionTypeClick: (type: ExceptionListType) => void; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; refetch: (() => void) | undefined; + refetchFlyoutData: () => Promise; timelineId: string; } @@ -57,6 +59,7 @@ export const TakeActionDropdown = React.memo( onAddExceptionTypeClick, onAddIsolationStatusClick, refetch, + refetchFlyoutData, timelineId, }: TakeActionDropdownProps) => { const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); @@ -184,6 +187,7 @@ export const TakeActionDropdown = React.memo( ecsData, nonEcsData: detailsData?.map((d) => ({ field: d.field, value: d.values })) ?? [], onMenuItemClick, + onSuccess: refetchFlyoutData, timelineId, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index d1c350632d7cb..01089552be251 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -259,6 +259,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should isHostIsolationPanelOpen={false} loadingEventDetails={true} onAddIsolationStatusClick={[Function]} + refetchFlyoutData={[Function]} timelineId="test" > { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index e97568a4ae52d..31e2f74f5bf4f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -34,6 +34,7 @@ interface EventDetailsFooterProps { loadingEventDetails: boolean; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; timelineId: string; + refetchFlyoutData: () => Promise; } interface AddExceptionModalWrapperData { @@ -55,6 +56,7 @@ export const EventDetailsFooterComponent = React.memo( timelineId, globalQuery, timelineQuery, + refetchFlyoutData, }: EventDetailsFooterProps & PropsFromRedux) => { const ruleIndex = useMemo( () => @@ -122,6 +124,7 @@ export const EventDetailsFooterComponent = React.memo( onAddEventFilterClick={onAddEventFilterClick} onAddExceptionTypeClick={onAddExceptionTypeClick} onAddIsolationStatusClick={onAddIsolationStatusClick} + refetchFlyoutData={refetchFlyoutData} refetch={refetchAll} indexName={expandedEvent.indexName} timelineId={timelineId} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 7577789408a8f..825a81f1984f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -81,14 +81,16 @@ const EventDetailsPanelComponent: React.FC = ({ tabType, timelineId, }) => { - const [loading, detailsData, rawEventData, ecsData] = useTimelineEventsDetails({ - docValueFields, - entityType, - indexName: expandedEvent.indexName ?? '', - eventId: expandedEvent.eventId ?? '', - runtimeMappings, - skip: !expandedEvent.eventId, - }); + const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails( + { + docValueFields, + entityType, + indexName: expandedEvent.indexName ?? '', + eventId: expandedEvent.eventId ?? '', + runtimeMappings, + skip: !expandedEvent.eventId, + } + ); const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false); @@ -240,6 +242,7 @@ const EventDetailsPanelComponent: React.FC = ({ detailsData={detailsData} detailsEcsData={ecsData} expandedEvent={expandedEvent} + refetchFlyoutData={refetchFlyoutData} handleOnEventClosed={handleOnEventClosed} isHostIsolationPanelOpen={isHostIsolationPanelOpen} loadingEventDetails={loading} @@ -277,6 +280,7 @@ const EventDetailsPanelComponent: React.FC = ({ isHostIsolationPanelOpen={isHostIsolationPanelOpen} loadingEventDetails={loading} onAddIsolationStatusClick={showHostIsolationPanel} + refetchFlyoutData={refetchFlyoutData} timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 730c4e4b19f84..cc60e9421d26b 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import { isEmpty, noop } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { Subscription } from 'rxjs'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { inputsModel } from '../../../common/store'; import { useKibana } from '../../../common/lib/kibana'; import { DocValueFields, @@ -51,10 +50,12 @@ export const useTimelineEventsDetails = ({ boolean, EventsArgs['detailsData'], object | undefined, - EventsArgs['ecs'] + EventsArgs['ecs'], + () => Promise ] => { + const asyncNoop = () => Promise.resolve(); const { data } = useKibana().services; - const refetch = useRef(noop); + const refetch = useRef<() => Promise>(asyncNoop); const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(new Subscription()); const [loading, setLoading] = useState(false); @@ -141,5 +142,5 @@ export const useTimelineEventsDetails = ({ }; }, [timelineDetailsRequest, timelineDetailsSearch]); - return [loading, timelineDetailsResponse, rawEventData, ecsData]; + return [loading, timelineDetailsResponse, rawEventData, ecsData, refetch.current]; }; From 484d23ce486ef24eabeb7d07b3bd59869097d3b8 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Fri, 18 Mar 2022 17:03:02 -0500 Subject: [PATCH 05/38] [build] Remove unused files (#127901) * Remove unused files from distribution * cleanup * Remove unused files * Remove markdown source files * fix --- src/dev/build/tasks/copy_source_task.ts | 7 ++++++- x-pack/tasks/build.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 5c6ac93852533..eb53b51293bbb 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -20,7 +20,7 @@ export const CopySource: Task = { 'src/**', '!src/**/*.{test,test.mocks,mock}.{js,ts,tsx}', '!src/**/mocks.ts', // special file who imports .mock files - '!src/**/{target,__tests__,__snapshots__,__mocks__,integration_tests,__fixtures__}/**', + '!src/**/{target,tests,__jest__,test_data,__tests__,__snapshots__,__mocks__,integration_tests,__fixtures__}/**', '!src/core/server/core_app/assets/favicons/favicon.distribution.png', '!src/core/server/core_app/assets/favicons/favicon.distribution.svg', '!src/test_utils/**', @@ -30,6 +30,11 @@ export const CopySource: Task = { '!src/functional_test_runner/**', '!src/dev/**', '!**/jest.config.js', + '!**/jest.integration.config.js', + '!**/mocks.js', + '!**/test_utils.js', + '!**/test_helpers.js', + '!**/*.{md,mdx,asciidoc}', '!src/plugins/telemetry/schema/**', // Skip telemetry schemas // this is the dev-only entry '!src/setup_node_env/index.js', diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index 182069665c4e7..03beae1108bbd 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -68,17 +68,24 @@ async function copySourceAndBabelify() { buffer: true, nodir: true, ignore: [ - '**/*.{md,asciidoc}', + '**/*.{md,mdx,asciidoc}', '**/jest.config.js', + '**/jest.config.dev.js', + '**/jest_setup.js', + '**/jest.integration.config.js', + '**/*.stories.js', '**/*.{test,test.mocks,mock,mocks}.*', '**/*.d.ts', '**/node_modules/**', '**/public/**/*.{js,ts,tsx,json,scss}', - '**/{__tests__,__mocks__,__snapshots__,__fixtures__,__jest__,cypress}/**', + '**/{test,__tests__,__mocks__,__snapshots__,__fixtures__,__jest__,cypress,fixtures}/**', 'plugins/*/target/**', 'plugins/canvas/shareable_runtime/test/**', 'plugins/screenshotting/chromium/**', 'plugins/telemetry_collection_xpack/schema/**', // Skip telemetry schemas + 'plugins/apm/ftr_e2e/**', + 'plugins/apm/scripts/**', + 'plugins/lists/server/scripts/**', ], allowEmpty: true, } From 5435cf922e8c9622d0ee175cfcbbeb5994930231 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Fri, 18 Mar 2022 18:38:42 -0400 Subject: [PATCH 06/38] [Response Ops] API to retrieve execution log entries from event log. (#127339) * wip * wip * Reverting changes not related to event log aggregation * Reverting changes not related to event log aggregation * Updating event log client find to take array of sort options * Updating tests and adding basic aggregation function * Adding tests * Fixing functional test * Fixing functional test * Revert "Reverting changes not related to event log aggregation" This reverts commit 939340e252f36796f272f663e7e75f275772cf84. * Revert "Reverting changes not related to event log aggregation" This reverts commit 40a93a4b3c02de15d3fb9448f830dffa385a54d0. * Getting aggregation and parsing aggregation results * Cleanup * Changing api to internal * Fixing types * PR feedback * omg types * types and optional accessors * Adding fn to calculate num executions based on date range * Fleshing out rules client function and tests * http api * Cleanup * Adding schedule delay * Limit to 1000 logs * Fixing security tests * Fixing unit tests * Validating numExecutions * Changing sort input format * Adding more sort fields * Fixing unit tests * Adding functional tests * Adding sort to terms aggregation * Fixing functional test * Adding audit event for rule GET * Adding audit event for rule execution log GET * PR feedback * Adding gap policy and using static num buckets * Fixing checks * Fixing checks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/security/audit-logging.asciidoc | 4 + x-pack/plugins/alerting/README.md | 1 + .../authorization/alerting_authorization.ts | 1 + .../lib/get_execution_log_aggregation.test.ts | 887 ++++++++++++++++++ .../lib/get_execution_log_aggregation.ts | 325 +++++++ .../routes/get_rule_execution_log.test.ts | 140 +++ .../server/routes/get_rule_execution_log.ts | 77 ++ .../plugins/alerting/server/routes/index.ts | 2 + .../alerting/server/rules_client.mock.ts | 1 + .../server/rules_client/audit_events.ts | 7 + .../server/rules_client/rules_client.ts | 79 ++ .../tests/get_execution_log.test.ts | 613 ++++++++++++ .../alerting.test.ts | 8 + .../feature_privilege_builder/alerting.ts | 2 +- .../tests/alerting/get_execution_log.ts | 456 +++++++++ .../spaces_only/tests/alerting/index.ts | 1 + 16 files changed, 2603 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts create mode 100644 x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts create mode 100644 x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 58f61b79f3ba6..58677141ab0c8 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -213,6 +213,10 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a rule. | `failure` | User is not authorized to access a rule. +.2+| `rule_get_execution_log` +| `success` | User has accessed execution log for a rule. +| `failure` | User is not authorized to access execution log for a rule. + .2+| `rule_find` | `success` | User has accessed a rule as part of a search operation. | `failure` | User is not authorized to search for rules. diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 903d190a216bd..6dde7de84aab4 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -643,6 +643,7 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `get` - `getRuleState` - `getAlertSummary` +- `getExecutionLog` - `find` When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index c275053874efa..546fd3e4aed9a 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -30,6 +30,7 @@ export enum ReadOperations { Get = 'get', GetRuleState = 'getRuleState', GetAlertSummary = 'getAlertSummary', + GetExecutionLog = 'getExecutionLog', Find = 'find', } diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts new file mode 100644 index 0000000000000..92999a80f6b99 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -0,0 +1,887 @@ +/* + * 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 { + getNumExecutions, + getExecutionLogAggregation, + formatExecutionLogResult, + formatSortForBucketSort, + formatSortForTermSort, +} from './get_execution_log_aggregation'; + +describe('formatSortForBucketSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForBucketSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': { order: 'desc' } }, + { 'ruleExecution>executionDuration': { order: 'asc' } }, + ]); + }); +}); + +describe('formatSortForTermSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForTermSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': 'desc' }, + { 'ruleExecution>executionDuration': 'asc' }, + ]); + }); +}); + +describe('getNumExecutions', () => { + test('should calculate the expected number of executions in a given date range with a given schedule interval', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1h' + ) + ).toEqual(24); + }); + + test('should return 0 if dateEnd is less that dateStart', () => { + expect( + getNumExecutions( + new Date('2020-12-02T00:00:00.000Z'), + new Date('2020-12-01T00:00:00.000Z'), + '1h' + ) + ).toEqual(0); + }); + + test('should cap numExecutions at default max buckets limit', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1s' + ) + ).toEqual(1000); + }); +}); + +describe('getExecutionLogAggregation', () => { + test('should throw error when given bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given one bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given bad page field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 0, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot(`"Invalid page field \\"0\\" - must be greater than 0"`); + }); + + test('should throw error when given bad perPage field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 0, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid perPage field \\"0\\" - must be greater than 0"` + ); + }); + + test('should correctly generate aggregation', () => { + expect( + getExecutionLogAggregation({ + page: 2, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }], + }) + ).toEqual({ + executionUuidCardinality: { cardinality: { field: 'kibana.alert.rule.execution.uuid' } }, + executionUuid: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 1000, + order: [ + { 'ruleExecution>executeStartTime': 'asc' }, + { 'ruleExecution>executionDuration': 'desc' }, + ], + }, + aggs: { + executionUuidSorted: { + bucket_sort: { + sort: [ + { 'ruleExecution>executeStartTime': { order: 'asc' } }, + { 'ruleExecution>executionDuration': { order: 'desc' } }, + ], + from: 10, + size: 10, + gap_policy: 'insert_zeros', + }, + }, + alertCounts: { + filters: { + filters: { + newAlerts: { match: { 'event.action': 'new-instance' } }, + activeAlerts: { match: { 'event.action': 'active-instance' } }, + recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, + }, + }, + }, + actionExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'actions' } }, + ], + }, + }, + aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } }, + }, + ruleExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + aggs: { + executeStartTime: { min: { field: 'event.start' } }, + scheduleDelay: { + max: { + field: 'kibana.task.schedule_delay', + }, + }, + totalSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, + }, + esSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' }, + }, + numTriggeredActions: { + max: { field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions' }, + }, + executionDuration: { max: { field: 'event.duration' } }, + outcomeAndMessage: { + top_hits: { size: 1, _source: { includes: ['event.outcome', 'message'] } }, + }, + }, + }, + timeoutMessage: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute-timeout' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + }, + }, + }, + }); + }); +}); + +describe('formatExecutionLogResult', () => { + test('should return empty results if aggregations are undefined', () => { + expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({ + total: 0, + data: [], + }); + }); + test('should format results correctly', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when execution timeouts occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + doc_count: 3, + timeoutMessage: { + meta: {}, + doc_count: 1, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 0, + }, + newAlerts: { + doc_count: 0, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 0.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'dJkWa38B1ylB1EvsAckB', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.0279e10, + }, + executeStartTime: { + value: 1.646769067607e12, + value_as_string: '2022-03-08T19:51:07.607Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 0, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + timestamp: '2022-03-08T19:51:07.607Z', + duration_ms: 10279, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: true, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when action errors occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 417, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 417, + data: [ + { + id: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + timestamp: '2022-03-09T16:56:13.039Z', + duration_ms: 1374, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 0, + num_errored_actions: 5, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '61bb867b-661a-471f-bf92-23471afa10b3', + timestamp: '2022-03-09T16:55:17.518Z', + duration_ms: 418, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3133, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts new file mode 100644 index 0000000000000..445cec6ad8412 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -0,0 +1,325 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import Boom from '@hapi/boom'; +import { flatMap, get } from 'lodash'; +import { parseDuration } from '.'; +import { AggregateEventsBySavedObjectResult } from '../../../event_log/server'; + +const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions + +const PROVIDER_FIELD = 'event.provider'; +const START_FIELD = 'event.start'; +const ACTION_FIELD = 'event.action'; +const OUTCOME_FIELD = 'event.outcome'; +const DURATION_FIELD = 'event.duration'; +const MESSAGE_FIELD = 'message'; +const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; +const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; +const TOTAL_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.total_search_duration_ms'; +const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = + 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; +const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; + +const Millis2Nanos = 1000 * 1000; + +export interface IExecutionLog { + id: string; + timestamp: string; + duration_ms: number; + status: string; + message: string; + num_active_alerts: number; + num_new_alerts: number; + num_recovered_alerts: number; + num_triggered_actions: number; + num_succeeded_actions: number; + num_errored_actions: number; + total_search_duration_ms: number; + es_search_duration_ms: number; + schedule_delay_ms: number; + timed_out: boolean; +} + +export interface IExecutionLogResult { + total: number; + data: IExecutionLog[]; +} + +interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { + buckets: { + activeAlerts: estypes.AggregationsSingleBucketAggregateBase; + newAlerts: estypes.AggregationsSingleBucketAggregateBase; + recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; + }; +} + +interface IActionExecution + extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { + buckets: Array<{ key: string; doc_count: number }>; +} + +interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketKeys { + timeoutMessage: estypes.AggregationsMultiBucketBase; + ruleExecution: { + executeStartTime: estypes.AggregationsMinAggregate; + executionDuration: estypes.AggregationsMaxAggregate; + scheduleDelay: estypes.AggregationsMaxAggregate; + esSearchDuration: estypes.AggregationsMaxAggregate; + totalSearchDuration: estypes.AggregationsMaxAggregate; + numTriggeredActions: estypes.AggregationsMaxAggregate; + outcomeAndMessage: estypes.AggregationsTopHitsAggregate; + }; + alertCounts: IAlertCounts; + actionExecution: { + actionOutcomes: IActionExecution; + }; +} + +interface ExecutionUuidAggResult + extends estypes.AggregationsAggregateBase { + buckets: TBucket[]; +} +export interface IExecutionLogAggOptions { + page: number; + perPage: number; + sort: estypes.Sort; +} + +const ExecutionLogSortFields: Record = { + timestamp: 'ruleExecution>executeStartTime', + execution_duration: 'ruleExecution>executionDuration', + total_search_duration: 'ruleExecution>totalSearchDuration', + es_search_duration: 'ruleExecution>esSearchDuration', + schedule_delay: 'ruleExecution>scheduleDelay', + num_triggered_actions: 'ruleExecution>numTriggeredActions', +}; + +export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { + // Check if valid sort fields + const sortFields = flatMap(sort as estypes.SortCombinations[], (s) => Object.keys(s)); + for (const field of sortFields) { + if (!Object.keys(ExecutionLogSortFields).includes(field)) { + throw Boom.badRequest( + `Invalid sort field "${field}" - must be one of [${Object.keys(ExecutionLogSortFields).join( + ',' + )}]` + ); + } + } + + // Check if valid page value + if (page <= 0) { + throw Boom.badRequest(`Invalid page field "${page}" - must be greater than 0`); + } + + // Check if valid page value + if (perPage <= 0) { + throw Boom.badRequest(`Invalid perPage field "${perPage}" - must be greater than 0`); + } + + return { + // Get total number of executions + executionUuidCardinality: { + cardinality: { + field: EXECUTION_UUID_FIELD, + }, + }, + executionUuid: { + // Bucket by execution UUID + terms: { + field: EXECUTION_UUID_FIELD, + size: DEFAULT_MAX_BUCKETS_LIMIT, + order: formatSortForTermSort(sort), + }, + aggs: { + // Bucket sort to allow paging through executions + executionUuidSorted: { + bucket_sort: { + sort: formatSortForBucketSort(sort), + from: (page - 1) * perPage, + size: perPage, + gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, + }, + }, + // Get counts for types of alerts and whether there was an execution timeout + alertCounts: { + filters: { + filters: { + newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, + activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, + recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, + }, + }, + }, + // Filter by action execute doc and get information from this event + actionExecution: { + filter: getProviderAndActionFilter('actions', 'execute'), + aggs: { + actionOutcomes: { + terms: { + field: OUTCOME_FIELD, + size: 2, + }, + }, + }, + }, + // Filter by rule execute doc and get information from this event + ruleExecution: { + filter: getProviderAndActionFilter('alerting', 'execute'), + aggs: { + executeStartTime: { + min: { + field: START_FIELD, + }, + }, + scheduleDelay: { + max: { + field: SCHEDULE_DELAY_FIELD, + }, + }, + totalSearchDuration: { + max: { + field: TOTAL_SEARCH_DURATION_FIELD, + }, + }, + esSearchDuration: { + max: { + field: ES_SEARCH_DURATION_FIELD, + }, + }, + numTriggeredActions: { + max: { + field: NUMBER_OF_TRIGGERED_ACTIONS_FIELD, + }, + }, + executionDuration: { + max: { + field: DURATION_FIELD, + }, + }, + outcomeAndMessage: { + top_hits: { + size: 1, + _source: { + includes: [OUTCOME_FIELD, MESSAGE_FIELD], + }, + }, + }, + }, + }, + // If there was a timeout, this filter will return non-zero doc count + timeoutMessage: { + filter: getProviderAndActionFilter('alerting', 'execute-timeout'), + }, + }, + }, + }; +} + +function getProviderAndActionFilter(provider: string, action: string) { + return { + bool: { + must: [ + { + match: { + [ACTION_FIELD]: action, + }, + }, + { + match: { + [PROVIDER_FIELD]: provider, + }, + }, + ], + }, + }; +} + +function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutionLog { + const durationUs = bucket?.ruleExecution?.executionDuration?.value + ? bucket.ruleExecution.executionDuration.value + : 0; + const scheduleDelayUs = bucket?.ruleExecution?.scheduleDelay?.value + ? bucket.ruleExecution.scheduleDelay.value + : 0; + const timedOut = (bucket?.timeoutMessage?.doc_count ?? 0) > 0; + + const actionExecutionOutcomes = bucket?.actionExecution?.actionOutcomes?.buckets ?? []; + const actionExecutionSuccess = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'success')?.doc_count ?? 0; + const actionExecutionError = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'failure')?.doc_count ?? 0; + + return { + id: bucket?.key ?? '', + timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', + duration_ms: durationUs / Millis2Nanos, + status: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.event?.outcome, + message: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.message, + num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, + num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, + num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, + num_succeeded_actions: actionExecutionSuccess, + num_errored_actions: actionExecutionError, + total_search_duration_ms: bucket?.ruleExecution?.totalSearchDuration?.value ?? 0, + es_search_duration_ms: bucket?.ruleExecution?.esSearchDuration?.value ?? 0, + schedule_delay_ms: scheduleDelayUs / Millis2Nanos, + timed_out: timedOut, + }; +} + +export function formatExecutionLogResult( + results: AggregateEventsBySavedObjectResult +): IExecutionLogResult { + const { aggregations } = results; + + if (!aggregations) { + return { + total: 0, + data: [], + }; + } + + const total = (aggregations.executionUuidCardinality as estypes.AggregationsCardinalityAggregate) + .value; + const buckets = (aggregations.executionUuid as ExecutionUuidAggResult).buckets; + + return { + total, + data: buckets.map((bucket: IExecutionUuidAggBucket) => formatExecutionLogAggBucket(bucket)), + }; +} + +export function getNumExecutions(dateStart: Date, dateEnd: Date, ruleSchedule: string) { + const durationInMillis = dateEnd.getTime() - dateStart.getTime(); + const scheduleMillis = parseDuration(ruleSchedule); + + const numExecutions = Math.ceil(durationInMillis / scheduleMillis); + + return Math.min(numExecutions < 0 ? 0 : numExecutions, DEFAULT_MAX_BUCKETS_LIMIT); +} + +export function formatSortForBucketSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, curr) }), + {} + ) + ); +} + +export function formatSortForTermSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, `${curr}.order`) }), + {} + ) + ); +} diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts new file mode 100644 index 0000000000000..e359e9c52dda0 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRuleExecutionLogRoute } from './get_rule_execution_log'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { rulesClientMock } from '../rules_client.mock'; +import { IExecutionLogResult } from '../lib/get_execution_log_aggregation'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('getRuleExecutionLogRoute', () => { + const dateString = new Date().toISOString(); + const mockedExecutionLog: IExecutionLogResult = { + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3008, + }, + ], + }; + + it('gets rule execution log', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_execution_log"`); + + rulesClient.getExecutionLogForRule.mockResolvedValue(mockedExecutionLog); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: { + date_start: dateString, + per_page: 10, + page: 1, + sort: [{ timestamp: { order: 'desc' } }], + }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.getExecutionLogForRule).toHaveBeenCalledTimes(1); + expect(rulesClient.getExecutionLogForRule.mock.calls[0]).toEqual([ + { + dateStart: dateString, + id: '1', + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }, + ]); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('returns NOT-FOUND when rule is not found', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.getExecutionLogForRule = jest + .fn() + .mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1')); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['notFound'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: Saved object [alert/1] not found]` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts new file mode 100644 index 0000000000000..845c14ecf0ea4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { ILicenseState } from '../lib'; +import { GetExecutionLogByIdParams } from '../rules_client'; +import { RewriteRequestCase, verifyAccessAndContext } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]); + +const sortFieldSchema = schema.oneOf([ + schema.object({ timestamp: schema.object({ order: sortOrderSchema }) }), + schema.object({ execution_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ total_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ es_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), +]); + +const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { + defaultValue: [{ timestamp: { order: 'desc' } }], +}); + +const querySchema = schema.object({ + date_start: schema.string(), + date_end: schema.maybe(schema.string()), + filter: schema.maybe(schema.string()), + per_page: schema.number({ defaultValue: 10, min: 1 }), + page: schema.number({ defaultValue: 1, min: 1 }), + sort: sortFieldsSchema, +}); + +const rewriteReq: RewriteRequestCase = ({ + date_start: dateStart, + date_end: dateEnd, + per_page: perPage, + ...rest +}) => ({ + ...rest, + dateStart, + dateEnd, + perPage, +}); + +export const getRuleExecutionLogRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_execution_log`, + validate: { + params: paramSchema, + query: querySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const { id } = req.params; + return res.ok({ + body: await rulesClient.getExecutionLogForRule(rewriteReq({ id, ...req.query })), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 1cb58fd6d0657..ed1a9583cc75c 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -20,6 +20,7 @@ import { disableRuleRoute } from './disable_rule'; import { enableRuleRoute } from './enable_rule'; import { findRulesRoute, findInternalRulesRoute } from './find_rules'; import { getRuleAlertSummaryRoute } from './get_rule_alert_summary'; +import { getRuleExecutionLogRoute } from './get_rule_execution_log'; import { getRuleStateRoute } from './get_rule_state'; import { healthRoute } from './health'; import { resolveRuleRoute } from './resolve_rule'; @@ -54,6 +55,7 @@ export function defineRoutes(opts: RouteOptions) { findRulesRoute(router, licenseState, usageCounter); findInternalRulesRoute(router, licenseState, usageCounter); getRuleAlertSummaryRoute(router, licenseState); + getRuleExecutionLogRoute(router, licenseState); getRuleStateRoute(router, licenseState); healthRoute(router, licenseState, encryptedSavedObjects); ruleTypesRoute(router, licenseState); diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 2a7fb7177ce4c..de1de6a8e3cbc 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -30,6 +30,7 @@ const createRulesClientMock = () => { unmuteInstance: jest.fn(), listAlertTypes: jest.fn(), getAlertSummary: jest.fn(), + getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), snooze: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index 96d6b9a5d17ef..65be7fc739ca2 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -23,6 +23,7 @@ export enum RuleAuditAction { MUTE_ALERT = 'rule_alert_mute', UNMUTE_ALERT = 'rule_alert_unmute', AGGREGATE = 'rule_aggregate', + GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', } @@ -43,6 +44,11 @@ const eventVerbs: Record = { rule_alert_mute: ['mute alert of', 'muting alert of', 'muted alert of'], rule_alert_unmute: ['unmute alert of', 'unmuting alert of', 'unmuted alert of'], rule_aggregate: ['access', 'accessing', 'accessed'], + rule_get_execution_log: [ + 'access execution log for', + 'accessing execution log for', + 'accessed execution log for', + ], rule_snooze: ['snooze', 'snoozing', 'snoozed'], }; @@ -61,6 +67,7 @@ const eventTypes: Record = { rule_alert_mute: 'change', rule_alert_unmute: 'change', rule_aggregate: 'access', + rule_get_execution_log: 'access', rule_snooze: 'change', }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 4208c0d76d5ff..e396b4fd94943 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -84,6 +84,11 @@ import { getModifiedSearch, modifyFilterKueryNode, } from './lib/mapped_params_utils'; +import { + formatExecutionLogResult, + getExecutionLogAggregation, + IExecutionLogResult, +} from '../lib/get_execution_log_aggregation'; import { validateSnoozeDate } from '../lib/validate_snooze_date'; import { RuleMutedError } from '../lib/errors/rule_muted'; @@ -235,6 +240,16 @@ export interface GetAlertSummaryParams { numberOfExecutions?: number; } +export interface GetExecutionLogByIdParams { + id: string; + dateStart: string; + dateEnd?: string; + filter?: string; + page: number; + perPage: number; + sort: estypes.Sort; +} + // NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects const extractedSavedObjectParamReferenceNamePrefix = 'param:'; @@ -639,6 +654,70 @@ export class RulesClient { }); } + public async getExecutionLogForRule({ + id, + dateStart, + dateEnd, + filter, + page, + perPage, + sort, + }: GetExecutionLogByIdParams): Promise { + this.logger.debug(`getExecutionLogForRule(): getting execution log for rule ${id}`); + const rule = (await this.get({ id, includeLegacyId: true })) as SanitizedRuleWithLegacyId; + + try { + // Make sure user has access to this rule + await this.authorization.ensureAuthorized({ + ruleTypeId: rule.alertTypeId, + consumer: rule.consumer, + operation: ReadOperations.GetExecutionLog, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + }) + ); + + // default duration of instance summary is 60 * rule interval + const dateNow = new Date(); + const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow); + const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow); + + const eventLogClient = await this.getEventLogClient(); + + const results = await eventLogClient.aggregateEventsBySavedObjectIds( + 'alert', + [id], + { + start: parsedDateStart.toISOString(), + end: parsedDateEnd.toISOString(), + filter, + aggs: getExecutionLogAggregation({ + page, + perPage, + sort, + }), + }, + rule.legacyId !== null ? [rule.legacyId] : undefined + ); + + return formatExecutionLogResult(results); + } + public async find({ options: { fields, ...options } = {}, excludeFromPublicApi = false, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts new file mode 100644 index 0000000000000..a55a3e57428bb --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -0,0 +1,613 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '../../../../actions/server'; +import { eventLogClientMock } from '../../../../event_log/server/mocks'; +import { SavedObject } from 'kibana/server'; +import { RawRule } from '../../types'; +import { auditLoggerMock } from '../../../../security/server/audit/mocks'; +import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; +import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation'; + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const eventLogClient = eventLogClientMock.create(); + +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const kibanaVersion = 'v7.10.0'; +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + minimumScheduleInterval: '1m', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry, eventLogClient); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +const RuleIntervalSeconds = 1; + +const BaseRuleSavedObject: SavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'rule-consumer', + legacyId: null, + schedule: { interval: `${RuleIntervalSeconds}s` }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: mockedDateString, + updatedAt: mockedDateString, + apiKey: null, + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + warning: null, + }, + }, + references: [], +}; + +const aggregateResults = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.345e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, +}; + +function getRuleSavedObject(attributes: Partial = {}): SavedObject { + return { + ...BaseRuleSavedObject, + attributes: { ...BaseRuleSavedObject.attributes, ...attributes }, + }; +} + +function getExecutionLogByIdParams(overwrites = {}) { + return { + id: '1', + dateStart: new Date(Date.now() - 3600000).toISOString(), + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }] as estypes.Sort, + ...overwrites, + }; +} +describe('getExecutionLogForRule()', () => { + let rulesClient: RulesClient; + + beforeEach(() => { + rulesClient = new RulesClient(rulesClientParams); + }); + + test('runs as expected with some event log aggregation data', async () => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const result = await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(result).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3345, + }, + ], + }); + }); + + // Further tests don't check the result of `getExecutionLogForRule()`, as the result + // is just the result from the `formatExecutionLogResult()`, which itself + // has a complete set of tests. + + test('calls saved objects and event log client with default params', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with legacy ids param', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce( + getRuleSavedObject({ legacyId: '99999' }) + ); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + ['99999'], + ]); + }); + + test('calls event log client with end date if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ dateEnd: new Date(Date.now() - 2700000).toISOString() }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: '2019-02-12T20:16:22.479Z', + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with filter if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ filter: 'event.outcome: success' }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + filter: 'event.outcome: success', + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('invalid start date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateStart = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateStart })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateStart: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid end date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateEnd = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateEnd })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateEnd: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid page value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ page: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid page field "-3" - must be greater than 0]`); + }); + + test('invalid perPage value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ perPage: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid perPage field "-3" - must be greater than 0]`); + }); + + test('invalid sort value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]]` + ); + }); + + test('throws error when saved object get throws an error', async () => { + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('OMG!')); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG!]`); + }); + + test('throws error when eventLog.aggregateEventsBySavedObjectIds throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockRejectedValueOnce(new Error('OMG 2!')); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG 2!]`); + }); + + describe('authorization', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('ensures user is authorised to get this type of alert under the consumer', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValueOnce( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to get a "myType" alert for "myApp"]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + }); + + describe('auditLogger', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('logs audit event when getting a rule execution log', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a rule', async () => { + // first call occurs during rule SO get + authorization.ensureAuthorized.mockResolvedValueOnce(); + authorization.ensureAuthorized.mockRejectedValueOnce(new Error('Unauthorized')); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 861f6900fda58..a3b4b76980cd3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -87,6 +87,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", ] `); @@ -169,6 +170,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", @@ -211,6 +213,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -304,6 +307,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -357,6 +361,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -371,6 +376,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", ] `); @@ -456,6 +462,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -470,6 +477,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index f536959a910cd..4d2cc97f75d89 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -16,7 +16,7 @@ enum AlertingEntity { } const readOperations: Record = { - rule: ['get', 'getRuleState', 'getAlertSummary', 'find'], + rule: ['get', 'getRuleState', 'getAlertSummary', 'getExecutionLog', 'find'], alert: ['get', 'find'], }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts new file mode 100644 index 0000000000000..55d4a72643c86 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts @@ -0,0 +1,456 @@ +/* + * 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 expect from '@kbn/expect'; + +import { Spaces } from '../../scenarios'; +import { + getUrlPrefix, + ObjectRemover, + getTestRuleData, + getEventLog, + ESTestIndexTool, +} from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetExecutionLogTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('es'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + const dateStart = new Date(Date.now() - 600000).toISOString(); + + describe('getExecutionLog', () => { + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + afterEach(() => objectRemover.removeAll()); + + it(`handles non-existent rule`, async () => { + await supertest + .get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rule/1/_execution_log?date_start=${dateStart}` + ) + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + + it('gets execution log for rule with executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(2); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(2); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // default sort is `desc` by timestamp + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(dateStart)); + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(new Date().toISOString())); + + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // no-op rule doesn't query ES + expect(log.total_search_duration_ms).to.equal(0); + expect(log.es_search_duration_ms).to.equal(0); + } + }); + + it('gets execution log for rule with no executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data).to.eql([]); + }); + + it('gets execution log for rule that performs ES searches', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.multipleSearches', + params: { + numSearches: 2, + delay: `2s`, + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // rule executes 2 searches with delay of 2 seconds each + // setting compare threshold lower to avoid flakiness + expect(log.total_search_duration_ms).to.be.greaterThan(2000); + expect(log.es_search_duration_ms).to.be.greaterThan(2000); + } + }); + + it('gets execution log for rule that errors', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.throw', + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('failure'); + expect(log.timed_out).to.equal(false); + } + }); + + it('gets execution log for rule that times out', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternLongRunning', + params: { + pattern: [true, true, true, true], + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(true); + } + }); + + it('gets execution log for rule that triggers actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'noop connector', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(1); + expect(log.num_errored_actions).to.equal(0); + } + }); + + it('gets execution log for rule that has failed actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'connector that throws', + connector_type_id: 'test.throw', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(1); + } + }); + + it('handles date_end if specified', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + + // set the date end to date start - should filter out all execution logs + const earlierDateStart = new Date(new Date(dateStart).getTime() - 900000).toISOString(); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${earlierDateStart}&date_end=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data.length).to.eql(0); + }); + + it('handles sort query parameter', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '5s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 3 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}&sort=[{"timestamp":{"order":"asc"}}]` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(3); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(3); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // sorting by `asc` timestamp + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + } + }); + + it(`handles invalid date_start`, async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=X0X0-08-08T08:08:08.008Z` + ) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Invalid date for parameter dateStart: "X0X0-08-08T08:08:08.008Z"', + }); + }); + }); + + async function waitForEvents( + id: string, + provider: string, + actions: Map< + string, + { + gte: number; + } + > + ) { + await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider, + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 242c6ffcba10f..14c8268ce80e0 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./get_alert_summary')); + loadTestFile(require.resolve('./get_execution_log')); loadTestFile(require.resolve('./rule_types')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./execution_status')); From f5533008ddc6270a004d8dc21117d90b967cd3f0 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Fri, 18 Mar 2022 23:45:46 +0100 Subject: [PATCH 07/38] [Discover] Improve doc viewer source tab performance by limiting height (#127439) --- .../doc_viewer_source/get_height.test.tsx | 50 +++++++++++++++++++ .../doc_viewer_source/get_height.tsx | 32 ++++++++++++ .../components/doc_viewer_source/source.tsx | 20 ++++++-- 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx create mode 100644 src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx new file mode 100644 index 0000000000000..5b641cced5163 --- /dev/null +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { monaco } from '@kbn/monaco'; +import { getHeight } from './get_height'; + +describe('getHeight', () => { + window.innerHeight = 500; + const getMonacoMock = (lineCount: number) => { + return { + getDomNode: jest.fn(() => { + return { + getBoundingClientRect: jest.fn(() => { + return { + top: 200, + }; + }), + }; + }), + getOption: jest.fn(() => 10), + getModel: jest.fn(() => ({ getLineCount: jest.fn(() => lineCount) })), + getTopForLineNumber: jest.fn((line) => line * 10), + } as unknown as monaco.editor.IStandaloneCodeEditor; + }; + test('when using document explorer, returning the available height in the flyout', () => { + const monacoMock = getMonacoMock(500); + + const height = getHeight(monacoMock, true); + expect(height).toBe(275); + }); + + test('when using classic table, its displayed inline without scrolling', () => { + const monacoMock = getMonacoMock(100); + + const height = getHeight(monacoMock, false); + expect(height).toBe(1020); + }); + + test('when using classic table, limited height > 500 lines to allow scrolling', () => { + const monacoMock = getMonacoMock(1000); + + const height = getHeight(monacoMock, false); + expect(height).toBe(5020); + }); +}); diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx new file mode 100644 index 0000000000000..0dcabc8ae951d --- /dev/null +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { monaco } from '@kbn/monaco'; +import { MARGIN_BOTTOM, MAX_LINES_CLASSIC_TABLE } from './source'; + +export function getHeight(editor: monaco.editor.IStandaloneCodeEditor, useDocExplorer: boolean) { + const editorElement = editor?.getDomNode(); + if (!editorElement) { + return 0; + } + + let result; + if (useDocExplorer) { + // assign a good height filling the available space of the document flyout + const position = editorElement.getBoundingClientRect(); + result = window.innerHeight - position.top - MARGIN_BOTTOM; + } else { + // takes care of the classic table, display a maximum of 500 lines + // why not display it all? Due to performance issues when the browser needs to render it all + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const displayedLineCount = + lineCount > MAX_LINES_CLASSIC_TABLE ? MAX_LINES_CLASSIC_TABLE : lineCount; + result = editor.getTopForLineNumber(displayedLineCount + 1) + lineHeight; + } + return result > 0 ? result : 0; +} diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx index 9199903d2c084..327547f232265 100644 --- a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx @@ -14,10 +14,11 @@ import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from import { i18n } from '@kbn/i18n'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { JSONCodeEditorCommonMemoized } from '../../../../components/json_code_editor/json_code_editor_common'; -import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common'; +import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common'; import { useEsDocSearch } from '../../../../utils/use_es_doc_search'; import { DataView } from '../../../../../../data_views/public'; import { ElasticRequestState } from '../../../../application/doc/types'; +import { getHeight } from './get_height'; interface SourceViewerProps { id: string; @@ -27,6 +28,12 @@ interface SourceViewerProps { width?: number; } +// Ihe number of lines displayed without scrolling used for classic table, which renders the component +// inline limitation was necessary to enable virtualized scrolling, which improves performance +export const MAX_LINES_CLASSIC_TABLE = 500; +// Displayed margin of the code editor to the window bottom when rendered in the document explorer flyout +export const MARGIN_BOTTOM = 25; + export const DocViewerSource = ({ id, index, @@ -38,6 +45,7 @@ export const DocViewerSource = ({ const [jsonValue, setJsonValue] = useState(''); const { uiSettings } = useDiscoverServices(); const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const useDocExplorer = !uiSettings.get(DOC_TABLE_LEGACY); const [reqState, hit, requestData] = useEsDocSearch({ id, index, @@ -62,16 +70,18 @@ export const DocViewerSource = ({ return; } - const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); - const lineCount = editor.getModel()?.getLineCount() || 1; - const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + const height = getHeight(editor, useDocExplorer); + if (height === 0) { + return; + } + if (!jsonValue || jsonValue === '') { editorElement.style.height = '0px'; } else { editorElement.style.height = `${height}px`; } editor.layout(); - }, [editor, jsonValue]); + }, [editor, jsonValue, useDocExplorer]); const loadingState = (
From 64a25db639daa30d6e888f490478de70e47f07ef Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Sat, 19 Mar 2022 09:29:49 -0500 Subject: [PATCH 08/38] Fix Storybook background/grid selection (#125961) --- packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts b/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts index ecf5760252abf..7f40451a34b35 100644 --- a/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts +++ b/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts @@ -23,7 +23,7 @@ export function registerThemeSwitcherAddon() { 'eui-theme-css' ) as HTMLLinkElement | null; - if (stylesheet) { + if (stylesheet && globals.euiTheme) { stylesheet.href = `kbn-ui-shared-deps-npm.${globals.euiTheme}.css`; } }); From 32d08793f02171391ab4b0d3044970c3f14c2d94 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Sat, 19 Mar 2022 15:41:23 +0000 Subject: [PATCH 09/38] update wording for temporary sourcerer (#127907) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/sourcerer/index.test.tsx | 186 ++++++++++++++++++ .../common/components/sourcerer/temporary.tsx | 20 +- .../components/sourcerer/translations.ts | 14 ++ 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx index f4707272d2c24..05b24600ff9af 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { cloneDeep } from 'lodash'; + import { initialSourcererState, SourcererScopeName } from '../../store/sourcerer/model'; import { Sourcerer } from './index'; import { sourcererActions, sourcererModel } from '../../store/sourcerer'; @@ -22,6 +24,7 @@ import { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_sel import { waitFor } from '@testing-library/dom'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; +import { TimelineId, TimelineType } from '../../../../common/types'; const mockDispatch = jest.fn(); @@ -989,6 +992,16 @@ describe('Update available', () => { expect(wrapper.find(`UpdateDefaultDataViewModal`).prop('isShowing')).toEqual(true); }); + test('Show UpdateDefaultDataViewModal Callout', () => { + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline uses a legacy data view selector' + ); + }); + test('Show Add index pattern in UpdateDefaultDataViewModal', () => { wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); @@ -1017,3 +1030,176 @@ describe('Update available', () => { ); }); }); + +describe('Update available for timeline template', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: null, + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + let wrapper: ReactWrapper; + + beforeEach(() => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut', () => { + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline template uses a legacy data view selector' + ); + }); +}); + +describe('Missing index patterns', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: 'fake-data-view-id', + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + let wrapper: ReactWrapper; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + const state3 = cloneDeep(state2); + state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default; + store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline is out of date with the Security Data View' + ); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline template', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline template is out of date with the Security Data View' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx index ec55b654b9fcc..156d08df79d06 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx @@ -22,6 +22,10 @@ import React, { useMemo } from 'react'; import * as i18n from './translations'; import { Blockquote, ResetButton } from './helpers'; import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; +import { TimelineId, TimelineType } from '../../../../common/types'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; interface Props { activePatterns?: string[]; @@ -36,11 +40,17 @@ interface Props { const translations = { deprecated: { - title: i18n.CALL_OUT_DEPRECATED_TITLE, + title: { + [TimelineType.default]: i18n.CALL_OUT_DEPRECATED_TITLE, + [TimelineType.template]: i18n.CALL_OUT_DEPRECATED_TEMPLATE_TITLE, + }, update: i18n.UPDATE_INDEX_PATTERNS, }, missingPatterns: { - title: i18n.CALL_OUT_MISSING_PATTERNS_TITLE, + title: { + [TimelineType.default]: i18n.CALL_OUT_MISSING_PATTERNS_TITLE, + [TimelineType.template]: i18n.CALL_OUT_MISSING_PATTERNS_TEMPLATE_TITLE, + }, update: i18n.ADD_INDEX_PATTERN, }, }; @@ -87,7 +97,11 @@ export const TemporarySourcererComp = React.memo( activePatterns && activePatterns.length > 0 ? selectedPatterns.filter((p) => !activePatterns.includes(p)) : []; + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineType = useDeepEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).timelineType + ); return ( <> ( data-test-subj="sourcerer-deprecated-callout" iconType="alert" size="s" - title={translations[isModified].title} + title={translations[isModified].title[timelineType]} /> diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts index 2d8e506f39437..1e1d300f4acf9 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts @@ -18,6 +18,13 @@ export const CALL_OUT_DEPRECATED_TITLE = i18n.translate( } ); +export const CALL_OUT_DEPRECATED_TEMPLATE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.callOutDeprecxatedTemplateTitle', + { + defaultMessage: 'This timeline template uses a legacy data view selector', + } +); + export const CALL_OUT_MISSING_PATTERNS_TITLE = i18n.translate( 'xpack.securitySolution.indexPatterns.callOutMissingPatternsTitle', { @@ -25,6 +32,13 @@ export const CALL_OUT_MISSING_PATTERNS_TITLE = i18n.translate( } ); +export const CALL_OUT_MISSING_PATTERNS_TEMPLATE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.callOutMissingPatternsTemplateTitle', + { + defaultMessage: 'This timeline template is out of date with the Security Data View', + } +); + export const CALL_OUT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.indexPatterns.callOutTimelineTitle', { From 7184408f3316c2354694534c8ca079c85e4191cd Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Sun, 20 Mar 2022 10:45:03 -0500 Subject: [PATCH 10/38] fix cache problem, fix all spaces problem (#128113) --- .../components/edit_index_pattern/edit_index_pattern.tsx | 2 +- .../components/index_pattern_table/index_pattern_table.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx index 86193f50b2fe2..f8a26baceb615 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -112,7 +112,7 @@ export const EditIndexPattern = withRouter( } const warning = - indexPattern.namespaces.length > 1 ? ( + indexPattern.namespaces.length > 1 || indexPattern.namespaces.includes('*') ? ( { + dataViews.clearCache(dataView.id); + loadDataViews(); + }} /> ) : ( <> From e0d667b2662777327b0ac6f314ebbcb2ddd4130b Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 21 Mar 2022 08:41:39 +0100 Subject: [PATCH 11/38] [docs] bring the Node.js upgrade docs up to date (#128126) --- .../advanced/upgrading-nodejs.asciidoc | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index d426ec1a2c91c..e96fd1c7dfd25 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -17,33 +17,26 @@ These files must be updated when upgrading Node.js: - {kib-repo}blob/{branch}/WORKSPACE.bazel[`WORKSPACE.bazel`] - The version is specified in the `node_version` property. Besides this property, the list of files under `node_repositories` must be updated along with their respective SHA256 hashes. These can be found on the https://nodejs.org[nodejs.org] website. - Example for Node.js v14.16.1: https://nodejs.org/dist/v14.16.1/SHASUMS256.txt.asc + Example for Node.js v16.14.2: https://nodejs.org/dist/v16.14.2/SHASUMS256.txt.asc -See PR {kib-repo}pull/96382[#96382] for an example of how the Node.js version has been upgraded previously. - -In the 6.8 branch, neither the `.ci/Dockerfile` file nor the `WORKSPACE.bazel` file exists, so when upgrading Node.js in that branch, just skip those files. +See PR {kib-repo}pull/128123[#128123] for an example of how the Node.js version has been upgraded previously. === Backporting The following rules are not set in stone. Use best judgement when backporting. -Currently version 7.11 and newer run Node.js 14, while 7.10 and older run Node.js 10. -Hence, upgrades to either Node.js 14 or Node.js 10 should be done as separate PRs. - ==== Node.js patch upgrades -Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version: +Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version (which currently is all of them, but this might change in the future once Node.js v18 is released and becomes LTS): - - If upgrading Node.js 14, and the current release is 7.11.1, the main PR should target `master` and be backported to `7.x` and `7.11`. - - If upgrading Node.js 10, the main PR should target `6.8` only. + - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17` and `8.1`. ==== Node.js minor upgrades Typically, you want to backport Node.js *minor* upgrades to the next minor {kib} release branch that runs the same *major* Node.js version: - - If upgrading Node.js 14, and the current release is 7.11.1, the main PR should target `master` and be backported to `7.x`, while leaving the `7.11` branch as-is. - - If upgrading Node.js 10, the main PR should target `6.8` only. + - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17`, while leaving the `8.1` branch as-is. === Upgrading installed Node.js version @@ -56,11 +49,11 @@ Run the following to install the new Node.js version. Replace `` with t nvm install ---- -To get the same global npm modules installed with the new version of Node.js as is currently installed, use the `--reinstall-packages-from` command-line argument (optionally replace `14` with the desired source version): +To get the same global npm modules installed with the new version of Node.js as is currently installed, use the `--reinstall-packages-from` command-line argument (optionally replace `16` with the desired source version): [source,bash] ---- -nvm install --reinstall-packages-from=14 +nvm install --reinstall-packages-from=16 ---- If needed, uninstall the old version of Node.js by running the following. Replace `` with the full version number of the version that should be uninstalled: @@ -70,11 +63,11 @@ If needed, uninstall the old version of Node.js by running the following. Replac nvm uninstall ---- -Optionally, tell nvm to always use the "highest" installed Node.js 14 version. Replace `14` if a different major version is desired: +Optionally, tell nvm to always use the "highest" installed Node.js 16 version. Replace `16` if a different major version is desired: [source,bash] ---- -nvm alias default 14 +nvm alias default 16 ---- Alternatively, include the full version number at the end to specify a specific default version. From 86a0edeb72bbeb9617ced5f4ed25ffb06f1701d8 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Mon, 21 Mar 2022 09:12:47 +0000 Subject: [PATCH 12/38] [APM] Add docs link for Tail Sampling Settings (#127747) * [APM]Add docs link for Tail Sampling Settings * pass doclinks as argument to tail_sampling-settings * improve typing * review nits --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../apm_policy_form/index.tsx | 9 ++++++-- ...est.ts => tail_sampling_settings.test.tsx} | 10 ++++---- ...settings.ts => tail_sampling_settings.tsx} | 23 ++++++++++++++++++- .../apm_policy_form/settings_form/index.tsx | 22 +----------------- .../apm_policy_form/typings.ts | 5 +++- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 9 files changed, 42 insertions(+), 33 deletions(-) rename x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/{tail_sampling_settings.test.ts => tail_sampling_settings.test.tsx} (84%) rename x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/{tail_sampling_settings.ts => tail_sampling_settings.tsx} (79%) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 9487b3559536e..de8ac8bd5672b 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -45,6 +45,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { droppedTransactionSpans: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-spans.html#data-model-dropped-spans`, upgrading: `${APM_DOCS}guide/${DOC_LINK_VERSION}/upgrade.html`, metaData: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-metadata.html`, + tailSamplingPolicies: `${APM_DOCS}guide/${DOC_LINK_VERSION}/configure-tail-based-sampling.html`, }, canvas: { guide: `${KIBANA_DOCS}canvas.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 6d10dadcafaa3..0922a0b2e49c1 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -31,6 +31,7 @@ export interface DocLinks { readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; + readonly tailSamplingPolicies: string; }; readonly canvas: { readonly guide: string; diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx index 863f2c6227f41..bce1d5936a352 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx @@ -25,6 +25,7 @@ import { import { SettingsForm, SettingsSection } from './settings_form'; import { isSettingsFormValid, mergeNewVars } from './settings_form/utils'; import { PackagePolicyVars } from './typings'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { updateAPMPolicy: (newVars: PackagePolicyVars, isValid: boolean) => void; @@ -37,6 +38,8 @@ export function APMPolicyForm({ isCloudPolicy, updateAPMPolicy, }: Props) { + const tailSamplingPoliciesDocsLink = + useKibana().services.docLinks?.links.apm.tailSamplingPolicies; const { apmSettings, rumSettings, @@ -51,9 +54,11 @@ export function APMPolicyForm({ agentAuthorizationSettings: getAgentAuthorizationSettings({ isCloudPolicy, }), - tailSamplingSettings: getTailSamplingSettings(), + tailSamplingSettings: getTailSamplingSettings( + tailSamplingPoliciesDocsLink + ), }; - }, [isCloudPolicy]); + }, [isCloudPolicy, tailSamplingPoliciesDocsLink]); function handleFormChange(key: string, value: any) { // Merge new key/value with the rest of fields diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts rename to x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx index cb9c72cdb59f6..6b19cd22c4eb9 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx @@ -4,15 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { getTailSamplingSettings, isTailBasedSamplingValid, } from './tail_sampling_settings'; +const DOCS_LINK = + 'https://www.elastic.co/guide/en/apm/guide/master/configure-tail-based-sampling.html'; + describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { it('return true when tail_sampling_interval is greater than 1s', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: true, type: 'bool' }, @@ -28,7 +30,7 @@ describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { }); it('return false when tail_sampling_interval is less than 1s', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: true, type: 'bool' }, @@ -44,7 +46,7 @@ describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { }); it('returns true when tail_sampling_enabled is disabled', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: false, type: 'bool' } }, settings diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx similarity index 79% rename from x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts rename to x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx index 075e838b5d818..74c509fc4fc7d 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx @@ -4,6 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { isSettingsFormValid, OPTIONAL_LABEL } from '../settings_form/utils'; import { PackagePolicyVars, SettingsRow } from '../typings'; @@ -11,7 +14,7 @@ import { getDurationRt } from '../../../../../common/agent_configuration/runtime export const TAIL_SAMPLING_ENABLED_KEY = 'tail_sampling_enabled'; -export function getTailSamplingSettings(): SettingsRow[] { +export function getTailSamplingSettings(docsLinks?: string): SettingsRow[] { return [ { key: TAIL_SAMPLING_ENABLED_KEY, @@ -67,6 +70,24 @@ export function getTailSamplingSettings(): SettingsRow[] { 'Policies map trace events to a sample rate. Each policy must specify a sample rate. Trace events are matched to policies in the order specified. All policy conditions must be true for a trace event to match. Each policy list should conclude with a policy that only specifies a sample rate. This final policy is used to catch remaining trace events that don’t match a stricter policy.', } ), + helpText: docsLinks && ( + + {i18n.translate( + 'xpack.apm.fleet_integration.settings.tailSamplingDocsHelpTextLink', + { + defaultMessage: 'docs', + } + )} + + ), + }} + /> + ), required: true, }, ], diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx index 506d5bbb5128c..84b06e37b2ae2 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx @@ -98,8 +98,7 @@ interface Props { } export function SettingsForm({ settingsSection, vars, onChange }: Props) { - const { title, subtitle, settings, isBeta, isPlatinumLicence } = - settingsSection; + const { title, subtitle, settings, isPlatinumLicence } = settingsSection; return ( @@ -130,25 +129,6 @@ export function SettingsForm({ settingsSection, vars, onChange }: Props) { )} /> )} -   - {isBeta && ( - - )} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts index d1283e0fede17..e7108e8910446 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts @@ -5,6 +5,7 @@ * 2.0. */ import * as t from 'io-ts'; +import { ReactNode } from 'react'; import { PackagePolicyConfigRecordEntry } from '../../../../../fleet/common'; export type { @@ -41,9 +42,11 @@ export interface BasicSettingRow { rowTitle?: string; rowDescription?: string; label?: string; - helpText?: string; + helpText?: ReactNode; placeholder?: string; labelAppend?: string; + labelAppendLink?: string; + labelAppendLinkText?: string; settings?: SettingsRow[]; validation?: SettingValidation; required?: boolean; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ff74a38cae97e..8b080e3cc8787 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7060,8 +7060,6 @@ "xpack.apm.fleet_integration.settings.apm.urlLabel": "URL", "xpack.apm.fleet_integration.settings.apm.writeTimeoutLabel": "応答を書き込む最大時間", "xpack.apm.fleet_integration.settings.apmAgent.description": "{title}アプリケーションの計測を構成します。", - "xpack.apm.fleet_integration.settings.betaBadgeLabel": "ベータ", - "xpack.apm.fleet_integration.settings.betaBadgeTooltip": "このモジュールはGAではありません。不具合が発生したら報告してください。", "xpack.apm.fleet_integration.settings.disabledLabel": "無効", "xpack.apm.fleet_integration.settings.enabledLabel": "有効", "xpack.apm.fleet_integration.settings.optionalLabel": "オプション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3134de6bd6f44..8c7dbd7b9df35 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7075,8 +7075,6 @@ "xpack.apm.fleet_integration.settings.apm.urlLabel": "URL", "xpack.apm.fleet_integration.settings.apm.writeTimeoutLabel": "写入响应的最大持续时间", "xpack.apm.fleet_integration.settings.apmAgent.description": "为 {title} 应用程序配置检测。", - "xpack.apm.fleet_integration.settings.betaBadgeLabel": "公测版", - "xpack.apm.fleet_integration.settings.betaBadgeTooltip": "此模块不是 GA 版。请通过报告错误来帮助我们。", "xpack.apm.fleet_integration.settings.disabledLabel": "已禁用", "xpack.apm.fleet_integration.settings.enabledLabel": "已启用", "xpack.apm.fleet_integration.settings.optionalLabel": "可选", From 9565191ab6b24993173866954e578ef7937a5787 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Mon, 21 Mar 2022 10:19:12 +0100 Subject: [PATCH 13/38] [8.1] [UA] Enable es deprecation flyout test (#127610) * Enable test * commit using @elastic.co Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/accessibility/apps/upgrade_assistant.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/accessibility/apps/upgrade_assistant.ts b/x-pack/test/accessibility/apps/upgrade_assistant.ts index 850cb5de52bba..1f7fd2a654bca 100644 --- a/x-pack/test/accessibility/apps/upgrade_assistant.ts +++ b/x-pack/test/accessibility/apps/upgrade_assistant.ts @@ -143,8 +143,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - // Failing: See https://github.com/elastic/kibana/issues/115859 - it.skip('Index settings deprecation flyout', async () => { + it('Index settings deprecation flyout', async () => { await PageObjects.upgradeAssistant.clickEsDeprecation( 'indexSettings' // An index setting deprecation was added in the before() hook so should be guaranteed ); From e658ceff01dc4dd02acdeff84d2d3f8f6b694ea9 Mon Sep 17 00:00:00 2001 From: Ari Aviran Date: Mon, 21 Mar 2022 11:39:11 +0200 Subject: [PATCH 14/38] [Cloud Posture] Link blank page graphic to CIS integration (#127977) --- .../use_navigate_to_cis_integration.ts | 16 ++++++++++++++++ .../public/components/page_template.tsx | 10 ++++++---- .../public/pages/benchmarks/benchmarks.tsx | 8 +++----- 3 files changed, 25 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts new file mode 100644 index 0000000000000..f8b7685c776e0 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pagePathGetters } from '../../../../fleet/public'; +import { useKibana } from '../hooks/use_kibana'; + +const CIS_INTEGRATION_PATH = pagePathGetters.integrations_all({ searchTerm: 'CIS' }).join(''); + +export const useCISIntegrationLink = () => { + const { http } = useKibana().services; + return http.basePath.prepend(CIS_INTEGRATION_PATH); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx index f164b7b92fc72..d8640dd516f09 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx @@ -15,6 +15,7 @@ import { import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view'; import { allNavigationItems } from '../common/navigation/constants'; import type { CspNavigationItem } from '../common/navigation/types'; +import { useCISIntegrationLink } from '../common/navigation/use_navigate_to_cis_integration'; import { CLOUD_SECURITY_POSTURE } from '../common/translations'; import { CspLoadingState } from './csp_loading_state'; import { @@ -50,7 +51,7 @@ const DEFAULT_PROPS: KibanaPageTemplateProps = { restrictWidth: false, }; -const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = { +const getNoDataConfig = (cisIntegrationLink: string): KibanaPageTemplateProps['noDataConfig'] => ({ pageTitle: NO_DATA_CONFIG_TITLE, solution: NO_DATA_CONFIG_SOLUTION_NAME, // TODO: Add real docs link once we have it @@ -58,20 +59,21 @@ const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = { logo: 'logoSecurity', actions: { elasticAgent: { - // TODO: Use `href` prop to link to our own integration once we have it + href: cisIntegrationLink, title: NO_DATA_CONFIG_BUTTON, description: NO_DATA_CONFIG_DESCRIPTION, }, }, -}; +}); export const CspPageTemplate: React.FC = ({ children, ...props }) => { // TODO: Consider using more sophisticated logic to find out if our integration is installed const kubeBeatQuery = useKubebeatDataView(); + const cisIntegrationLink = useCISIntegrationLink(); let noDataConfig: KibanaPageTemplateProps['noDataConfig']; if (kubeBeatQuery.status === 'success' && !kubeBeatQuery.data) { - noDataConfig = NO_DATA_CONFIG; + noDataConfig = getNoDataConfig(cisIntegrationLink); } let template: KibanaPageTemplateProps['template'] = 'default'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx index 1c06d4162fc8b..a86877af4112c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx @@ -20,25 +20,23 @@ import { FormattedMessage } from '@kbn/i18n-react'; import useDebounce from 'react-use/lib/useDebounce'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; +import { useCISIntegrationLink } from '../../common/navigation/use_navigate_to_cis_integration'; import { CspPageTemplate } from '../../components/page_template'; import { BenchmarksTable } from './benchmarks_table'; import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations'; import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations'; -import { pagePathGetters } from '../../../../fleet/public'; -import { useKibana } from '../../common/hooks/use_kibana'; import { extractErrorMessage } from '../../../common/utils/helpers'; import { SEARCH_PLACEHOLDER } from './translations'; -const integrationPath = pagePathGetters.integrations_all({ searchTerm: 'CIS' }).join(''); const BENCHMARKS_BREADCRUMBS = [allNavigationItems.benchmarks]; const SEARCH_DEBOUNCE_MS = 300; export const BENCHMARKS_TABLE_DATA_TEST_SUBJ = 'cspBenchmarksTable'; const AddCisIntegrationButton = () => { - const { http } = useKibana().services; + const cisIntegrationLink = useCISIntegrationLink(); return ( - + {ADD_A_CIS_INTEGRATION} ); From b51e9abe6682522343a685b7e392fbceec860d64 Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Mon, 21 Mar 2022 11:50:27 +0200 Subject: [PATCH 15/38] [Cloud Posture] use explciit side nav links (#128119) --- .../public/components/page_template.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx index d8640dd516f09..3294ec5111455 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx @@ -46,7 +46,11 @@ export const getSideNavItems = ( const DEFAULT_PROPS: KibanaPageTemplateProps = { solutionNav: { name: CLOUD_SECURITY_POSTURE, - items: getSideNavItems(allNavigationItems), + items: getSideNavItems({ + dashboard: allNavigationItems.dashboard, + findings: allNavigationItems.findings, + benchmark: allNavigationItems.benchmarks, + }), }, restrictWidth: false, }; From 3a3a29478e39ef3e6d5544eec49c87bb948b36b5 Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Mon, 21 Mar 2022 11:54:26 +0200 Subject: [PATCH 16/38] [Cloud Posture] Add integration metadata to rules page (#127629) --- .../public/common/navigation/constants.ts | 2 +- .../public/components/csp_loading_state.tsx | 4 +- .../public/components/page_template.tsx | 2 +- .../pages/benchmarks/benchmarks_table.tsx | 4 +- .../public/pages/rules/index.tsx | 82 +++++++++++-- .../public/pages/rules/rules.test.tsx | 111 ++++++++++++++++++ .../public/pages/rules/rules_container.tsx | 45 ++++++- .../pages/rules/use_csp_integration.tsx | 19 +++ 8 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index 661afac394e1c..1132b7a348b5d 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -14,7 +14,7 @@ export const allNavigationItems: Record = { findings: { name: TEXT.FINDINGS, path: '/findings' }, rules: { name: 'Rules', - path: '/benchmarks/:packageId/:policyId/rules', + path: '/benchmarks/:packagePolicyId/:policyId/rules', disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, benchmarks: { diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx index eb89284f27351..60586dd3c5c05 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx @@ -8,8 +8,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React from 'react'; +export const cspLoadingStateTestId = 'csp_loading_state'; + export const CspLoadingState: React.FunctionComponent = ({ children }) => ( - + diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx index 3294ec5111455..45cae3f996e36 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx @@ -87,9 +87,9 @@ export const CspPageTemplate: React.FC = ({ children, . return ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx index ac1b01b88a1b5..475d6c9077359 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx @@ -54,7 +54,7 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [ render: (packageName, benchmark) => ( history.push( generatePath(allNavigationItems.rules.path, { - packageId: benchmark.package_policy.id, + packagePolicyId: benchmark.package_policy.id, policyId: benchmark.package_policy.policy_id, }) ), diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx index 0b511c9fb9031..bcfc06e8e16a5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx @@ -4,28 +4,84 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; -import type { EuiPageHeaderProps } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { EuiTextColor, EuiEmptyPrompt } from '@elastic/eui'; +import * as t from 'io-ts'; import { CspPageTemplate } from '../../components/page_template'; -import { RulesContainer } from './rules_container'; +import { RulesContainer, type PageUrlParams } from './rules_container'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; +import type { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public'; +import { CspLoadingState } from '../../components/csp_loading_state'; +import { CspNavigationItem } from '../../common/navigation/types'; +import { extractErrorMessage } from '../../../common/utils/helpers'; +import { useCspIntegration } from './use_csp_integration'; -// TODO: -// - get selected integration - -const pageHeader: EuiPageHeaderProps = { - pageTitle: 'Rules', -}; +const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] => + [allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter( + (breadcrumb): breadcrumb is CspNavigationItem => !!breadcrumb.name + ); -const breadcrumbs = [allNavigationItems.rules]; +export const Rules = ({ match: { params } }: RouteComponentProps) => { + const integrationInfo = useCspIntegration(params); + const breadcrumbs = useMemo( + // TODO: make benchmark breadcrumb navigable + () => getRulesBreadcrumbs(integrationInfo.data?.name), + [integrationInfo.data?.name] + ); -export const Rules = () => { useCspBreadcrumbs(breadcrumbs); + const pageProps: KibanaPageTemplateProps = useMemo( + () => ({ + template: integrationInfo.status !== 'success' ? 'centeredContent' : undefined, + pageHeader: { + bottomBorder: false, // TODO: border still shows. + pageTitle: 'Rules', + description: integrationInfo.data && integrationInfo.data.package && ( + + ), + }, + }), + [integrationInfo.data, integrationInfo.status] + ); + return ( - - + + {integrationInfo.status === 'success' && } + {integrationInfo.status === 'error' && ( + + )} + {integrationInfo.status === 'loading' && } ); }; + +// react-query puts the response data on the 'error' object +const bodyError = t.type({ + body: t.type({ + message: t.string, + }), +}); + +const extractErrorBodyMessage = (err: unknown) => { + if (bodyError.is(err)) return err.body.message; + return extractErrorMessage(err); +}; + +const PageDescription = ({ text }: { text: string }) => ( + {text} +); + +const RulesErrorPrompt = ({ error }: { error: string }) => ( + {error}, + }} + /> +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx new file mode 100644 index 0000000000000..aaf7bdc557e21 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { Rules } from './index'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from 'react-query'; +import { TestProvider } from '../../test/test_provider'; +import { useCspIntegration } from './use_csp_integration'; +import { type RouteComponentProps } from 'react-router-dom'; +import { cspLoadingStateTestId } from '../../components/csp_loading_state'; +import type { PageUrlParams } from './rules_container'; +import * as TEST_SUBJECTS from './test_subjects'; + +jest.mock('./use_csp_integration', () => ({ + useCspIntegration: jest.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const getTestComponent = + (params: PageUrlParams): React.FC => + () => + ( + + )} + /> + + ); + +describe('', () => { + beforeEach(() => { + queryClient.clear(); + jest.clearAllMocks(); + }); + + it('calls API with URL params', async () => { + const params = { packagePolicyId: '1', policyId: '2' }; + const Component = getTestComponent(params); + const result = { + status: 'loading', + }; + + (useCspIntegration as jest.Mock).mockReturnValue(result); + + render(); + + expect(useCspIntegration).toHaveBeenCalledWith(params); + }); + + it('displays error state when request had an error', async () => { + const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' }); + const request = { + status: 'error', + data: null, + error: new Error('some error message'), + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect(await screen.findByText(request.error.message)).toBeInTheDocument(); + }); + + it('displays loading state when request is pending', () => { + const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' }); + const request = { + status: 'loading', + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect(screen.getByTestId(cspLoadingStateTestId)).toBeInTheDocument(); + }); + + it('displays success state when result request is resolved', async () => { + const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' }); + const request = { + status: 'success', + data: { + name: 'CIS Kubernetes Benchmark', + package: { + title: 'my package', + }, + }, + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect( + await screen.findByText(`${request.data.package.title}, ${request.data.name}`) + ).toBeInTheDocument(); + expect(await screen.findByTestId(TEST_SUBJECTS.CSP_RULES_CONTAINER)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx index cce93556a8306..39d8f38475287 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx @@ -5,7 +5,16 @@ * 2.0. */ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { type EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + type EuiBasicTable, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; import { extractErrorMessage } from '../../../common/utils/helpers'; import { RulesTable } from './rules_table'; import { RulesBottomBar } from './rules_bottom_bar'; @@ -19,6 +28,8 @@ import { } from './use_csp_rules'; import * as TEST_SUBJECTS from './test_subjects'; import { RuleFlyout } from './rules_flyout'; +import { pagePathGetters } from '../../../../fleet/public'; +import { useKibana } from '../../common/hooks/use_kibana'; interface RulesPageData { rules_page: RuleSavedObject[]; @@ -79,7 +90,10 @@ const getPage = (data: readonly RuleSavedObject[], { page, perPage }: RulesQuery const MAX_ITEMS_PER_PAGE = 10000; +export type PageUrlParams = Record<'policyId' | 'packagePolicyId', string>; + export const RulesContainer = () => { + const params = useParams(); const tableRef = useRef(null); const [changedRules, setChangedRules] = useState>(new Map()); const [selectedRuleId, setSelectedRuleId] = useState(null); @@ -145,6 +159,8 @@ export const RulesContainer = () => { return (
+ + setRulesQuery((currentQuery) => ({ ...currentQuery, search: value }))} @@ -196,3 +212,30 @@ export const RulesContainer = () => {
); }; + +const ManageIntegrationButton = ({ policyId, packagePolicyId }: PageUrlParams) => { + const { http } = useKibana().services; + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx new file mode 100644 index 0000000000000..52e07ae9e016c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery } from 'react-query'; +import { type PageUrlParams } from './rules_container'; +import { useKibana } from '../../common/hooks/use_kibana'; +import { type PackagePolicy, packagePolicyRouteService } from '../../../../../plugins/fleet/common'; + +export const useCspIntegration = ({ packagePolicyId }: PageUrlParams) => { + const { http } = useKibana().services; + return useQuery( + ['packagePolicy', { packagePolicyId }], + () => http.get<{ item: PackagePolicy }>(packagePolicyRouteService.getInfoPath(packagePolicyId)), + { select: (response) => response.item, enabled: !!packagePolicyId } + ); +}; From 27502306e096326ccc36253dd795a15891e34795 Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Mon, 21 Mar 2022 11:55:57 +0200 Subject: [PATCH 17/38] [Cloud Posture] update tabs content in findings rule flyout (#127976) --- .../public/pages/findings/findings_flyout.tsx | 129 ++++++++++-------- .../public/pages/findings/translations.ts | 117 +++++++++++----- 2 files changed, 156 insertions(+), 90 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx index 544e3542d1b59..f53d76b82c177 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx @@ -6,9 +6,9 @@ */ import React, { useState } from 'react'; import { + EuiCodeBlock, EuiFlexItem, EuiSpacer, - EuiCode, EuiDescriptionList, EuiTextColor, EuiFlyout, @@ -20,14 +20,19 @@ import { EuiTab, EuiFlexGrid, EuiCard, - PropsOf, + EuiFlexGroup, + type PropsOf, } from '@elastic/eui'; import { assertNever } from '@kbn/std'; import type { CspFinding } from './types'; import { CspEvaluationBadge } from '../../components/csp_evaluation_badge'; import * as TEXT from './translations'; -const tabs = ['result', 'rule', 'resource'] as const; +const tabs = ['remediation', 'resource', 'general'] as const; + +const CodeBlock: React.FC> = (props) => ( + +); type FindingsTab = typeof tabs[number]; @@ -44,15 +49,22 @@ interface FindingFlyoutProps { } export const FindingsRuleFlyout = ({ onClose, findings }: FindingFlyoutProps) => { - const [tab, setTab] = useState('result'); + const [tab, setTab] = useState('remediation'); return ( - - -

{TEXT.FINDINGS}

-
-
+ + + + + + + + {findings.rule.name} + + + + {tabs.map((v) => ( @@ -78,11 +90,15 @@ const Cards = ({ data }: { data: Card[] }) => ( {data.map((card) => ( - + ({ title: v[0], description: v[1] }))} + style={{ flexFlow: 'column' }} + descriptionProps={{ + style: { width: '100%' }, + }} /> @@ -92,38 +108,68 @@ const Cards = ({ data }: { data: Card[] }) => ( const FindingsTab = ({ tab, findings }: { findings: CspFinding; tab: FindingsTab }) => { switch (tab) { - case 'result': - return ; - case 'rule': - return ; + case 'remediation': + return ; case 'resource': return ; + case 'general': + return ; default: assertNever(tab); } }; -const getResourceCards = ({ resource }: CspFinding): Card[] => [ +const getResourceCards = ({ resource, host }: CspFinding): Card[] => [ { title: TEXT.RESOURCE, listItems: [ - [TEXT.FILENAME, {resource.filename}], + [TEXT.FILENAME, {resource.filename}], [TEXT.MODE, resource.mode], - [TEXT.PATH, {resource.path}], + [TEXT.PATH, {resource.path}], [TEXT.TYPE, resource.type], [TEXT.UID, resource.uid], ], }, + { + title: TEXT.HOST, + listItems: [ + [TEXT.ARCHITECTURE, host.architecture], + [TEXT.CONTAINERIZED, host.containerized ? 'true' : 'false'], + [TEXT.HOSTNAME, host.hostname], + [TEXT.ID, {host.id}], + [TEXT.IP, {host.ip.join(', ')}], + [TEXT.MAC, {host.mac.join(', ')}], + [TEXT.NAME, host.name], + ], + }, + { + title: TEXT.OS, + listItems: [ + [TEXT.CODENAME, host.os.codename], + [TEXT.FAMILY, host.os.family], + [TEXT.KERNEL, host.os.kernel], + [TEXT.NAME, host.os.name], + [TEXT.PLATFORM, host.os.platform], + [TEXT.TYPE, host.os.type], + [TEXT.VERSION, host.os.version], + ], + }, ]; -const getRuleCards = ({ rule }: CspFinding): Card[] => [ +const getGeneralCards = ({ rule }: CspFinding): Card[] => [ { title: TEXT.RULE, listItems: [ - [TEXT.BENCHMARK, rule.benchmark], + [TEXT.SEVERITY, ''], + [TEXT.INDEX, ''], + [TEXT.RULE_EVALUATED_AT, ''], + [TEXT.FRAMEWORK_SOURCES, ''], + [TEXT.SECTION, ''], + [TEXT.PROFILE_APPLICABILITY, ''], + [TEXT.AUDIT, ''], + [TEXT.BENCHMARK, rule.benchmark.name], [TEXT.NAME, rule.name], [TEXT.DESCRIPTION, rule.description], - [TEXT.REMEDIATION, {rule.remediation}], [ TEXT.TAGS, rule.tags.map((t) => ( @@ -136,47 +182,22 @@ const getRuleCards = ({ rule }: CspFinding): Card[] => [ }, ]; -const getResultCards = ({ result, agent, host, ...rest }: CspFinding): Card[] => [ +const getRemediationCards = ({ result, ...rest }: CspFinding): Card[] => [ { title: TEXT.RESULT, listItems: [ - [TEXT.EVALUATION, ], - [TEXT.EVIDENCE, {JSON.stringify(result.evidence, null, 2)}], - [TEXT.TIMESTAMP, rest['@timestamp']], - result.evaluation === 'failed' && [TEXT.REMEDIATION, rest.rule.remediation], - ].filter(Boolean) as Card['listItems'], - }, - { - title: TEXT.AGENT, - listItems: [ - [TEXT.NAME, agent.name], - [TEXT.ID, agent.id], - [TEXT.TYPE, agent.type], - [TEXT.VERSION, agent.version], + [TEXT.EXPECTED, ''], + [TEXT.EVIDENCE, {JSON.stringify(result.evidence, null, 2)}], + [TEXT.TIMESTAMP, {rest['@timestamp']}], ], }, { - title: TEXT.HOST, + title: TEXT.REMEDIATION, listItems: [ - [TEXT.ARCHITECTURE, host.architecture], - [TEXT.CONTAINERIZED, host.containerized ? 'true' : 'false'], - [TEXT.HOSTNAME, host.hostname], - [TEXT.ID, host.id], - [TEXT.IP, host.ip.join(',')], - [TEXT.MAC, host.mac.join(',')], - [TEXT.NAME, host.name], - ], - }, - { - title: TEXT.OS, - listItems: [ - [TEXT.CODENAME, host.os.codename], - [TEXT.FAMILY, host.os.family], - [TEXT.KERNEL, host.os.kernel], - [TEXT.NAME, host.os.name], - [TEXT.PLATFORM, host.os.platform], - [TEXT.TYPE, host.os.type], - [TEXT.VERSION, host.os.version], + ['', {rest.rule.remediation}], + [TEXT.IMPACT, rest.rule.impact], + [TEXT.DEFAULT_VALUE, ''], + [TEXT.RATIONALE, ''], ], }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts index 3517589a37a58..610f7b8e6e721 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts @@ -6,142 +6,187 @@ */ import { i18n } from '@kbn/i18n'; -export const NAME = i18n.translate('xpack.csp.name', { +export const NAME = i18n.translate('xpack.csp.findings.nameLabel', { defaultMessage: 'Name', }); -export const SEARCH_FAILED = i18n.translate('xpack.csp.search_failed', { +export const IMPACT = i18n.translate('xpack.csp.findings.impactLabel', { + defaultMessage: 'Impact', +}); + +export const DEFAULT_VALUE = i18n.translate('xpack.csp.findings.defaultValueLabel', { + defaultMessage: 'Default Value', +}); + +export const RATIONALE = i18n.translate('xpack.csp.findings.rationaleLabel', { + defaultMessage: 'Rationale', +}); + +export const SEARCH_FAILED = i18n.translate('xpack.csp.findings.searchFailedLabel', { defaultMessage: 'Search failed', }); -export const TAGS = i18n.translate('xpack.csp.tags', { +export const TAGS = i18n.translate('xpack.csp.findings.tagsLabel', { defaultMessage: 'Tags', }); -export const RULE_NAME = i18n.translate('xpack.csp.rule_name', { +export const RULE_NAME = i18n.translate('xpack.csp.findings.ruleNameLabel', { defaultMessage: 'Rule Name', }); -export const OS = i18n.translate('xpack.csp.os', { +export const OS = i18n.translate('xpack.csp.findings.osLabel', { defaultMessage: 'OS', }); -export const FINDINGS = i18n.translate('xpack.csp.findings', { +export const FINDINGS = i18n.translate('xpack.csp.findings.findingsLabel', { defaultMessage: 'Findings', }); -export const RESOURCE = i18n.translate('xpack.csp.resource', { +export const RESOURCE = i18n.translate('xpack.csp.findings.resourceLabel', { defaultMessage: 'Resource', }); -export const FILENAME = i18n.translate('xpack.csp.filename', { +export const FILENAME = i18n.translate('xpack.csp.findings.filenameLabel', { defaultMessage: 'Filename', }); -export const MODE = i18n.translate('xpack.csp.mode', { +export const MODE = i18n.translate('xpack.csp.findings.modeLabel', { defaultMessage: 'Mode', }); -export const TYPE = i18n.translate('xpack.csp.type', { +export const TYPE = i18n.translate('xpack.csp.findings.typeLabel', { defaultMessage: 'Type', }); -export const PATH = i18n.translate('xpack.csp.path', { +export const PATH = i18n.translate('xpack.csp.findings.pathLabel', { defaultMessage: 'Path', }); -export const UID = i18n.translate('xpack.csp.uid', { +export const UID = i18n.translate('xpack.csp.findings.uidLabel', { defaultMessage: 'UID', }); -export const GID = i18n.translate('xpack.csp.gid', { +export const GID = i18n.translate('xpack.csp.findings.gidLabel', { defaultMessage: 'GID', }); -export const RULE = i18n.translate('xpack.csp.rule', { +export const RULE = i18n.translate('xpack.csp.findings.ruleLabel', { defaultMessage: 'Rule', }); -export const DESCRIPTION = i18n.translate('xpack.csp.description', { +export const DESCRIPTION = i18n.translate('xpack.csp.findings.descriptionLabel', { defaultMessage: 'Description', }); -export const REMEDIATION = i18n.translate('xpack.csp.remediation', { +export const REMEDIATION = i18n.translate('xpack.csp.findings.remediationLabel', { defaultMessage: 'Remediation', }); -export const BENCHMARK = i18n.translate('xpack.csp.benchmark', { +export const BENCHMARK = i18n.translate('xpack.csp.findings.benchmarkLabel', { defaultMessage: 'Benchmark', }); -export const RESULT = i18n.translate('xpack.csp.result', { - defaultMessage: 'Result', +export const SEVERITY = i18n.translate('xpack.csp.findings.severityLabel', { + defaultMessage: 'Severity', }); -export const EVALUATION = i18n.translate('xpack.csp.evaluation', { +export const INDEX = i18n.translate('xpack.csp.findings.indexLabel', { + defaultMessage: 'Index', +}); + +export const RULE_EVALUATED_AT = i18n.translate('xpack.csp.findings.ruleEvaluatedAt', { + defaultMessage: 'Rule evaluated at', +}); + +export const FRAMEWORK_SOURCES = i18n.translate('xpack.csp.findings.frameworkSourcesLabel', { + defaultMessage: 'Framework Sources', +}); + +export const SECTION = i18n.translate('xpack.csp.findings.sectionLabel', { + defaultMessage: 'Section', +}); + +export const AUDIT = i18n.translate('xpack.csp.findings.auditLabel', { + defaultMessage: 'Audit', +}); + +export const RESULT = i18n.translate('xpack.csp.findings.resultLabel', { + defaultMessage: 'Result Details', +}); + +export const PROFILE_APPLICABILITY = i18n.translate( + 'xpack.csp.findings.profileApplicabilityLabel', + { defaultMessage: 'Profile Applicability' } +); + +export const EVALUATION = i18n.translate('xpack.csp.findings.evaluationLabel', { defaultMessage: 'Evaluation', }); -export const EVIDENCE = i18n.translate('xpack.csp.evidence', { +export const EXPECTED = i18n.translate('xpack.csp.findings.expectedLabel', { + defaultMessage: 'Expected', +}); + +export const EVIDENCE = i18n.translate('xpack.csp.findings.evidenceLabel', { defaultMessage: 'Evidence', }); -export const TIMESTAMP = i18n.translate('xpack.csp.timestamp', { +export const TIMESTAMP = i18n.translate('xpack.csp.findings.timestampLabel', { defaultMessage: 'Timestamp', }); -export const AGENT = i18n.translate('xpack.csp.agent', { +export const AGENT = i18n.translate('xpack.csp.findings.agentLabel', { defaultMessage: 'Agent', }); -export const VERSION = i18n.translate('xpack.csp.version', { +export const VERSION = i18n.translate('xpack.csp.findings.versionLabel', { defaultMessage: 'Version', }); -export const ID = i18n.translate('xpack.csp.id', { +export const ID = i18n.translate('xpack.csp.findings.idLabel', { defaultMessage: 'ID', }); -export const HOST = i18n.translate('xpack.csp.host', { +export const HOST = i18n.translate('xpack.csp.findings.hostLabel', { defaultMessage: 'HOST', }); -export const ARCHITECTURE = i18n.translate('xpack.csp.architecture', { +export const ARCHITECTURE = i18n.translate('xpack.csp.findings.architectureLabel', { defaultMessage: 'Architecture', }); -export const CONTAINERIZED = i18n.translate('xpack.csp.containerized', { +export const CONTAINERIZED = i18n.translate('xpack.csp.findings.containerizedLabel', { defaultMessage: 'Containerized', }); -export const HOSTNAME = i18n.translate('xpack.csp.hostname', { +export const HOSTNAME = i18n.translate('xpack.csp.findings.hostnameLabel', { defaultMessage: 'Hostname', }); -export const MAC = i18n.translate('xpack.csp.mac', { +export const MAC = i18n.translate('xpack.csp.findings.macLabel', { defaultMessage: 'Mac', }); -export const IP = i18n.translate('xpack.csp.ip', { +export const IP = i18n.translate('xpack.csp.findings.ipLabel', { defaultMessage: 'IP', }); -export const CODENAME = i18n.translate('xpack.csp.codename', { +export const CODENAME = i18n.translate('xpack.csp.findings.codenameLabel', { defaultMessage: 'Codename', }); -export const FAMILY = i18n.translate('xpack.csp.family', { +export const FAMILY = i18n.translate('xpack.csp.findings.familyLabel', { defaultMessage: 'Family', }); -export const KERNEL = i18n.translate('xpack.csp.kernel', { +export const KERNEL = i18n.translate('xpack.csp.findings.kernelLabel', { defaultMessage: 'Kernel', }); -export const PLATFORM = i18n.translate('xpack.csp.platform', { +export const PLATFORM = i18n.translate('xpack.csp.findings.platformLabel', { defaultMessage: 'Platform', }); -export const NO_FINDINGS = i18n.translate('xpack.csp.thereAreNoFindings', { +export const NO_FINDINGS = i18n.translate('xpack.csp.findings.nonFindingsLabel', { defaultMessage: 'There are no Findings', }); From f7c61839e8839284f6c7ab5ee60a737261ec299c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 21 Mar 2022 13:03:25 +0200 Subject: [PATCH 18/38] [Cases] Prevent fetching connectors if the user does not have read access to the actions plugin (#127872) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/lib/kibana/__mocks__/index.ts | 8 +- .../public/common/lib/kibana/hooks.test.tsx | 32 +++++ .../cases/public/common/lib/kibana/hooks.ts | 54 ++++++-- .../common/lib/kibana/kibana_react.mock.ts | 20 +++ .../cases/public/common/mock/connectors.ts | 109 ++++++++++++++++ .../plugins/cases/public/common/test_utils.ts | 4 +- .../cases/public/common/translations.ts | 8 ++ .../components/all_cases/all_cases_list.tsx | 2 +- .../components/all_cases/columns.test.tsx | 82 ++++++++---- .../public/components/all_cases/columns.tsx | 20 +-- .../components/all_cases/index.test.tsx | 7 - .../cases/public/components/app/index.tsx | 2 +- .../case_view/case_view_page.test.tsx | 2 +- .../components/case_view/case_view_page.tsx | 9 +- .../components/case_view/index.test.tsx | 2 +- .../configure_cases/__mock__/index.tsx | 2 +- .../configure_cases/connectors.test.tsx | 49 ++++--- .../components/configure_cases/connectors.tsx | 27 ++-- .../connectors_dropdown.test.tsx | 8 -- .../components/connectors/card.test.tsx | 12 -- .../components/create/connector.test.tsx | 29 +++-- .../public/components/create/connector.tsx | 13 +- .../components/create/description.test.tsx | 27 ++-- .../components/create/form_context.test.tsx | 2 +- .../components/edit_connector/index.test.tsx | 93 +++++++++----- .../components/edit_connector/index.tsx | 14 +- .../markdown_editor/renderer.test.tsx | 41 +++--- .../components/markdown_editor/use_plugins.ts | 8 +- .../components/recent_cases/index.test.tsx | 17 ++- .../use_push_to_service/index.test.tsx | 3 +- .../containers/configure/__mocks__/api.ts | 3 +- .../public/containers/configure/api.test.ts | 3 +- .../cases/public/containers/configure/mock.ts | 109 +--------------- .../containers/configure/translations.ts | 8 -- .../configure/use_action_types.test.tsx | 2 +- .../configure/use_connectors.test.tsx | 121 ++++++++++++++---- .../containers/configure/use_connectors.tsx | 40 +++--- .../plugins/cases/public/containers/mock.ts | 3 +- 38 files changed, 606 insertions(+), 389 deletions(-) create mode 100644 x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx create mode 100644 x-pack/plugins/cases/public/common/mock/connectors.ts diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index d7ce7318f8724..ffd1f2bc4c8c9 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -17,6 +17,7 @@ export const KibanaServices = { getKibanaVersion: jest.fn(() => '8.0.0'), getConfig: jest.fn(() => null), }; + export const useKibana = jest.fn().mockReturnValue({ services: createStartServicesMock(), }); @@ -46,6 +47,9 @@ export const useNavigation = jest.fn().mockReturnValue({ navigateTo: jest.fn(), }); -export const useKibanaCapabilities = jest.fn().mockReturnValue({ - visualize: true, +export const useApplicationCapabilities = jest.fn().mockReturnValue({ + actions: { crud: true, read: true }, + generalCases: { crud: true, read: true }, + visualize: { crud: true, read: true }, + dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx new file mode 100644 index 0000000000000..0f6a1e0035e5c --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useApplicationCapabilities } from './hooks'; +import { TestProviders } from '../../mock'; + +describe('hooks', () => { + describe('useApplicationCapabilities', () => { + it('should return the correct capabilities', async () => { + const { result } = renderHook<{}, ReturnType>( + () => useApplicationCapabilities(), + { + wrapper: ({ children }) => {children}, + } + ); + + expect(result.current).toEqual({ + actions: { crud: true, read: true }, + generalCases: { crud: true, read: true }, + visualize: { crud: true, read: true }, + dashboard: { crud: true, read: true }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index f8b3fc1df7b44..127274e5ed55f 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -7,7 +7,7 @@ import moment from 'moment-timezone'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { @@ -160,22 +160,48 @@ export const useNavigation = (appId: string) => { return { navigateTo, getAppUrl }; }; +interface Capabilities { + crud: boolean; + read: boolean; +} +interface UseApplicationCapabilities { + actions: Capabilities; + generalCases: Capabilities; + visualize: Capabilities; + dashboard: Capabilities; +} + /** - * Returns the capabilities of the main cases application + * Returns the capabilities of various applications * */ -export const useApplicationCapabilities = (): { crud: boolean; read: boolean } => { - const capabilities = useKibana().services.application.capabilities; - const casesCapabilities = capabilities[FEATURE_ID]; - return { - crud: !!casesCapabilities?.crud_cases, - read: !!casesCapabilities?.read_cases, - }; -}; -export const useKibanaCapabilities = (): { visualize?: boolean; dashboard?: boolean } => { + +export const useApplicationCapabilities = (): UseApplicationCapabilities => { const capabilities = useKibana().services?.application?.capabilities; + const casesCapabilities = capabilities[FEATURE_ID]; - return { - visualize: !!capabilities?.visualize?.save, - }; + return useMemo( + () => ({ + actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show }, + generalCases: { + crud: !!casesCapabilities?.crud_cases, + read: !!casesCapabilities?.read_cases, + }, + visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show }, + dashboard: { + crud: !!capabilities.dashboard?.createNew, + read: !!capabilities.dashboard?.show, + }, + }), + [ + capabilities.actions?.save, + capabilities.actions?.show, + capabilities.dashboard?.createNew, + capabilities.dashboard?.show, + capabilities.visualize?.save, + capabilities.visualize?.show, + casesCapabilities?.crud_cases, + casesCapabilities?.read_cases, + ] + ); }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts index 05da3dd75cf60..90291201d74e1 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -19,6 +19,8 @@ import { securityMock } from '../../../../../security/public/mocks'; import { spacesPluginMock } from '../../../../../spaces/public/mocks'; import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks'; import { BehaviorSubject } from 'rxjs'; +import { registerConnectorsToMockActionRegistry } from '../../mock/register_connectors'; +import { connectorsMock } from '../../mock/connectors'; export const createStartServicesMock = (): StartServices => { const services = { @@ -38,6 +40,24 @@ export const createStartServicesMock = (): StartServices => { new Map([['testAppId', { category: { label: 'Test' } } as unknown as PublicAppInfo]]) ); + services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ + actionTypeTitle: '.servicenow', + iconClass: 'logoSecurity', + }); + + registerConnectorsToMockActionRegistry( + services.triggersActionsUi.actionTypeRegistry, + connectorsMock + ); + + services.application.capabilities = { + ...services.application.capabilities, + actions: { save: true, show: true }, + generalCases: { crud_cases: true, read_cases: true }, + visualize: { save: true, show: true }, + dashboard: { show: true, createNew: true }, + }; + return services; }; diff --git a/x-pack/plugins/cases/public/common/mock/connectors.ts b/x-pack/plugins/cases/public/common/mock/connectors.ts new file mode 100644 index 0000000000000..01afbbee118a8 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/connectors.ts @@ -0,0 +1,109 @@ +/* + * 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 { ActionConnector, ActionTypeConnector } from '../../../common/api'; + +export const connectorsMock: ActionConnector[] = [ + { + id: 'servicenow-1', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, + { + id: 'resilient-2', + actionTypeId: '.resilient', + name: 'My Connector 2', + config: { + apiUrl: 'https://test/', + orgId: '201', + }, + isPreconfigured: false, + }, + { + id: 'jira-1', + actionTypeId: '.jira', + name: 'Jira', + config: { + apiUrl: 'https://instance.atlassian.ne', + }, + isPreconfigured: false, + }, + { + id: 'servicenow-sir', + actionTypeId: '.servicenow-sir', + name: 'My Connector SIR', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, + { + id: 'servicenow-uses-table-api', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + usesTableApi: true, + }, + isPreconfigured: false, + }, +]; + +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow-sir', + name: 'ServiceNow SIR', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.ts index 5965ccbcf504e..a7d4405ced030 100644 --- a/x-pack/plugins/cases/public/common/test_utils.ts +++ b/x-pack/plugins/cases/public/common/test_utils.ts @@ -12,8 +12,8 @@ import { MatcherFunction } from '@testing-library/react'; /** * Convenience utility to remove text appended to links by EUI */ -export const removeExternalLinkText = (str: string) => - str.replace(/\(opens in a new tab or window\)/g, ''); +export const removeExternalLinkText = (str: string | null) => + str?.replace(/\(opens in a new tab or window\)/g, ''); export async function waitForComponentToPaint

(wrapper: ReactWrapper

, amount = 0) { await act(async () => { diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 61554f5191dc8..5c349a65dd869 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -276,3 +276,11 @@ export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { export const APP_DESC = i18n.translate('xpack.cases.common.appDescription', { defaultMessage: 'Open and track issues, push information to third party systems.', }); + +export const READ_ACTIONS_PERMISSIONS_ERROR_MSG = i18n.translate( + 'xpack.cases.configure.readPermissionsErrorDescription', + { + defaultMessage: + 'You do not have permissions to view connectors. If you would like to view connectors, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 1ceb950ee201d..eae099404d318 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -99,7 +99,7 @@ export const AllCasesList = React.memo( // Post Comment to Case const { postComment, isLoading: isCommentUpdating } = usePostComment(); - const { connectors } = useConnectors({ toastPermissionsErrors: false }); + const { connectors } = useConnectors(); const sorting = useMemo( () => ({ diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 090ac0d31ed06..764a51443b0e3 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -11,23 +11,24 @@ import { mount } from 'enzyme'; import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; -import { useKibana } from '../../common/lib/kibana'; import { connectors } from '../configure_cases/__mock__'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; describe('ExternalServiceColumn ', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); + let appMockRender: AppMockRenderer; + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('Not pushed render', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() @@ -36,7 +37,12 @@ describe('ExternalServiceColumn ', () => { it('Up to date', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() @@ -45,7 +51,12 @@ describe('ExternalServiceColumn ', () => { it('Needs update', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() @@ -56,19 +67,42 @@ describe('ExternalServiceColumn ', () => { // If the component throws the test will fail expect(() => mount( - + + + ) ).not.toThrowError(); }); + + it('shows the connectors icon if the user has read access to actions', async () => { + const result = appMockRender.render( + + ); + + expect(result.getByTestId('cases-table-connector-icon')).toBeInTheDocument(); + }); + + it('hides the connectors icon if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render( + + ); + + expect(result.queryByTestId('cases-table-connector-icon')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 391bef00b6e86..f92f1605c4c51 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -34,7 +34,7 @@ import { getActions } from './actions'; import { UpdateCase } from '../../containers/use_get_cases'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; -import { useKibana } from '../../common/lib/kibana'; +import { useApplicationCapabilities, useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; @@ -409,6 +409,7 @@ const IconWrapper = styled.span` export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) => { const { triggersActionsUi } = useKibana().services; + const { actions } = useApplicationCapabilities(); if (theCase.externalService == null) { return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); @@ -426,13 +427,16 @@ export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) return (

- - - + {actions.read && ( + + + + )} ; const useConnectorsMock = useConnectors as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; describe('AllCases', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - const dispatchUpdateCaseProperty = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); @@ -70,7 +64,6 @@ describe('AllCases', () => { }; beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); (useGetReporters as jest.Mock).mockReturnValue({ reporters: ['casetester'], diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 5fb46676d9231..ba2a61ec6691f 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -23,7 +23,7 @@ const CasesAppComponent: React.FC = () => { {getCasesLazy({ owner: [APP_OWNER], useFetchAlertData: () => [false, {}], - userCanCrud: userCapabilities.crud, + userCanCrud: userCapabilities.generalCases.crud, basePath: '/', features: { alerts: { enabled: false } }, releasePhase: 'experimental', 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 index e5564ee9429aa..a933e823f8b3a 100644 --- 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 @@ -17,13 +17,13 @@ import { basicCaseMetrics, caseUserActions, getAlertUserAction, + connectorsMock, } 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 { ConnectorTypes } from '../../../common/api'; 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 index 235c8eabc9e59..72d976f45f618 100644 --- 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 @@ -202,13 +202,7 @@ export const CaseViewPage = React.memo( updateCase, ]); - const { - loading: isLoadingConnectors, - connectors, - permissionsError, - } = useConnectors({ - toastPermissionsErrors: false, - }); + const { loading: isLoadingConnectors, connectors } = useConnectors(); const [connectorName, isValidConnector] = useMemo(() => { const connector = connectors.find((c) => c.id === caseData.connector.id); @@ -403,7 +397,6 @@ export const CaseViewPage = React.memo( isLoading={isLoadingConnectors || (isLoading && loadingKey === 'connector')} isValidConnector={isLoadingConnectors ? true : isValidConnector} onSubmit={onSubmitConnector} - permissionsError={permissionsError} updateCase={handleUpdateCase} userActions={caseUserActions} userCanCrud={userCanCrud} 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 9d41e3ec0f7fc..d7d8ca6df8f8d 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 @@ -18,6 +18,7 @@ import { alertComment, getAlertUserAction, basicCaseMetrics, + connectorsMock, } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; import { SpacesApi } from '../../../../spaces/public'; @@ -27,7 +28,6 @@ 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 { ConnectorTypes } from '../../../common/api'; import { Case } from '../../../common/ui'; diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index 49ac373724336..46dbbdbe9a196 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -10,7 +10,7 @@ import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; -import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; +import { connectorsMock, actionTypesMock } from '../../../common/mock/connectors'; export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 7a6bca518ac3e..955e114561ce8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -10,18 +10,14 @@ import { mount, ReactWrapper } from 'enzyme'; import { render, screen } from '@testing-library/react'; import { Connectors, Props } from './connectors'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors, actionTypes } from './__mock__'; import { ConnectorTypes } from '../../../common/api'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('Connectors', () => { let wrapper: ReactWrapper; + let appMockRender: AppMockRenderer; const onChangeConnector = jest.fn(); const handleShowEditFlyout = jest.fn(); @@ -37,28 +33,30 @@ describe('Connectors', () => { updateConnectorDisabled: false, }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); - test('it shows the connectors from group', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('shows the connectors from group', () => { expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').first().exists()).toBe( true ); }); - test('it shows the connectors form row', () => { + it('shows the connectors form row', () => { expect(wrapper.find('[data-test-subj="case-connectors-form-row"]').first().exists()).toBe(true); }); - test('it shows the connectors dropdown', () => { + it('shows the connectors dropdown', () => { expect(wrapper.find('[data-test-subj="case-connectors-dropdown"]').first().exists()).toBe(true); }); - test('it pass the correct props to child', () => { + it('pass the correct props to child', () => { const connectorsDropdownProps = wrapper.find(ConnectorsDropdown).props(); expect(connectorsDropdownProps).toMatchObject({ disabled: false, @@ -69,7 +67,7 @@ describe('Connectors', () => { }); }); - test('the connector is changed successfully', () => { + it('the connector is changed successfully', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); @@ -77,7 +75,7 @@ describe('Connectors', () => { expect(onChangeConnector).toHaveBeenCalledWith('resilient-2'); }); - test('the connector is changed successfully to none', () => { + it('the connector is changed successfully to none', () => { onChangeConnector.mockClear(); const newWrapper = mount( { expect(onChangeConnector).toHaveBeenCalledWith('none'); }); - test('it shows the add connector button', () => { + it('shows the add connector button', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); @@ -105,7 +103,7 @@ describe('Connectors', () => { ).toBeTruthy(); }); - test('the text of the update button is shown correctly', () => { + it('the text of the update button is shown correctly', () => { const newWrapper = mount( { ).toBe('Update My Connector'); }); - test('it shows the deprecated callout when the connector is deprecated', async () => { + it('shows the deprecated callout when the connector is deprecated', async () => { render( { expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); }); - test('it does not shows the deprecated callout when the connector is none', async () => { + it('does not shows the deprecated callout when the connector is none', async () => { render(, { // wrapper: TestProviders produces a TS error wrapper: ({ children }) => {children}, @@ -147,4 +145,17 @@ describe('Connectors', () => { expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument(); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + expect( + result.getByTestId('configure-case-connector-permissions-error-msg') + ).toBeInTheDocument(); + expect(result.queryByTestId('case-connectors-dropdown')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index e75f7ed2bdffa..4b608246a4c22 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -12,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, + EuiText, } from '@elastic/eui'; import styled from 'styled-components'; @@ -24,6 +25,7 @@ import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common/api'; import { DeprecatedCallout } from '../connectors/deprecated_callout'; import { isDeprecatedConnector } from '../utils'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -55,6 +57,7 @@ const ConnectorsComponent: React.FC = ({ selectedConnector, updateConnectorDisabled, }) => { + const { actions } = useApplicationCapabilities(); const connector = useMemo( () => connectors.find((c) => c.id === selectedConnector.id), [connectors, selectedConnector.id] @@ -101,15 +104,21 @@ const ConnectorsComponent: React.FC = ({ > - + {actions.read ? ( + + ) : ( + + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} + + )} {selectedConnector.type !== ConnectorTypes.none && isDeprecatedConnector(connector) && ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 127c6c30febfb..4fd56525541a6 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -13,11 +13,6 @@ import { render, screen } from '@testing-library/react'; import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; import { connectors } from './__mock__'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorsDropdown', () => { let wrapper: ReactWrapper; @@ -29,10 +24,7 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx index 7a07e87a1da4c..6254150620fd4 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -9,21 +9,9 @@ import React from 'react'; import { mount } from 'enzyme'; import { ConnectorTypes } from '../../../common/api'; -import { useKibana } from '../../common/lib/kibana'; -import { connectors } from '../configure_cases/__mock__'; import { ConnectorCard } from './card'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorCard ', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); - }); - it('it does not throw when accessing the icon if the connector type is not registered', () => { expect(() => mount( diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index b48e8ef82ac0e..7a2a4b366c7a1 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -18,13 +18,10 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; -jest.mock('../../common/lib/kibana'); jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); @@ -34,7 +31,6 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; -const useKibanaMock = useKibana as jest.Mocked; const useGetIncidentTypesResponse = { isLoading: false, @@ -58,6 +54,7 @@ const defaultProps = { }; describe('Connector', () => { + let appMockRender: AppMockRenderer; let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { @@ -74,14 +71,9 @@ describe('Connector', () => { return

{children}
; }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); - }); - beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); @@ -179,4 +171,19 @@ describe('Connector', () => { }); }); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); + expect(result.queryByTestId('caseConnectors')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 3b479927ee069..ca196f06908b1 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useCallback, useMemo, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { ActionConnector } from '../../../common/api'; import { @@ -21,6 +21,8 @@ import { ConnectorFieldsForm } from '../connectors/fields_form'; import { FormProps, schema } from './schema'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; +import * as i18n from '../../common/translations'; interface Props { connectors: ActionConnector[]; @@ -54,6 +56,7 @@ ConnectorFields.displayName = 'ConnectorFields'; const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const { getFields, setFieldValue } = useFormContext(); const { connector: configurationConnector } = useCaseConfigure(); + const { actions } = useApplicationCapabilities(); const handleConnectorChange = useCallback(() => { const { fields } = getFields(); @@ -76,6 +79,14 @@ const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingC connectors, }); + if (!actions.read) { + return ( + + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} + + ); + } + return ( diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index a28e6819f0268..1fe13b17e7337 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -6,17 +6,19 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { act } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; +import userEvent, { specialChars } from '@testing-library/user-event'; import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Description } from './description'; import { schema, FormProps } from './schema'; +import { createAppMockRenderer, AppMockRenderer } from '../../common/mock'; jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); describe('Description', () => { let globalForm: FormHook; + let appMockRender: AppMockRenderer; const MockHookWrapperComponent: React.FC = ({ children }) => { const { form } = useForm({ @@ -33,32 +35,33 @@ describe('Description', () => { beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('it renders', async () => { - const wrapper = mount( + const result = appMockRender.render( ); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); + expect(result.getByTestId('caseDescription')).toBeInTheDocument(); }); it('it changes the description', async () => { - const wrapper = mount( + const result = appMockRender.render( ); - await act(async () => { - wrapper - .find(`[data-test-subj="caseDescription"] textarea`) - .first() - .simulate('change', { target: { value: 'My new description' } }); - }); + userEvent.type( + result.getByRole('textbox'), + `${specialChars.selectAll}${specialChars.delete}My new description` + ); - expect(globalForm.getFormData()).toEqual({ description: 'My new description' }); + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ description: 'My new description' }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index f70ea7eba4896..5e62415def154 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -18,7 +18,6 @@ import { usePostComment } from '../../containers/use_post_comment'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { connectorsMock } from '../../containers/configure/mock'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; @@ -41,6 +40,7 @@ import { SubmitCaseButton } from './submit_button'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { Choice } from '../connectors/servicenow/types'; import userEvent from '@testing-library/user-event'; +import { connectorsMock } from '../../common/mock/connectors'; const sampleId = 'case-id'; diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 0512501bf5e11..040fe0866a84e 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -8,17 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; import { render, waitFor, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { EditConnector, EditConnectorProps } from './index'; -import { TestProviders } from '../../common/mock'; -import { connectorsMock } from '../../containers/configure/mock'; -import { basicCase, basicPush, caseUserActions } from '../../containers/mock'; -import { useKibana } from '../../common/lib/kibana'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { basicCase, basicPush, caseUserActions, connectorsMock } from '../../containers/mock'; import { CaseConnector } from '../../containers/configure/types'; -import userEvent from '@testing-library/user-event'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; const onSubmit = jest.fn(); const updateCase = jest.fn(); @@ -48,12 +43,10 @@ const getDefaultProps = (): EditConnectorProps => { }; describe('EditConnector ', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: '.servicenow', - iconClass: 'logoSecurity', - }); + appMockRender = createAppMockRenderer(); }); it('Renders servicenow connector from case initially', async () => { @@ -224,28 +217,6 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy(); }); - it('displays the permissions error message when one is provided', async () => { - const defaultProps = getDefaultProps(); - const props = { ...defaultProps, permissionsError: 'error message' }; - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() - ).toBeTruthy(); - - expect( - wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() - ).toBeFalsy(); - - expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy(); - }); - }); - it('displays the callout message when none is selected', async () => { const defaultProps = getDefaultProps(); const props = { ...defaultProps, connectors: [] }; @@ -336,4 +307,58 @@ describe('EditConnector ', () => { expect(true).toBeTruthy(); }); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); + }); + }); + + it('does not show the actions permission message if the user has read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: true, show: true }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.queryByTestId('edit-connector-permissions-error-msg')).toBe(null); + }); + }); + + it('does not show the callout if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + const props = { ...defaultProps, connectors: [] }; + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); + expect(result.queryByTestId('push-callouts')).toBe(null); + }); + }); + + it('does not show the push button if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.queryByTestId('has-data-to-push-button')).toBe(null); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 7db20170c7857..85bf7de10b7ca 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -31,6 +31,7 @@ import * as i18n from './translations'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { usePushToService } from '../use_push_to_service'; import { CaseServices } from '../../containers/use_get_case_user_actions'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; export interface EditConnectorProps { caseData: Case; @@ -46,7 +47,6 @@ export interface EditConnectorProps { onError: () => void, onSuccess: () => void ) => void; - permissionsError?: string; updateCase: (newCase: Case) => void; userActions: CaseUserActions[]; userCanCrud?: boolean; @@ -119,18 +119,20 @@ export const EditConnector = React.memo( isLoading, isValidConnector, onSubmit, - permissionsError, updateCase, userActions, userCanCrud = true, }: EditConnectorProps) => { const caseFields = caseData.connector.fields; const selectedConnector = caseData.connector.id; + const { form } = useForm({ defaultValue: { connectorId: selectedConnector }, options: { stripEmptyFields: false }, schema, }); + const { actions } = useApplicationCapabilities(); + const actionsReadCapabilities = actions.read; // by default save if disabled const [enableSave, setEnableSave] = useState(false); @@ -303,7 +305,7 @@ export const EditConnector = React.memo( - {!isLoading && !editConnector && pushCallouts && permissionsError == null && ( + {!isLoading && !editConnector && pushCallouts && actionsReadCapabilities && ( {pushCallouts} )} @@ -330,9 +332,9 @@ export const EditConnector = React.memo( - {!editConnector && permissionsError && ( + {!editConnector && !actionsReadCapabilities && ( - {permissionsError} + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} )} {pushButton} diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx index 5d299529561ba..af803cfc14e05 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx @@ -6,55 +6,54 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { removeExternalLinkText } from '../../common/test_utils'; import { MarkdownRenderer } from './renderer'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; describe('Markdown', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + describe('markdown links', () => { const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; test('it renders the expected link text', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect( - removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text()) - ).toEqual('External Site'); + expect(removeExternalLinkText(result.getByTestId('markdown-link').textContent)).toEqual( + 'External Site' + ); }); test('it renders the expected href', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'href', - 'https://google.com/' - ); + expect(result.getByTestId('markdown-link')).toHaveProperty('href', 'https://google.com/'); }); test('it does NOT render the href if links are disabled', () => { - const wrapper = mount( + const result = appMockRender.render( {markdownWithLink} ); - expect( - wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() - ).not.toHaveProperty('href'); + expect(result.getByTestId('markdown-link')).not.toHaveProperty('href'); }); test('it opens links in a new tab via target="_blank"', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'target', - '_blank' - ); + expect(result.getByTestId('markdown-link')).toHaveProperty('target', '_blank'); }); test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + expect(result.getByTestId('markdown-link')).toHaveProperty( 'rel', 'nofollow noopener noreferrer' ); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts index b11d3e42b0b9e..15d92d1c7f1a1 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts @@ -13,14 +13,14 @@ import { import { useMemo } from 'react'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { TemporaryProcessingPluginsType } from './types'; -import { KibanaServices, useKibanaCapabilities } from '../../common/lib/kibana'; +import { KibanaServices, useApplicationCapabilities } from '../../common/lib/kibana'; import * as lensMarkdownPlugin from './plugins/lens'; import { ID as LensPluginId } from './plugins/lens/constants'; export const usePlugins = (disabledPlugins?: string[]) => { const kibanaConfig = KibanaServices.getConfig(); const timelinePlugins = useTimelineContext()?.editor_plugins; - const appCapabilities = useKibanaCapabilities(); + const appCapabilities = useApplicationCapabilities(); return useMemo(() => { const uiPlugins = getDefaultEuiMarkdownUiPlugins(); @@ -40,7 +40,7 @@ export const usePlugins = (disabledPlugins?: string[]) => { if ( kibanaConfig?.markdownPlugins?.lens && !disabledPlugins?.includes(LensPluginId) && - appCapabilities?.visualize + appCapabilities?.visualize.crud ) { uiPlugins.push(lensMarkdownPlugin.plugin); } @@ -55,7 +55,7 @@ export const usePlugins = (disabledPlugins?: string[]) => { processingPlugins, }; }, [ - appCapabilities?.visualize, + appCapabilities?.visualize.crud, disabledPlugins, kibanaConfig?.markdownPlugins?.lens, timelinePlugins, diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx index 4f99f23b9b208..74b3a46398292 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -6,10 +6,10 @@ */ import React from 'react'; -import { configure, render } from '@testing-library/react'; +import { configure } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import RecentCases, { RecentCasesProps } from '.'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesMockState } from '../../containers/mock'; import { useCurrentUser } from '../../common/lib/kibana/hooks'; @@ -33,6 +33,7 @@ const useGetCasesMock = useGetCases as jest.Mock; const useCurrentUserMock = useCurrentUser as jest.Mock; describe('RecentCases', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); useGetCasesMock.mockImplementation(() => mockData); @@ -41,6 +42,7 @@ describe('RecentCases', () => { fullName: 'Elastic', username: 'elastic', }); + appMockRender = createAppMockRenderer(); }); it('is good at loading', () => { @@ -48,7 +50,8 @@ describe('RecentCases', () => { ...mockData, loading: 'cases', })); - const { getAllByTestId } = render( + + const { getAllByTestId } = appMockRender.render( @@ -57,7 +60,7 @@ describe('RecentCases', () => { }); it('is good at rendering cases', () => { - const { getAllByTestId } = render( + const { getAllByTestId } = appMockRender.render( @@ -66,7 +69,7 @@ describe('RecentCases', () => { }); it('is good at rendering max cases', () => { - render( + appMockRender.render( @@ -77,7 +80,7 @@ describe('RecentCases', () => { }); it('updates filters', () => { - const { getByTestId } = render( + const { getByTestId } = appMockRender.render( @@ -89,7 +92,7 @@ describe('RecentCases', () => { }); it('it resets the reporters when changing from my recently reported cases to recent cases', () => { - const { getByTestId } = render( + const { getByTestId } = appMockRender.render( diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index cf81b5195a961..1c97c6ff30506 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -14,9 +14,8 @@ import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../common/mock'; import { CaseStatuses, ConnectorTypes } from '../../../common/api'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { basicPush, actionLicenses } from '../../containers/mock'; +import { basicPush, actionLicenses, connectorsMock } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { connectorsMock } from '../../containers/configure/mock'; import { CLOSED_CASE_PUSH_ERROR_ID } from './callout/types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts index 10cfde0c5ef9c..b24213cc43af7 100644 --- a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -14,7 +14,8 @@ import { import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; -import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; +import { caseConfigurationCamelCaseResponseMock } from '../mock'; +import { actionTypesMock, connectorsMock } from '../../../common/mock/connectors'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => Promise.resolve(connectorsMock); diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts index a315a455ec2a2..7b5db5692011e 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -13,8 +13,6 @@ import { fetchActionTypes, } from './api'; import { - connectorsMock, - actionTypesMock, caseConfigurationMock, caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, @@ -22,6 +20,7 @@ import { import { ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; +import { actionTypesMock, connectorsMock } from '../../common/mock/connectors'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index bbcf420324c83..c75d2c839534d 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - ActionConnector, - ActionTypeConnector, - CasesConfigureResponse, - CasesConfigureRequest, - ConnectorTypes, -} from '../../../common/api'; +import { CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { CaseConfigure, CaseConnectorMapping } from './types'; @@ -33,107 +27,6 @@ export const mappings: CaseConnectorMapping[] = [ }, ]; -export const connectorsMock: ActionConnector[] = [ - { - id: 'servicenow-1', - actionTypeId: '.servicenow', - name: 'My Connector', - config: { - apiUrl: 'https://instance1.service-now.com', - }, - isPreconfigured: false, - }, - { - id: 'resilient-2', - actionTypeId: '.resilient', - name: 'My Connector 2', - config: { - apiUrl: 'https://test/', - orgId: '201', - }, - isPreconfigured: false, - }, - { - id: 'jira-1', - actionTypeId: '.jira', - name: 'Jira', - config: { - apiUrl: 'https://instance.atlassian.ne', - }, - isPreconfigured: false, - }, - { - id: 'servicenow-sir', - actionTypeId: '.servicenow-sir', - name: 'My Connector SIR', - config: { - apiUrl: 'https://instance1.service-now.com', - }, - isPreconfigured: false, - }, - { - id: 'servicenow-uses-table-api', - actionTypeId: '.servicenow', - name: 'My Connector', - config: { - apiUrl: 'https://instance1.service-now.com', - usesTableApi: true, - }, - isPreconfigured: false, - }, -]; - -export const actionTypesMock: ActionTypeConnector[] = [ - { - id: '.email', - name: 'Email', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.index', - name: 'Index', - minimumLicenseRequired: 'basic', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.resilient', - name: 'IBM Resilient', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.servicenow-sir', - name: 'ServiceNow SIR', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, -]; - export const caseConfigurationResposeMock: CasesConfigureResponse = { id: '123', created_at: '2020-04-06T13:03:18.657Z', diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts index 01900b8850c19..e77b9f57c8f4c 100644 --- a/x-pack/plugins/cases/public/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -12,11 +12,3 @@ export * from '../translations'; export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { defaultMessage: 'Saved external connection settings', }); - -export const READ_PERMISSIONS_ERROR_MSG = i18n.translate( - 'xpack.cases.configure.readPermissionsErrorDescription', - { - defaultMessage: - 'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.', - } -); diff --git a/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx index fad84617ee140..3b19e74d09208 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx @@ -7,8 +7,8 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useActionTypes, UseActionTypesResponse } from './use_action_types'; -import { actionTypesMock } from './mock'; import * as api from './api'; +import { actionTypesMock } from '../../common/mock/connectors'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx index e3d2650fee025..b1a3bac22d56f 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx @@ -5,41 +5,53 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useConnectors, UseConnectorsResponse } from './use_connectors'; -import { connectorsMock } from './mock'; import * as api from './api'; +import { connectorsMock } from '../mock'; +import { TestProviders } from '../../common/mock'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; + +const useApplicationCapabilitiesMock = useApplicationCapabilities as jest.Mocked< + typeof useApplicationCapabilities +>; -jest.mock('./api'); jest.mock('../../common/lib/kibana'); +jest.mock('./api'); describe('useConnectors', () => { beforeEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); }); - test('init', async () => { + it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() - ); - await waitForNextUpdate(); - expect(result.current).toEqual({ - loading: true, - connectors: [], - refetchConnectors: result.current.refetchConnectors, + const { result, waitFor } = renderHook(() => useConnectors(), { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + loading: true, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); }); }); }); - test('fetch connectors', async () => { + it('fetch connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); + await waitForNextUpdate(); - await waitForNextUpdate(); + expect(result.current).toEqual({ loading: false, connectors: connectorsMock, @@ -48,44 +60,97 @@ describe('useConnectors', () => { }); }); - test('refetch connectors', async () => { + it('refetch connectors', async () => { const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); - await waitForNextUpdate(); result.current.refetchConnectors(); expect(spyOnfetchConnectors).toHaveBeenCalledTimes(2); }); }); - test('set isLoading to true when refetching connectors', async () => { + it('set isLoading to true when refetching connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); - await waitForNextUpdate(); result.current.refetchConnectors(); expect(result.current.loading).toBe(true); }); }); - test('unhappy path', async () => { + it('unhappy path', async () => { const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); spyOnfetchConnectors.mockImplementation(() => { throw new Error('Something went wrong'); }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + it('does not fetch connectors when the user does not has access to actions', async () => { + const spyOnFetchConnectors = jest.spyOn(api, 'fetchConnectors'); + useApplicationCapabilitiesMock().actions = { crud: false, read: false }; + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + + expect(spyOnFetchConnectors).not.toHaveBeenCalled(); + }); + + it('does not refetch connectors when the user does not has access to actions', async () => { + const spyOnFetchConnectors = jest.spyOn(api, 'fetchConnectors'); + useApplicationCapabilitiesMock().actions = { crud: false, read: false }; + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + result.current.refetchConnectors(); expect(result.current).toEqual({ loading: false, @@ -93,5 +158,7 @@ describe('useConnectors', () => { refetchConnectors: result.current.refetchConnectors, }); }); + + expect(spyOnFetchConnectors).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index e350146c650ce..e8176f5f397e8 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -9,13 +9,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; -import { useToasts } from '../../common/lib/kibana'; +import { useApplicationCapabilities, useToasts } from '../../common/lib/kibana'; import * as i18n from './translations'; interface ConnectorsState { loading: boolean; connectors: ActionConnector[]; - permissionsError?: string; } export interface UseConnectorsResponse { @@ -30,12 +29,9 @@ export interface UseConnectorsResponse { * * @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error */ -export const useConnectors = ({ - toastPermissionsErrors = true, -}: { - toastPermissionsErrors?: boolean; -} = {}): UseConnectorsResponse => { +export const useConnectors = (): UseConnectorsResponse => { const toasts = useToasts(); + const { actions } = useApplicationCapabilities(); const [state, setState] = useState({ loading: true, connectors: [], @@ -43,8 +39,16 @@ export const useConnectors = ({ const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); - const refetchConnectors = useCallback(async () => { + if (!actions.read) { + setState({ + loading: false, + connectors: [], + }); + + return; + } + try { isCancelledRef.current = false; abortCtrlRef.current.abort(); @@ -63,26 +67,15 @@ export const useConnectors = ({ } } catch (error) { if (!isCancelledRef.current) { - let permissionsError: string | undefined; if (error.name !== 'AbortError') { - // if the error was related to permissions then let's return a boilerplate error message describing the problem - if (error.body?.statusCode === 403 || error.body?.statusCode === 401) { - permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG; - } - - // if the error was not permissions related then toast it - // if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast - if (permissionsError === undefined || toastPermissionsErrors) { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setState({ loading: false, connectors: [], - permissionsError, }); } } @@ -102,6 +95,5 @@ export const useConnectors = ({ loading: state.loading, connectors: state.connectors, refetchConnectors, - permissionsError: state.permissionsError, }; }; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 0d2aff225dfa6..5c2fcd70db2bb 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -31,11 +31,12 @@ import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { SnakeToCamelCase } from '../../common/types'; import { covertToSnakeCase } from './utils'; -export { connectorsMock } from './configure/mock'; +export { connectorsMock } from '../common/mock/connectors'; export const basicCaseId = 'basic-case-id'; export const caseWithAlertsId = 'case-with-alerts-id'; export const caseWithAlertsSyncOffId = 'case-with-alerts-syncoff-id'; + const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; From 9fa9ff2d72381525b12323a4b0bb9a4e5b6592c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Mon, 21 Mar 2022 12:47:16 +0100 Subject: [PATCH 19/38] [Security Solution] [Endpoint] Users can access blocklist configurations from installed integrations and package policy (#127785) * Adds Blocklist cards on fleet endpoint security integration pages * Adds blocklists card on fleet security solution integration pages. It also includes a generic card component * Includes privileges to avoid rendering host isolation exceptions card when no permisions * Fixes ts type checks * Remove unused code in order to fix ts checks * Adds unit tests and fix ts types and translations * Adds ftr tests and update data-test-subj to be artifact specific * Adds missing prop to FleetIntegrationArtifactsCard component in unit test * Fixes ftr test because the testSubject was wrong * Fixes wrong artifact names for trusted applications and blocklist * Fixes translation files removing unused translations Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../artifacts/use_summary_artifact.test.tsx | 4 +- .../hooks/artifacts/use_summary_artifact.tsx | 17 +- .../components/fleet_artifacts_card.test.tsx | 82 +++++++ .../components/fleet_artifacts_card.tsx | 123 ++++++++++ .../fleet_event_filters_card.test.tsx | 117 --------- .../components/fleet_event_filters_card.tsx | 128 ---------- ...et_host_isolation_exceptions_card.test.tsx | 110 --------- .../fleet_host_isolation_exceptions_card.tsx | 130 ---------- ...fleet_integration_artifacts_card.test.tsx} | 50 +++- .../fleet_integration_artifacts_card.tsx | 156 ++++++++++++ .../fleet_integration_event_filters_card.tsx | 145 ----------- ...on_host_isolation_exceptions_card.test.tsx | 110 --------- ...gration_host_isolation_exceptions_card.tsx | 160 ------------- .../fleet_trusted_apps_card.test.tsx | 137 ----------- .../components/fleet_trusted_apps_card.tsx | 128 ---------- .../fleet_trusted_apps_card_wrapper.tsx | 73 ------ .../index.tsx | 145 ++++++++++- .../endpoint_policy_edit_extension.tsx | 226 +++++++++++++----- .../translations/translations/fr-FR.json | 6 - .../translations/translations/ja-JP.json | 15 -- .../translations/translations/zh-CN.json | 15 -- .../apps/endpoint/fleet_integrations.ts | 2 +- .../apps/endpoint/policy_details.ts | 23 +- 23 files changed, 732 insertions(+), 1370 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx rename x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/{fleet_integration_event_filters_card.test.tsx => fleet_integration_artifacts_card.test.tsx} (62%) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx index e2e9fac383d12..18c8f928660d9 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx @@ -52,7 +52,7 @@ describe('Summary artifact hook', () => { result = await renderQuery( () => - useSummaryArtifact(instance, searchableFields, options, { + useSummaryArtifact(instance, options, searchableFields, { onSuccess: onSuccessMock, retry: false, }), @@ -84,7 +84,7 @@ describe('Summary artifact hook', () => { result = await renderQuery( () => - useSummaryArtifact(instance, searchableFields, options, { + useSummaryArtifact(instance, options, searchableFields, { onError: onErrorMock, retry: false, }), diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx index 9e4ca1682f022..b92929f10503f 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx @@ -9,22 +9,23 @@ import { HttpFetchError } from 'kibana/public'; import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../common/utils'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; +import { MaybeImmutable } from '../../../../common/endpoint/types'; const DEFAULT_OPTIONS = Object.freeze({}); export function useSummaryArtifact( exceptionListApiClient: ExceptionsListApiClient, - searchableFields: string[], - options: { + options: Partial<{ filter: string; policies: string[]; - } = { - filter: '', - policies: [], - }, - customQueryOptions: UseQueryOptions = DEFAULT_OPTIONS + }> = DEFAULT_OPTIONS, + searchableFields: MaybeImmutable = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, + customQueryOptions: Partial< + UseQueryOptions + > = DEFAULT_OPTIONS ): QueryObserverResult { - const { filter, policies } = options; + const { filter = '', policies = [] } = options; return useQuery( ['summary', exceptionListApiClient, options], diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx new file mode 100644 index 0000000000000..87860db1fe69d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.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 { act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../../common/mock/endpoint'; +import { getEventFiltersListPath } from '../../../../../../common/routing'; +import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../../../common/components/user_privileges/endpoint/mocks'; +import { useToasts } from '../../../../../../../common/lib/kibana'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { FleetArtifactsCard } from './fleet_artifacts_card'; +import { EVENT_FILTERS_LABELS } from '..'; + +jest.mock('../../../../../../../common/lib/kibana'); + +describe('Fleet artifacts card', () => { + let render: (externalPrivileges?: boolean) => Promise>; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockedApi: ReturnType; + let addDanger: jest.Mock = jest.fn(); + const useToastsMock = useToasts as jest.Mock; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: true, + }); + render = async () => { + await act(async () => { + renderResult = mockedContext.render( + // @ts-expect-error TS2739 + + ); + await waitFor(mockedApi.responseProvider.eventFiltersList); + }); + return renderResult; + }; + }); + + beforeAll(() => { + useToastsMock.mockImplementation(() => { + return { + addDanger, + }; + }); + }); + beforeEach(() => { + addDanger = jest.fn(); + }); + + it('should render correctly', async () => { + const component = await render(); + expect(component.getByText('Event filters')).not.toBeNull(); + expect(component.getByText('Manage')).not.toBeNull(); + }); + it('should render an error toast when api call fails', async () => { + expect(addDanger).toBeCalledTimes(0); + mockedApi.responseProvider.eventFiltersGetSummary.mockImplementation(() => { + throw new Error('error getting summary'); + }); + const component = await render(); + expect(component.getByText('Event filters')).not.toBeNull(); + expect(component.getByText('Manage')).not.toBeNull(); + await waitFor(() => expect(addDanger).toBeCalledTimes(1)); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx new file mode 100644 index 0000000000000..dc8256d93ae00 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + PackageCustomExtensionComponentProps, + pagePathGetters, +} from '../../../../../../../../../fleet/public'; +import { ListPageRouteState } from '../../../../../../../../common/endpoint/types'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; +import { useToasts } from '../../../../../../../common/lib/kibana'; +import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; +import { LinkWithIcon } from './link_with_icon'; +import { ExceptionItemsSummary } from './exception_items_summary'; +import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; +import { useSummaryArtifact } from '../../../../../../hooks/artifacts'; +import { ExceptionsListApiClient } from '../../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useTestIdGenerator } from '../../../../../../components/hooks/use_test_id_generator'; + +const ARTIFACTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetCustomExtension.artifactsSummaryError', { + defaultMessage: 'There was an error trying to fetch artifacts stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), +}; + +export type ARTIFACTS_LABELS_TYPE = typeof ARTIFACTS_LABELS; + +type FleetArtifactsCardProps = PackageCustomExtensionComponentProps & { + artifactApiClientInstance: ExceptionsListApiClient; + getArtifactsPath: () => string; + labels?: ARTIFACTS_LABELS_TYPE; + 'data-test-subj': string; +}; + +export const FleetArtifactsCard = memo( + ({ + pkgkey, + artifactApiClientInstance, + getArtifactsPath, + labels = ARTIFACTS_LABELS, + 'data-test-subj': dataTestSubj, + }) => { + const { getAppUrl } = useAppUrl(); + const toasts = useToasts(); + const artifactsListUrlPath = getArtifactsPath(); + const getTestId = useTestIdGenerator(dataTestSubj); + + const { data } = useSummaryArtifact(artifactApiClientInstance, {}, [], { + onError: (error) => toasts.addDanger(labels.artifactsSummaryApiError(error.message)), + }); + + const artifactsRouteState = useMemo(() => { + const fleetPackageCustomUrlPath = `#${ + pagePathGetters.integration_details_custom({ pkgkey })[1] + }`; + return { + backButtonLabel: i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', + { defaultMessage: 'Return to Endpoint Security integrations' } + ), + onBackButtonNavigateTo: [ + INTEGRATIONS_PLUGIN_ID, + { + path: fleetPackageCustomUrlPath, + }, + ], + backButtonUrl: getAppUrl({ + appId: INTEGRATIONS_PLUGIN_ID, + path: fleetPackageCustomUrlPath, + }), + }; + }, [getAppUrl, pkgkey]); + + return ( + + + + +

{labels.cardTitle}

+
+
+ + + + + <> + + + + + +
+
+ ); + } +); + +FleetArtifactsCard.displayName = 'FleetArtifactsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx deleted file mode 100644 index 148f85cb301e9..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { I18nProvider } from '@kbn/i18n-react'; -import { FleetEventFiltersCard } from './fleet_event_filters_card'; -import * as reactTestingLibrary from '@testing-library/react'; -import { EventFiltersHttpService } from '../../../../../event_filters/service'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../event_filters/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -// Casting to unknown to avoid ts error because there is an static method in the class -const EventFiltersHttpServiceMock = EventFiltersHttpService as unknown as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -describe('Fleet event filters card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: () => Promise = async () => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - // @ts-expect-error TS2739 - const component = reactTestingLibrary.render(, { wrapper: Wrapper }); - try { - // @ts-expect-error TS2769 - await reactTestingLibrary.act(() => promise); - } catch (err) { - return component; - } - return component; - }; - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - EventFiltersHttpServiceMock.mockReset(); - }); - it('should render correctly', async () => { - EventFiltersHttpServiceMock.mockImplementationOnce(() => { - return { - getSummary: () => jest.fn(() => promise), - }; - }); - const component = await renderComponent(); - expect(component.getByText('Event filters')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - EventFiltersHttpServiceMock.mockImplementationOnce(() => { - return { - getSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Event filters')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx deleted file mode 100644 index a470d4b63e7bd..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useState, useEffect, useRef } from 'react'; -import { EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { getEventFiltersListPath } from '../../../../../../common/routing'; -import { - GetExceptionSummaryResponse, - ListPageRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { LinkWithIcon } from './link_with_icon'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { EventFiltersHttpService } from '../../../../../event_filters/service'; -import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; - -export const FleetEventFiltersCard = memo(({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const eventFiltersListUrlPath = getEventFiltersListPath(); - const eventFiltersApi = useMemo(() => new EventFiltersHttpService(http), [http]); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await eventFiltersApi.getSummary(); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError', - { - defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [eventFiltersApi, toasts]); - - const eventFiltersRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - return ( - - - - -

- -

-
-
- - - - - <> - - - - - -
-
- ); -}); - -FleetEventFiltersCard.displayName = 'FleetEventFiltersCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx deleted file mode 100644 index a60c6aac602e0..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { I18nProvider } from '@kbn/i18n-react'; -import * as reactTestingLibrary from '@testing-library/react'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { FleetHostIsolationExceptionsCard } from './fleet_host_isolation_exceptions_card'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../host_isolation_exceptions/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -const getHostIsolationExceptionSummaryMock = getHostIsolationExceptionSummary as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -describe('Fleet host isolation exceptions card filters card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: () => Promise = async () => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - // @ts-expect-error TS2739 - const component = reactTestingLibrary.render(, { - wrapper: Wrapper, - }); - try { - // @ts-expect-error TS2769 - await reactTestingLibrary.act(() => promise); - } catch (err) { - return component; - } - return component; - }; - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - getHostIsolationExceptionSummaryMock.mockReset(); - }); - it('should render correctly', async () => { - getHostIsolationExceptionSummaryMock.mockReturnValueOnce(promise); - const component = await renderComponent(); - expect(component.getByText('Host isolation exceptions')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - getHostIsolationExceptionSummaryMock.mockReturnValueOnce(promise); - const component = await renderComponent(); - expect(component.getByText('Host isolation exceptions')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx deleted file mode 100644 index 286047d804ebf..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - ListPageRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { getHostIsolationExceptionsListPath } from '../../../../../../common/routing'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; - -export const FleetHostIsolationExceptionsCard = memo( - ({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const hostIsolationExceptionsListUrlPath = getHostIsolationExceptionsListPath(); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getHostIsolationExceptionSummary(http); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error', - { - defaultMessage: - 'There was an error trying to fetch host isolation exceptions stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, toasts]); - - const hostIsolationExceptionsRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - return ( - - - - -

- -

-
-
- - - - - <> - - - - - -
-
- ); - } -); - -FleetHostIsolationExceptionsCard.displayName = 'FleetHostIsolationExceptionsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx index 73721345b8ff6..7989bb2646483 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx @@ -14,16 +14,19 @@ import { } from '../../../../../../../common/mock/endpoint'; import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; -import { FleetIntegrationEventFiltersCard } from './fleet_integration_event_filters_card'; +import { FleetIntegrationArtifactsCard } from './fleet_integration_artifacts_card'; import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/generate_data'; import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; import { PolicyData } from '../../../../../../../../common/endpoint/types'; import { getSummaryExceptionListSchemaMock } from '../../../../../../../../../lists/common/schemas/response/exception_list_summary_schema.mock'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { SEARCHABLE_FIELDS } from '../../../../../event_filters/constants'; +import { EVENT_FILTERS_LABELS } from '../../endpoint_policy_edit_extension'; const endpointGenerator = new EndpointDocGenerator('seed'); describe('Fleet integration policy endpoint security event filters card', () => { - let render: () => Promise>; + let render: (externalPrivileges?: boolean) => Promise>; let renderResult: ReturnType; let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; @@ -35,10 +38,20 @@ describe('Fleet integration policy endpoint security event filters card', () => mockedContext = createAppRootMockRenderer(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); ({ history } = mockedContext); - render = async () => { + render = async (externalPrivileges = true) => { await act(async () => { renderResult = mockedContext.render( - + ); await waitFor(() => expect(mockedApi.responseProvider.eventFiltersGetSummary).toHaveBeenCalled() @@ -58,7 +71,7 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toHaveTextContent( + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toHaveTextContent( 'Event filters3' ); }); @@ -69,7 +82,25 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toBeTruthy(); + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toBeTruthy(); + }); + + it('should not show the card when no permissions and no results', async () => { + mockedApi.responseProvider.eventFiltersGetSummary.mockReturnValue( + getSummaryExceptionListSchemaMock({ total: 0 }) + ); + + await render(false); + expect(renderResult.queryByTestId('artifacts-fleet-integration-card')).toBeNull(); + }); + + it('should show the card when no permissions but results', async () => { + mockedApi.responseProvider.eventFiltersGetSummary.mockReturnValue( + getSummaryExceptionListSchemaMock({ total: 1 }) + ); + + await render(false); + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toBeTruthy(); }); it('should have the correct manage event filters link', async () => { @@ -78,14 +109,15 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-link-to-exceptions')).toHaveAttribute( + expect(renderResult.getByTestId('artifacts-link-to-exceptions')).toHaveAttribute( 'href', `/app/security/administration/policy/${policy.id}/eventFilters` ); }); it('should show an error toast when API request fails', async () => { - const error = new Error('Uh oh! API error!'); + const errorMessage = 'Uh oh! API error!'; + const error = new Error(errorMessage); mockedApi.responseProvider.eventFiltersGetSummary.mockImplementation(() => { throw error; }); @@ -94,7 +126,7 @@ describe('Fleet integration policy endpoint security event filters card', () => await waitFor(() => { expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - `There was an error trying to fetch event filters stats: "${error}"` + `There was an error trying to fetch event filters stats: "${errorMessage}"` ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx new file mode 100644 index 0000000000000..6ed604df57918 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { memo, useMemo } from 'react'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; +import { pagePathGetters } from '../../../../../../../../../fleet/public'; +import { PolicyDetailsRouteState } from '../../../../../../../../common/endpoint/types'; +import { useAppUrl, useToasts } from '../../../../../../../common/lib/kibana'; +import { ExceptionItemsSummary } from './exception_items_summary'; +import { LinkWithIcon } from './link_with_icon'; +import { StyledEuiFlexItem } from './styled_components'; +import { useSummaryArtifact } from '../../../../../../hooks/artifacts'; +import { ExceptionsListApiClient } from '../../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useTestIdGenerator } from '../../../../../../components/hooks/use_test_id_generator'; + +const ARTIFACTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetIntegrationCard.artifactsSummary.error', { + defaultMessage: 'There was an error trying to fetch artifacts stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; + +export type ARTIFACTS_LABELS_TYPE = typeof ARTIFACTS_LABELS; + +export const FleetIntegrationArtifactsCard = memo<{ + policyId: string; + artifactApiClientInstance: ExceptionsListApiClient; + getArtifactsPath: (policyId: string) => string; + searchableFields: readonly string[]; + labels?: ARTIFACTS_LABELS_TYPE; + privileges?: boolean; + 'data-test-subj': string; +}>( + ({ + policyId, + artifactApiClientInstance, + getArtifactsPath, + searchableFields, + labels = ARTIFACTS_LABELS, + privileges = true, + 'data-test-subj': dataTestSubj, + }) => { + const toasts = useToasts(); + const { getAppUrl } = useAppUrl(); + const policyArtifactsPath = getArtifactsPath(policyId); + const getTestId = useTestIdGenerator(dataTestSubj); + + const { data } = useSummaryArtifact( + artifactApiClientInstance, + { policies: [policyId, 'all'] }, + searchableFields, + { + onError: (error) => toasts.addDanger(labels.artifactsSummaryApiError(error.message)), + } + ); + + const policyArtifactsRouteState = useMemo(() => { + const fleetPackageIntegrationCustomUrlPath = `#${ + pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] + }`; + + return { + backLink: { + label: i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.artifacts.backButtonLabel', + { + defaultMessage: `Back to Fleet integration policy`, + } + ), + navigateTo: [ + INTEGRATIONS_PLUGIN_ID, + { + path: fleetPackageIntegrationCustomUrlPath, + }, + ], + href: getAppUrl({ + appId: INTEGRATIONS_PLUGIN_ID, + path: fleetPackageIntegrationCustomUrlPath, + }), + }, + }; + }, [getAppUrl, policyId]); + + const linkToArtifacts = useMemo( + () => ( + + {labels.linkLabel} + + ), + [getAppUrl, getTestId, labels.linkLabel, policyArtifactsPath, policyArtifactsRouteState] + ); + + // do not render if doesn't have privileges. + // render if doesn't have privileges but has data to show + if ((data === undefined && !privileges) || (data?.total === 0 && !privileges)) { + return null; + } + + return ( + + + + +
{labels.cardTitle}
+
+
+ + + + {linkToArtifacts} +
+
+ ); + } +); + +FleetIntegrationArtifactsCard.displayName = 'FleetIntegrationArtifactsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx deleted file mode 100644 index c6857531a9dd0..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { pagePathGetters } from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - PolicyDetailsRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useAppUrl, useHttp, useToasts } from '../../../../../../../common/lib/kibana'; -import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexItem } from './styled_components'; -import { getSummary } from '../../../../../event_filters/service/service_actions'; - -export const FleetIntegrationEventFiltersCard = memo<{ - policyId: string; -}>(({ policyId }) => { - const toasts = useToasts(); - const http = useHttp(); - const [stats, setStats] = useState(); - const isMounted = useRef(); - const { getAppUrl } = useAppUrl(); - - const policyEventFiltersPath = getPolicyEventFiltersPath(policyId); - - const policyEventFiltersRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const linkToEventFilters = useMemo( - () => ( - - - - ), - [getAppUrl, policyEventFiltersPath, policyEventFiltersRouteState] - ); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getSummary({ http, filter: parsePoliciesToKQL([policyId, 'all']) }); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error', - { - defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, policyId, toasts]); - - return ( - - - - -
- -
-
-
- - - - {linkToEventFilters} -
-
- ); -}); - -FleetIntegrationEventFiltersCard.displayName = 'FleetIntegrationEventFiltersCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx deleted file mode 100644 index 08b5475b4589c..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { waitFor } from '@testing-library/react'; -import React from 'react'; -import uuid from 'uuid'; -import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; -import { useUserPrivileges } from '../../../../../../../common/components/user_privileges'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { FleetIntegrationHostIsolationExceptionsCard } from './fleet_integration_host_isolation_exceptions_card'; - -jest.mock('../../../../../host_isolation_exceptions/service'); -jest.mock('../../../../../../../common/components/user_privileges'); - -const getHostIsolationExceptionSummaryMock = getHostIsolationExceptionSummary as jest.Mock; - -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; - -describe('Fleet host isolation exceptions card filters card', () => { - const policyId = uuid.v4(); - const mockedContext = createAppRootMockRenderer(); - const renderComponent = () => { - return mockedContext.render( - - ); - }; - afterEach(() => { - getHostIsolationExceptionSummaryMock.mockReset(); - }); - describe('With canIsolateHost privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: true, - }, - }); - }); - - it('should call the API and render the card correctly', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 5, - macos: 5, - total: 5, - windows: 5, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - `(exception-list-agnostic.attributes.tags:"policy:${policyId}" OR exception-list-agnostic.attributes.tags:"policy:all")` - ); - }); - - expect( - renderResult.getByTestId('hostIsolationExceptions-fleet-integration-card') - ).toHaveTextContent('Host isolation exceptions5'); - }); - }); - - describe('Without canIsolateHost privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: false, - }, - }); - }); - - it('should not render the card if there are no exceptions associated', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 0, - macos: 0, - total: 0, - windows: 0, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalled(); - }); - - expect( - renderResult.queryByTestId('hostIsolationExceptions-fleet-integration-card') - ).toBeFalsy(); - }); - - it('should render the card if there are exceptions associated', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 1, - macos: 1, - total: 1, - windows: 1, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalled(); - }); - - expect( - renderResult.queryByTestId('hostIsolationExceptions-fleet-integration-card') - ).toBeTruthy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx deleted file mode 100644 index 7bb464e1ba6df..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { useUserPrivileges } from '../../../../../../../common/components/user_privileges'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { pagePathGetters } from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - PolicyDetailsRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useAppUrl, useHttp, useToasts } from '../../../../../../../common/lib/kibana'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../../common/routing'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexItem } from './styled_components'; - -export const FleetIntegrationHostIsolationExceptionsCard = memo<{ - policyId: string; -}>(({ policyId }) => { - const toasts = useToasts(); - const http = useHttp(); - const [stats, setStats] = useState(); - const isMounted = useRef(); - const { getAppUrl } = useAppUrl(); - const policyHostIsolationExceptionsPath = getPolicyHostIsolationExceptionsPath(policyId); - const privileges = useUserPrivileges().endpointPrivileges; - - const policyHostIsolationExceptionsRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const href = useMemo( - () => ( - - - - ), - [getAppUrl, policyHostIsolationExceptionsPath, policyHostIsolationExceptionsRouteState] - ); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getHostIsolationExceptionSummary( - http, - parsePoliciesToKQL([policyId, 'all']) - ); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error', - { - defaultMessage: - 'There was an error trying to fetch host isolation exceptions stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, policyId, toasts]); - - // do not render if doesn't have privileges. - // render if doesn't have privileges but has data to show - if ( - (stats === undefined && !privileges.canIsolateHost) || - (stats?.total === 0 && !privileges.canIsolateHost) - ) { - return null; - } - - return ( - - - - -
- -
-
-
- - - - {href} -
-
- ); -}); - -FleetIntegrationHostIsolationExceptionsCard.displayName = - 'FleetIntegrationHostIsolationExceptionsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx deleted file mode 100644 index f1ab47b2ea425..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { I18nProvider } from '@kbn/i18n-react'; -import { FleetTrustedAppsCard, FleetTrustedAppsCardProps } from './fleet_trusted_apps_card'; -import * as reactTestingLibrary from '@testing-library/react'; -import { TrustedAppsHttpService } from '../../../../../trusted_apps/service'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../trusted_apps/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -const TrustedAppsHttpServiceMock = TrustedAppsHttpService as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -const customLinkMock =
; - -describe('Fleet trusted apps card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: ( - customProps?: Partial - ) => Promise = async (customProps = {}) => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - - const component = reactTestingLibrary.render( - , - { - wrapper: Wrapper, - } - ); - try { - await reactTestingLibrary.act(async () => { - await promise; - }); - } catch (err) { - return component; - } - return component; - }; - - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - TrustedAppsHttpServiceMock.mockReset(); - }); - it('should render correctly without policyId', async () => { - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - }); - it('should render correctly with policyId', async () => { - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => () => promise, - }; - }); - const component = await renderComponent({ policyId: 'policy-1' }); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx deleted file mode 100644 index c0bc7de5b7350..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useState, useEffect, useRef } from 'react'; -import { EuiPanel, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { TrustedAppsHttpService } from '../../../../../trusted_apps/service'; -import { - StyledEuiFlexGridGroup, - StyledEuiFlexGridItem, - StyledEuiFlexItem, -} from './styled_components'; - -export interface FleetTrustedAppsCardProps { - customLink: React.ReactNode; - policyId?: string; - cardSize?: 'm' | 'l'; -} - -export const FleetTrustedAppsCard = memo( - ({ customLink, policyId, cardSize = 'l' }) => { - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const trustedAppsApi = useMemo(() => new TrustedAppsHttpService(http), [http]); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const response = await trustedAppsApi.getTrustedAppsSummary( - policyId ? parsePoliciesToKQL([policyId, 'all']) : undefined - ); - if (isMounted.current) { - setStats(response); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError', - { - defaultMessage: - 'There was an error trying to fetch trusted apps stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - if (!stats) { - fetchStats(); - } - return () => { - isMounted.current = false; - }; - }, [toasts, trustedAppsApi, policyId, stats]); - - const getTitleMessage = () => ( - - ); - - const cardGrid = useMemo(() => { - if (cardSize === 'm') { - return ( - - - -
{getTitleMessage()}
-
-
- - - - {customLink} -
- ); - } else { - return ( - - - -

{getTitleMessage()}

-
-
- - - - - {customLink} - -
- ); - } - }, [cardSize, customLink, stats]); - - return ( - - {cardGrid} - - ); - } -); - -FleetTrustedAppsCard.displayName = 'FleetTrustedAppsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx deleted file mode 100644 index 5722f97ff680f..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { getTrustedAppsListPath } from '../../../../../../common/routing'; -import { ListPageRouteState } from '../../../../../../../../common/endpoint/types'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; - -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { LinkWithIcon } from './link_with_icon'; -import { FleetTrustedAppsCard } from './fleet_trusted_apps_card'; - -export const FleetTrustedAppsCardWrapper = memo( - ({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const trustedAppsListUrlPath = getTrustedAppsListPath(); - - const trustedAppRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - const customLink = useMemo( - () => ( - - - - ), - [getAppUrl, trustedAppRouteState, trustedAppsListUrlPath] - ); - return ; - } -); - -FleetTrustedAppsCardWrapper.displayName = 'FleetTrustedAppsCardWrapper'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx index d53fe308a90ec..0da28d6ed7d1b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx @@ -5,22 +5,149 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer } from '@elastic/eui'; -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; +import { useHttp } from '../../../../../../common/lib/kibana/hooks'; import { PackageCustomExtensionComponentProps } from '../../../../../../../../fleet/public'; -import { FleetEventFiltersCard } from './components/fleet_event_filters_card'; -import { FleetHostIsolationExceptionsCard } from './components/fleet_host_isolation_exceptions_card'; -import { FleetTrustedAppsCardWrapper } from './components/fleet_trusted_apps_card_wrapper'; +import { ReactQueryClientProvider } from '../../../../../../common/containers/query_client/query_client_provider'; +import { FleetArtifactsCard } from './components/fleet_artifacts_card'; +import { + getBlocklistsListPath, + getEventFiltersListPath, + getHostIsolationExceptionsListPath, + getTrustedAppsListPath, +} from '../../../../../common/routing'; +import { TrustedAppsApiClient } from '../../../../trusted_apps/service/trusted_apps_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { HostIsolationExceptionsApiClient } from '../../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { BlocklistsApiClient } from '../../../../blocklist/services'; + +export const TRUSTED_APPS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch trusted applications stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const EVENT_FILTERS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const HOST_ISOLATION_EXCEPTIONS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummarySummary.error', + { + defaultMessage: + 'There was an error trying to fetch host isolation exceptions stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const BLOCKLISTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.blocklistsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch blocklist stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; export const EndpointPackageCustomExtension = memo( (props) => { + const http = useHttp(); + const trustedAppsApiClientInstance = useMemo( + () => TrustedAppsApiClient.getInstance(http), + [http] + ); + + const eventFiltersApiClientInstance = useMemo( + () => EventFiltersApiClient.getInstance(http), + [http] + ); + + const hostIsolationExceptionsApiClientInstance = useMemo( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const bloklistsApiClientInstance = useMemo(() => BlocklistsApiClient.getInstance(http), [http]); + return (
- - - - - + + + + + + + + +
); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index 4f49ff91b5a8d..690cb2dc734bd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -13,12 +13,15 @@ import { useDispatch } from 'react-redux'; import { PackagePolicyEditExtensionComponentProps, NewPackagePolicy, - pagePathGetters, } from '../../../../../../../fleet/public'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../fleet/common'; -import { useAppUrl } from '../../../../../common/lib/kibana/hooks'; -import { PolicyDetailsRouteState } from '../../../../../../common/endpoint/types'; -import { getPolicyDetailPath, getPolicyTrustedAppsPath } from '../../../../common/routing'; +import { useHttp } from '../../../../../common/lib/kibana/hooks'; +import { + getPolicyDetailPath, + getPolicyTrustedAppsPath, + getPolicyBlocklistsPath, + getPolicyHostIsolationExceptionsPath, + getPolicyEventFiltersPath, +} from '../../../../common/routing'; import { PolicyDetailsForm } from '../policy_details_form'; import { AppAction } from '../../../../../common/store/actions'; import { usePolicyDetailsSelector } from '../policy_hooks'; @@ -27,10 +30,106 @@ import { policyDetails, policyDetailsForUpdate, } from '../../store/policy_details/selectors'; -import { FleetTrustedAppsCard } from './endpoint_package_custom_extension/components/fleet_trusted_apps_card'; -import { LinkWithIcon } from './endpoint_package_custom_extension/components/link_with_icon'; -import { FleetIntegrationHostIsolationExceptionsCard } from './endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card'; -import { FleetIntegrationEventFiltersCard } from './endpoint_package_custom_extension/components/fleet_integration_event_filters_card'; + +import { ReactQueryClientProvider } from '../../../../../common/containers/query_client/query_client_provider'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { FleetIntegrationArtifactsCard } from './endpoint_package_custom_extension/components/fleet_integration_artifacts_card'; +import { BlocklistsApiClient } from '../../../blocklist/services'; +import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { TrustedAppsApiClient } from '../../../trusted_apps/service/trusted_apps_api_client'; +import { SEARCHABLE_FIELDS as BLOCKLIST_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; +import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; +import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../event_filters/constants'; +import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; + +export const BLOCKLISTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetIntegrationCard.blocklistsSummary.error', { + defaultMessage: 'There was an error trying to fetch blocklists stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const HOST_ISOLATION_EXCEPTIONS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.hostIsolationExceptionsSummary.error', + { + defaultMessage: + 'There was an error trying to fetch host isolation exceptions stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const EVENT_FILTERS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.eventFiltersSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const TRUSTED_APPS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.trustedAppsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch trusted apps stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; + /** * Exports Endpoint-specific package policy instructions * for use in the Ingest app create / edit package policy @@ -38,10 +137,10 @@ import { FleetIntegrationEventFiltersCard } from './endpoint_package_custom_exte export const EndpointPolicyEditExtension = memo( ({ policy, onChange }) => { return ( - <> + - + ); } ); @@ -55,8 +154,26 @@ const WrappedPolicyDetailsForm = memo<{ const updatedPolicy = usePolicyDetailsSelector(policyDetailsForUpdate); const endpointPolicyDetails = usePolicyDetailsSelector(policyDetails); const endpointDetailsLoadingError = usePolicyDetailsSelector(apiError); - const { getAppUrl } = useAppUrl(); const [, setLastUpdatedPolicy] = useState(updatedPolicy); + const privileges = useUserPrivileges().endpointPrivileges; + + const http = useHttp(); + const blocklistsApiClientInstance = useMemo(() => BlocklistsApiClient.getInstance(http), [http]); + + const hostIsolationExceptionsApiClientInstance = useMemo( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const eventFiltersApiClientInstance = useMemo( + () => EventFiltersApiClient.getInstance(http), + [http] + ); + + const trustedAppsApiClientInstance = useMemo( + () => TrustedAppsApiClient.getInstance(http), + [http] + ); // When the form is initially displayed, trigger the Redux middleware which is based on // the location information stored via the `userChangedUrl` action. @@ -109,54 +226,6 @@ const WrappedPolicyDetailsForm = memo<{ }); }, [onChange, updatedPolicy]); - const policyTrustedAppsPath = useMemo(() => getPolicyTrustedAppsPath(policyId), [policyId]); - const policyTrustedAppRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const policyTrustedAppsLink = useMemo( - () => ( - - - - ), - [getAppUrl, policyTrustedAppsPath, policyTrustedAppRouteState] - ); - return (
<> @@ -170,15 +239,42 @@ const WrappedPolicyDetailsForm = memo<{ - + + - + - +
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0843867d79222..b7240d8653f90 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -21012,16 +21012,10 @@ "xpack.securitySolution.endpoint.details.policyStatusValue": "{policyStatus, select, success {Succès} warning {Avertissement} failure {Échec} other {Inconnu}}", "xpack.securitySolution.endpoint.detailsActions.buttonLabel": "Entreprendre une action", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "Retour à l'intégration du point de terminaison", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "Filtres d'événements", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "Une erreur s'est produite lors de la tentative de récupération des statistiques de filtres d'événements : \"{error}\"", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "Total", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "Gérer les filtres d'événements", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "Gérer les applications de confiance", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "Applications de confiance", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "Une erreur s'est produite lors de la tentative de récupération des statistiques des applications de confiance : \"{error}\"", "xpack.securitySolution.endpoint.hostIsolation.cancel": "Annuler", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "Impossible de trouver les cas associés", "xpack.securitySolution.endpoint.hostIsolation.comment": "Commentaire", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8b080e3cc8787..091f13cf31d48 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24053,26 +24053,11 @@ "xpack.securitySolution.endpoint.effectedPolicySelect.global": "グローバル", "xpack.securitySolution.endpoint.effectedPolicySelect.perPolicy": "ポリシー単位", "xpack.securitySolution.endpoint.eventFilters.fleetIntegration.title": "イベントフィルター", - "xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel": "Fleet統合ポリシーに戻る", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "Endpoint Security統合に戻る", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "イベントフィルター", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersManageLabel": "イベントフィルターの管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error": "イベントフィルター統計情報の取得中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "イベントフィルター統計情報の取得中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "合計", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsManageLabel": "ホスト分離例外の管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel": "Endpoint Security統合に戻る", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error": "ホスト分離例外統計情報の取得中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.label": "ホスト分離例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.manageLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "信頼できるアプリケーションを管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppshortLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "信頼できるアプリケーション", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "信頼できるアプリ統計情報の取得中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.endpoint.hostIsolation.cancel": "キャンセル", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "関連付けられたケースが見つかりませんでした", "xpack.securitySolution.endpoint.hostIsolation.comment": "コメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8c7dbd7b9df35..f0430e8571857 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24082,26 +24082,11 @@ "xpack.securitySolution.endpoint.effectedPolicySelect.global": "全局", "xpack.securitySolution.endpoint.effectedPolicySelect.perPolicy": "按策略", "xpack.securitySolution.endpoint.eventFilters.fleetIntegration.title": "事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel": "返回到 Fleet 集成策略", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "返回到 Endpoint Security 集成", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersManageLabel": "管理事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error": "尝试提取事件筛选统计时出错:“{error}”", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "尝试提取事件筛选统计时出错:“{error}”", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "合计", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsManageLabel": "管理主机隔离例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel": "返回到 Endpoint Security 集成", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error": "尝试提取主机隔离例外统计时出错:“{error}”", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.label": "主机隔离例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.manageLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "管理受信任的应用程序", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppshortLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "受信任的应用程序", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "尝试提取受信任应用统计时出错:“{error}”", "xpack.securitySolution.endpoint.hostIsolation.cancel": "取消", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "无法找到关联案例", "xpack.securitySolution.endpoint.hostIsolation.comment": "注释", diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts index 45e6b410baee5..4f9b7ad7c0401 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts @@ -47,7 +47,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should show the Trusted Apps page when link is clicked', async () => { await (await fleetIntegrations.findIntegrationDetailCustomTab()).click(); - await (await testSubjects.find('linkToTrustedApps')).click(); + await (await testSubjects.find('trustedApps-artifactsLink')).click(); await trustedApps.ensureIsOnTrustedAppsListPage(); }); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 45b52a00eb246..cd56755192fa0 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -323,12 +323,31 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('should show trusted apps card and link should go back to policy', async () => { - await testSubjects.existOrFail('fleetTrustedAppsCard'); - await (await testSubjects.find('linkToTrustedApps')).click(); + await testSubjects.existOrFail('trustedApps-fleet-integration-card'); + await (await testSubjects.find('trustedApps-link-to-exceptions')).click(); await testSubjects.existOrFail('policyDetailsPage'); await (await testSubjects.find('policyDetailsBackLink')).click(); await testSubjects.existOrFail('endpointIntegrationPolicyForm'); }); + it('should show event filters card and link should go back to policy', async () => { + await testSubjects.existOrFail('eventFilters-fleet-integration-card'); + await (await testSubjects.find('eventFilters-link-to-exceptions')).click(); + await testSubjects.existOrFail('policyDetailsPage'); + await (await testSubjects.find('policyDetailsBackLink')).click(); + await testSubjects.existOrFail('endpointIntegrationPolicyForm'); + }); + it('should show blocklists card and link should go back to policy', async () => { + await testSubjects.existOrFail('blocklists-fleet-integration-card'); + const blocklistsCard = await testSubjects.find('blocklists-fleet-integration-card'); + await pageObjects.ingestManagerCreatePackagePolicy.scrollToCenterOfWindow(blocklistsCard); + await (await testSubjects.find('blocklists-link-to-exceptions')).click(); + await testSubjects.existOrFail('policyDetailsPage'); + await (await testSubjects.find('policyDetailsBackLink')).click(); + await testSubjects.existOrFail('endpointIntegrationPolicyForm'); + }); + it('should not show host isolation exceptions card because no entries', async () => { + await testSubjects.missingOrFail('hostIsolationExceptions-fleet-integration-card'); + }); }); }); } From 40b0d3e49bd626b5c5bc045d3a0746e5ce5811c8 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 21 Mar 2022 08:15:30 -0400 Subject: [PATCH 20/38] [Upgrade Assistant] Add integration tests for version_precheck (#126524) (#128070) --- .../upgrade_assistant/cloud_backup_status.ts | 4 + .../upgrade_assistant/cluster_settings.ts | 4 +- .../upgrade_assistant/es_deprecation_logs.ts | 4 +- .../apis/upgrade_assistant/es_deprecations.ts | 4 +- .../apis/upgrade_assistant/index.ts | 4 +- .../apis/upgrade_assistant/node_disk_space.ts | 4 +- .../apis/upgrade_assistant/privileges.ts | 4 +- .../apis/upgrade_assistant/remote_clusters.ts | 4 +- .../upgrade_assistant/upgrade_assistant.ts | 4 +- .../upgrade_assistant/version_precheck.ts | 218 ++++++++++++++++++ 10 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts b/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts index 680b51d55ebd0..ccd4b60d241fb 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts @@ -51,6 +51,10 @@ export default function ({ getService }: FtrProviderContext) { }; describe('Cloud backup status', function () { + // file system repositories are not supported in cloud + this.tags(['skipCloud']); + this.onlyEsVersion('<=7'); + describe('get', () => { describe('with backups present', () => { // Needs SnapshotInfo type https://github.com/elastic/elasticsearch-specification/issues/685 diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts b/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts index b221e5e315952..7ca6fbc9ed4d5 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); - describe.skip('Cluster settings', () => { + describe('Cluster settings', function () { + this.onlyEsVersion('<=7'); + describe('POST /api/upgrade_assistant/cluster_settings', () => { before(async () => { try { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts index 2e9131a06b5e3..66bf9a46d05d5 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts @@ -21,7 +21,9 @@ export default function ({ getService }: FtrProviderContext) { const { createDeprecationLog, deleteDeprecationLogs } = initHelpers(getService); - describe('Elasticsearch deprecation logs', () => { + describe('Elasticsearch deprecation logs', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/deprecation_logging', () => { describe('/count', () => { it('should filter out the deprecation from Elastic products', async () => { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts index 33706ae42e4c5..7995408eeb1ce 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts @@ -12,7 +12,9 @@ export default function ({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const security = getService('security'); - describe('Elasticsearch deprecations', () => { + describe('Elasticsearch deprecations', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/es_deprecations', () => { describe('error handling', () => { it('handles auth error', async () => { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/index.ts b/x-pack/test/api_integration/apis/upgrade_assistant/index.ts index 961f95714eaa0..21aeea27e6174 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/index.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/index.ts @@ -8,7 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('Upgrade Assistant', () => { + describe('Upgrade Assistant', function () { loadTestFile(require.resolve('./upgrade_assistant')); loadTestFile(require.resolve('./cloud_backup_status')); loadTestFile(require.resolve('./privileges')); @@ -16,5 +16,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./es_deprecation_logs')); loadTestFile(require.resolve('./remote_clusters')); loadTestFile(require.resolve('./cluster_settings')); + loadTestFile(require.resolve('./version_precheck')); + loadTestFile(require.resolve('./node_disk_space')); }); } diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts b/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts index fce68f4a344c9..0a023c8a2065e 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts @@ -13,7 +13,9 @@ import { API_BASE_PATH } from '../../../../plugins/upgrade_assistant/common/cons export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - describe('Node disk space', () => { + describe('Node disk space', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/node_disk_space', () => { it('returns an array of nodes', async () => { const { body: apiRequestResponse } = await supertest diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts b/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts index c5c00c9a33685..9bac04bf1d48e 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - describe('Privileges', () => { + describe('Privileges', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/privileges', () => { it('User with with index privileges', async () => { const { body } = await supertest diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts b/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts index 5d8dcaf339068..412f5d2a0c7fd 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); - describe('Remote clusters', () => { + describe('Remote clusters', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/remote_clusters', () => { before(async () => { try { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts b/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts index 836048f379214..8a47c90c81ff3 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts @@ -14,7 +14,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const supertest = getService('supertest'); - describe.skip('Upgrade Assistant', () => { + describe('Upgrade Assistant', function () { + this.onlyEsVersion('<=7'); + describe('Reindex operation saved object', () => { const dotKibanaIndex = '.kibana'; const fakeSavedObjectId = 'fakeSavedObjectId'; diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts b/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts new file mode 100644 index 0000000000000..cd85b048dc8f6 --- /dev/null +++ b/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts @@ -0,0 +1,218 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { API_BASE_PATH } from '../../../../plugins/upgrade_assistant/common/constants'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + // Tests only applicable on 7.17 + describe.skip('Elasticsearch version precheck', function () { + this.onlyEsVersion('>=8'); + + describe('Elasticsearch 8.x running against Kibana 7.last', () => { + describe('Cloud backup status APIs', () => { + it('returns 426 for GET /cloud_backup_status', async () => { + await supertest + .get(`${API_BASE_PATH}/cloud_backup_status`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Cloud upgrade status APIs', () => { + it('returns 426 for GET /cluster_upgrade_status', async () => { + await supertest + .get(`${API_BASE_PATH}/cluster_upgrade_status`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Deprecation logging APIs', () => { + it('returns 426 for GET /deprecation_logging', async () => { + await supertest + .get(`${API_BASE_PATH}/deprecation_logging`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for PUT /deprecation_logging', async () => { + await supertest + .put(`${API_BASE_PATH}/deprecation_logging`) + .set('kbn-xsrf', 'xxx') + .send({ + isEnabled: true, + }) + .expect(426); + }); + + it('returns 426 for DELETE /deprecation_logging/cache', async () => { + await supertest + .delete(`${API_BASE_PATH}/deprecation_logging/cache`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /deprecation_logging/count', async () => { + await supertest + .get(`${API_BASE_PATH}/deprecation_logging/count?from=2021-08-23T07:32:34.782Z`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('ES deprecations APIs', () => { + it('returns 426 for GET /es_deprecations', async () => { + await supertest + .get(`${API_BASE_PATH}/es_deprecations`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Remote clusters APIs', () => { + it('returns 426 for GET /remote_clusters', async () => { + await supertest + .get(`${API_BASE_PATH}/remote_clusters`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('System indices migration APIs', () => { + it('returns 426 for GET /system_indices_migration', async () => { + await supertest + .get(`${API_BASE_PATH}/system_indices_migration`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /system_indices_migration', async () => { + await supertest + .post(`${API_BASE_PATH}/system_indices_migration`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Status APIs', () => { + it('returns 426 for GET /status', async () => { + await supertest.get(`${API_BASE_PATH}/status`).set('kbn-xsrf', 'xxx').expect(426); + }); + }); + + describe('Privileges APIs', () => { + it('returns 426 for GET /privileges', async () => { + await supertest.get(`${API_BASE_PATH}/privileges`).set('kbn-xsrf', 'xxx').expect(426); + }); + }); + + describe('Index settings APIs', () => { + it('returns 426 for POST /{indexName}/index_settings', async () => { + await supertest + .post(`${API_BASE_PATH}/test_index/index_settings`) + .set('kbn-xsrf', 'xxx') + .send({ + settings: ['index_settings'], + }) + .expect(426); + }); + }); + + describe('Cluster settings APIs', () => { + it('returns 426 for POST /cluster_settings', async () => { + await supertest + .post(`${API_BASE_PATH}/cluster_settings`) + .set('kbn-xsrf', 'xxx') + .send({ + settings: ['cluster_settings'], + }) + .expect(426); + }); + }); + + describe('Machine learning APIs', () => { + it('returns 426 for GET /ml_snapshots/{jobId}/{snapshotId}', async () => { + await supertest + .get(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /ml_upgrade_mode', async () => { + await supertest + .get(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /ml_snapshots', async () => { + await supertest + .post(`${API_BASE_PATH}/ml_snapshots`) + .set('kbn-xsrf', 'xxx') + .send({ + snapshotId: 'snapshot_1', + jobId: 'job_1', + }) + .expect(426); + }); + + it('returns 426 for DELETE /ml_snapshots/{jobId}/{snapshotId}', async () => { + await supertest + .delete(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Reindex APIs', () => { + it('returns 426 for POST /reindex/{indexName}', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/test_index`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /reindex/{indexName}', async () => { + await supertest + .get(`${API_BASE_PATH}/reindex/test_index`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /reindex/{indexName}/cancel', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/test_index/cancel`) + .set('kbn-xsrf', 'xxx') + .send({ + indexNames: ['test_index'], + }) + .expect(426); + }); + + it('returns 426 for GET /reindex/batch/queue', async () => { + await supertest + .get(`${API_BASE_PATH}/reindex/batch/queue`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /reindex/batch', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/batch`) + .set('kbn-xsrf', 'xxx') + .send({ + indexNames: ['test_index'], + }) + .expect(426); + }); + }); + }); + }); +} From de02c2da65c3431cd458a1b78f01cbeeaaf5f64a Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 21 Mar 2022 13:30:00 +0100 Subject: [PATCH 21/38] Upgrade Node.js from v16.13.2 to v16.14.2 (#128123) --- .ci/Dockerfile | 2 +- .node-version | 2 +- .nvmrc | 2 +- WORKSPACE.bazel | 14 +++++++------- package.json | 6 +++--- yarn.lock | 8 ++++---- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.ci/Dockerfile b/.ci/Dockerfile index d486aaeb0dee7..150e0925ae7bc 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,7 +1,7 @@ # NOTE: This Dockerfile is ONLY used to run certain tasks in CI. It is not used to run Kibana or as a distributable. # If you're looking for the Kibana Docker image distributable, please see: src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts -ARG NODE_VERSION=16.13.2 +ARG NODE_VERSION=16.14.2 FROM node:${NODE_VERSION} AS base diff --git a/.node-version b/.node-version index 23d9c36a1187a..d9f880069dc78 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16.13.2 +16.14.2 diff --git a/.nvmrc b/.nvmrc index d7cb9ec3a7643..d9f880069dc78 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.13.2 \ No newline at end of file +16.14.2 diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 04f61b7f95064..c00062734239e 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -27,14 +27,14 @@ check_rules_nodejs_version(minimum_version_string = "4.0.0") # we can update that rule. node_repositories( node_repositories = { - "16.13.2-darwin_amd64": ("node-v16.13.2-darwin-x64.tar.gz", "node-v16.13.2-darwin-x64", "900a952bb77533d349e738ff8a5179a4344802af694615f36320a888b49b07e6"), - "16.13.2-darwin_arm64": ("node-v16.13.2-darwin-arm64.tar.gz", "node-v16.13.2-darwin-arm64", "09d300008ad58792c12622a5eafdb14c931587bb88713df4df64cdf4ff2188d1"), - "16.13.2-linux_arm64": ("node-v16.13.2-linux-arm64.tar.xz", "node-v16.13.2-linux-arm64", "a3cf8e4e9fbea27573eee6da84720bf7227ddd22842b842d48049d6dfe55fb03"), - "16.13.2-linux_s390x": ("node-v16.13.2-linux-s390x.tar.xz", "node-v16.13.2-linux-s390x", "c4ba46fc19366f7377d28a60a98f741bfa38045d7924306244c51d1660afcc8d"), - "16.13.2-linux_amd64": ("node-v16.13.2-linux-x64.tar.xz", "node-v16.13.2-linux-x64", "7f5e9a42d6e86147867d35643c7b1680c27ccd45db85666fc52798ead5e74421"), - "16.13.2-windows_amd64": ("node-v16.13.2-win-x64.zip", "node-v16.13.2-win-x64", "107e3ece84b7fa1e80b3bdf03181d395246c7867e27b17b6d7e6fa9c7932b467"), + "16.14.2-darwin_amd64": ("node-v16.14.2-darwin-x64.tar.gz", "node-v16.14.2-darwin-x64", "d3076ca7fcc7269c8ff9b03fe7d1c277d913a7e84a46a14eff4af7791ff9d055"), + "16.14.2-darwin_arm64": ("node-v16.14.2-darwin-arm64.tar.gz", "node-v16.14.2-darwin-arm64", "a66d9217d2003bd416d3dd06dfd2c7a044c4c9ff2e43a27865790bd0d59c682d"), + "16.14.2-linux_arm64": ("node-v16.14.2-linux-arm64.tar.xz", "node-v16.14.2-linux-arm64", "f7c5a573c06a520d6c2318f6ae204141b8420386553a692fc359f8ae3d88df96"), + "16.14.2-linux_s390x": ("node-v16.14.2-linux-s390x.tar.xz", "node-v16.14.2-linux-s390x", "3197925919ca357e17a31132dc6ef4e5afae819fa09905cfe9f7ff7924a00bf5"), + "16.14.2-linux_amd64": ("node-v16.14.2-linux-x64.tar.xz", "node-v16.14.2-linux-x64", "e40c6f81bfd078976d85296b5e657be19e06862497741ad82902d0704b34bb1b"), + "16.14.2-windows_amd64": ("node-v16.14.2-win-x64.zip", "node-v16.14.2-win-x64", "4731da4fbb2015d414e871fa9118cabb643bdb6dbdc8a69a3ed563266ac93229"), }, - node_version = "16.13.2", + node_version = "16.14.2", node_urls = [ "https://nodejs.org/dist/v{version}/{filename}", ], diff --git a/package.json b/package.json index 589f9d83dacec..9f1cdfab2e305 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,12 @@ "url": "https://github.com/elastic/kibana.git" }, "engines": { - "node": "16.13.2", + "node": "16.14.2", "yarn": "^1.21.1" }, "resolutions": { "**/@babel/runtime": "^7.17.2", - "**/@types/node": "16.10.2", + "**/@types/node": "16.11.7", "**/chokidar": "^3.4.3", "**/deepmerge": "^4.2.2", "**/fast-deep-equal": "^3.1.1", @@ -655,7 +655,7 @@ "@types/mustache": "^0.8.31", "@types/ncp": "^2.0.1", "@types/nock": "^10.0.3", - "@types/node": "16.10.2", + "@types/node": "16.11.7", "@types/node-fetch": "^2.6.0", "@types/node-forge": "^1.0.1", "@types/nodemailer": "^6.4.0", diff --git a/yarn.lock b/yarn.lock index 5f9ddfa8c8f97..6df682be18360 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6363,10 +6363,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@12.20.24", "@types/node@16.10.2", "@types/node@>= 8", "@types/node@>=8.9.0", "@types/node@^10.1.0", "@types/node@^14.0.10", "@types/node@^14.14.31": - version "16.10.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.2.tgz#5764ca9aa94470adb4e1185fe2e9f19458992b2e" - integrity sha512-zCclL4/rx+W5SQTzFs9wyvvyCwoK9QtBpratqz2IYJ3O8Umrn0m3nsTv0wQBk9sRGpvUe9CwPDrQFB10f1FIjQ== +"@types/node@*", "@types/node@12.20.24", "@types/node@16.11.7", "@types/node@>= 8", "@types/node@>=8.9.0", "@types/node@^10.1.0", "@types/node@^14.0.10", "@types/node@^14.14.31": + version "16.11.7" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.7.tgz#36820945061326978c42a01e56b61cd223dfdc42" + integrity sha512-QB5D2sqfSjCmTuWcBWyJ+/44bcjO7VbjSbOE0ucoVbAsSNQc4Lt6QkgkVXkTDwkL4z/beecZNDvVX15D4P8Jbw== "@types/nodemailer@^6.4.0": version "6.4.0" From ee4f521b434f03a34031efcb5811965fa5ad603c Mon Sep 17 00:00:00 2001 From: Andrew Tate Date: Mon, 21 Mar 2022 08:10:51 -0500 Subject: [PATCH 22/38] only allow sorting by date and number fields (#128111) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../definitions/terms/helpers.test.ts | 42 ++++++++++++++++++- .../operations/definitions/terms/helpers.ts | 3 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts index 065c825076578..9f907c5584802 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -474,7 +474,28 @@ describe('isSortableByColumn()', () => { ).toBeFalsy(); }); - it('SHOULD be sortable when NOT using top-hit agg', () => { + it('should NOT be sortable when NOT using date or number source field', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'string', + isBucketed: false, + sourceField: 'some_string_field', + operationType: 'last_value', + params: { + sortField: 'time', + showArrayValues: false, + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('SHOULD be sortable when NOT using top-hit agg and source field is date or number', () => { expect( isSortableByColumn( getLayer(getStringBasedOperationColumn(), [ @@ -493,6 +514,25 @@ describe('isSortableByColumn()', () => { 'col2' ) ).toBeTruthy(); + + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'date', + isBucketed: false, + sourceField: 'order_date', + operationType: 'last_value', + params: { + sortField: 'time', + showArrayValues: false, + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts index ff8055be06d6b..95fffca1212b3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts @@ -208,7 +208,8 @@ export function getDisallowedTermsMessage( function checkLastValue(column: GenericIndexPatternColumn) { return ( column.operationType !== 'last_value' || - !(column as LastValueIndexPatternColumn).params.showArrayValues + (['number', 'date'].includes(column.dataType) && + !(column as LastValueIndexPatternColumn).params.showArrayValues) ); } From 20ede5dc33d66f8af0b296321c1004940cd65106 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 21 Mar 2022 13:31:36 +0000 Subject: [PATCH 23/38] skip flaky suite (#127678) --- x-pack/test/api_integration/apis/ml/filters/update_filters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts index 737e2c21cf0f6..f943378201dfd 100644 --- a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts @@ -31,7 +31,8 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - describe('update_filters', function () { + // FLAKY: https://github.com/elastic/kibana/issues/127678 + describe.skip('update_filters', function () { const updateFilterRequestBody = { description: 'Updated filter #1', removeItems: items, From ce0710efd24226875de1d620e336ecbb06636ddd Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 21 Mar 2022 15:33:55 +0200 Subject: [PATCH 24/38] [Visualize] Migrate to dataViews service (#128131) * visualize dataViews service * Fix CI --- src/plugins/visualizations/kibana.json | 3 ++- .../create_vis_embeddable_from_object.ts | 4 ++-- .../public/embeddable/visualize_embeddable.tsx | 12 ++++-------- src/plugins/visualizations/public/mocks.ts | 2 ++ src/plugins/visualizations/public/plugin.ts | 7 +++++-- .../controls_references.ts | 4 ++-- .../timeseries_references.ts | 4 ++-- src/plugins/visualizations/public/vis.ts | 5 +++-- .../visualizations/public/vis_types/types.ts | 11 +++-------- .../components/visualize_top_nav.tsx | 10 +++++----- .../visualizations/public/visualize_app/types.ts | 2 ++ .../visualization_saved_object_migrations.ts | 15 ++++++--------- src/plugins/visualizations/tsconfig.json | 1 + 13 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 79b04f132077b..3a4dc81ed8df7 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -13,7 +13,8 @@ "inspector", "savedObjects", "screenshotMode", - "presentationUtil" + "presentationUtil", + "dataViews" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact", "home"], diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index c72b8618dc199..b5cd655e712e9 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -24,7 +24,7 @@ import { getUISettings, getHttp, getTimeFilter, getCapabilities } from '../servi import { urlFor } from '../utils/saved_visualize_utils'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; -import { IndexPattern } from '../../../data/public'; +import type { DataView } from '../../../data_views/public'; import { createVisualizeEmbeddableAsync } from './visualize_embeddable_async'; export const createVisEmbeddableFromObject = @@ -51,7 +51,7 @@ export const createVisEmbeddableFromObject = return new DisabledLabEmbeddable(vis.title, input); } - let indexPatterns: IndexPattern[] = []; + let indexPatterns: DataView[] = []; if (vis.type.getUsedIndexPattern) { indexPatterns = await vis.type.getUsedIndexPattern(vis.params); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index 3d3c98ce4aaea..7854012bf61fe 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -16,12 +16,8 @@ import { Filter, onlyDisabledFiltersChanged } from '@kbn/es-query'; import type { SavedObjectAttributes, KibanaExecutionContext } from 'kibana/public'; import { KibanaThemeProvider } from '../../../kibana_react/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; -import { - IndexPattern, - TimeRange, - Query, - TimefilterContract, -} from '../../../../plugins/data/public'; +import { TimeRange, Query, TimefilterContract } from '../../../../plugins/data/public'; +import type { DataView } from '../../../../plugins/data_views/public'; import { EmbeddableInput, EmbeddableOutput, @@ -51,7 +47,7 @@ const getKeys = (o: T): Array => Object.keys(o) as Array< export interface VisualizeEmbeddableConfiguration { vis: Vis; - indexPatterns?: IndexPattern[]; + indexPatterns?: DataView[]; editPath: string; editUrl: string; capabilities: { visualizeSave: boolean; dashboardSave: boolean }; @@ -74,7 +70,7 @@ export interface VisualizeOutput extends EmbeddableOutput { editPath: string; editApp: string; editUrl: string; - indexPatterns?: IndexPattern[]; + indexPatterns?: DataView[]; visTypeName: string; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 69a7c61e68893..901ca34c34521 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -15,6 +15,7 @@ import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; import { dataPluginMock } from '../../../plugins/data/public/mocks'; +import { dataViewPluginMocks } from '../../../plugins/data_views/public/mocks'; import { usageCollectionPluginMock } from '../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; @@ -56,6 +57,7 @@ const createInstance = async () => { const doStart = () => plugin.start(coreMock.createStart(), { data: dataPluginMock.createStartContract(), + dataViews: dataViewPluginMocks.createStartContract(), expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 88b9d35d5255f..997d78b31163d 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -79,6 +79,7 @@ import type { Start as InspectorStart, } from '../../../plugins/inspector/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '../../../plugins/data/public'; +import type { DataViewsPublicPluginStart } from '../../../plugins/data_views/public'; import type { ExpressionsSetup, ExpressionsStart } from '../../expressions/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; @@ -117,6 +118,7 @@ export interface VisualizationsSetupDeps { export interface VisualizationsStartDeps { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; embeddable: EmbeddableStart; inspector: InspectorStart; @@ -237,10 +239,10 @@ export class VisualizationsPlugin }; // make sure the index pattern list is up to date - pluginsStart.data.indexPatterns.clearCache(); + pluginsStart.dataViews.clearCache(); // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered - await pluginsStart.data.indexPatterns.ensureDefaultDataView(); + await pluginsStart.dataViews.ensureDefaultDataView(); appMounted(); @@ -268,6 +270,7 @@ export class VisualizationsPlugin pluginInitializerContext: this.initializerContext, chrome: coreStart.chrome, data: pluginsStart.data, + dataViews: pluginsStart.dataViews, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, share: pluginsStart.share, diff --git a/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts index 31713d8ad7d5e..5e9af25e58249 100644 --- a/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts +++ b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; -import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; const isControlsVis = (visType: string) => visType === 'input_control_vis'; @@ -26,7 +26,7 @@ export const extractControlsReferences = ( control.indexPatternRefName = `${prefix}_${i}_index_pattern`; references.push({ name: control.indexPatternRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; diff --git a/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts index a3917699fcab3..36217579b88a6 100644 --- a/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts +++ b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; -import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; /** @internal **/ const REF_NAME_POSTFIX = '_ref_name'; @@ -49,7 +49,7 @@ export const extractTimeSeriesReferences = ( object[key + REF_NAME_POSTFIX] = name; references.push({ name, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: object[key].id, }); delete object[key]; diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index 6aa70c080f8e7..47a667a4f36b9 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -22,7 +22,8 @@ import { i18n } from '@kbn/i18n'; import { PersistedState } from './persisted_state'; import { getTypes, getAggs, getSearch, getSavedObjects, getSpaces } from './services'; -import { IAggConfigs, IndexPattern, ISearchSource, AggConfigSerialized } from '../../data/public'; +import { IAggConfigs, ISearchSource, AggConfigSerialized } from '../../data/public'; +import type { DataView } from '../../data_views/public'; import { BaseVisType } from './vis_types'; import { SerializedVis, SerializedVisData, VisParams } from '../common/types'; @@ -33,7 +34,7 @@ export type { SerializedVis, SerializedVisData }; export interface VisData { ast?: string; aggs?: IAggConfigs; - indexPattern?: IndexPattern; + indexPattern?: DataView; searchSource?: ISearchSource; savedSearchId?: string; } diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 834c781b3b828..14da9eb887e0c 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -9,13 +9,8 @@ import type { IconType } from '@elastic/eui'; import type { ReactNode } from 'react'; import type { Adapters } from 'src/plugins/inspector'; -import type { - IndexPattern, - AggGroupNames, - AggParam, - AggGroupName, - Query, -} from '../../../data/public'; +import type { AggGroupNames, AggParam, AggGroupName, Query } from '../../../data/public'; +import type { DataView } from '../../../data_views/public'; import { PaletteOutput } from '../../../charts/public'; import type { Vis, VisEditorOptionsProps, VisParams, VisToExpressionAst } from '../types'; import { VisGroups } from './vis_groups_enum'; @@ -181,7 +176,7 @@ export interface VisTypeDefinition { * Some visualizations are created without SearchSource and may change the used indexes during the visualization configuration. * Using this method we can rewrite the standard mechanism for getting used indexes */ - readonly getUsedIndexPattern?: (visParams: VisParams) => IndexPattern[] | Promise; + readonly getUsedIndexPattern?: (visParams: VisParams) => DataView[] | Promise; readonly isAccessible?: boolean; /** diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 245441d26f3f0..276c99ec4ca5c 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -20,7 +20,7 @@ import { } from '../types'; import { VISUALIZE_APP_NAME } from '../../../common/constants'; import { getTopNavConfig } from '../utils'; -import type { IndexPattern } from '../../../../data/public'; +import type { DataView } from '../../../../data_views/public'; import type { NavigateToLensContext } from '../../../../visualizations/public'; const LOCAL_STORAGE_EDIT_IN_LENS_BADGE = 'EDIT_IN_LENS_BADGE_VISIBLE'; @@ -151,7 +151,7 @@ const TopNav = ({ hideLensBadge, hideTryInLensBadge, ]); - const [indexPatterns, setIndexPatterns] = useState( + const [indexPatterns, setIndexPatterns] = useState( vis.data.indexPattern ? [vis.data.indexPattern] : [] ); const showDatePicker = () => { @@ -210,13 +210,13 @@ const TopNav = ({ useEffect(() => { const asyncSetIndexPattern = async () => { - let indexes: IndexPattern[] | undefined; + let indexes: DataView[] | undefined; if (vis.type.getUsedIndexPattern) { indexes = await vis.type.getUsedIndexPattern(vis.params); } if (!indexes || !indexes.length) { - const defaultIndex = await services.data.indexPatterns.getDefault(); + const defaultIndex = await services.dataViews.getDefault(); if (defaultIndex) { indexes = [defaultIndex]; } @@ -229,7 +229,7 @@ const TopNav = ({ if (!vis.data.indexPattern) { asyncSetIndexPattern(); } - }, [vis.params, vis.type, services.data.indexPatterns, vis.data.indexPattern]); + }, [vis.params, vis.type, vis.data.indexPattern, services.dataViews]); useEffect(() => { const autoRefreshFetchSub = services.data.query.timefilter.timefilter diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index a414dd2e61762..7c4c8155a9405 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -37,6 +37,7 @@ import type { import type { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import type { Filter } from '@kbn/es-query'; import type { Query, DataPublicPluginStart, TimeRange } from 'src/plugins/data/public'; +import type { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; @@ -89,6 +90,7 @@ export interface VisualizeServices extends CoreStart { pluginInitializerContext: PluginInitializerContext; chrome: ChromeStart; data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; localStorage: Storage; navigation: NavigationStart; toastNotifications: ToastsStart; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 4855b2589bed3..e480d03e137ae 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -11,11 +11,8 @@ import type { SavedObjectMigrationFn, SavedObjectMigrationMap } from 'kibana/ser import { mergeSavedObjectMigrationMaps } from '../../../../core/server'; import { MigrateFunctionsObject, MigrateFunction } from '../../../kibana_utils/common'; -import { - DEFAULT_QUERY_LANGUAGE, - INDEX_PATTERN_SAVED_OBJECT_TYPE, - SerializedSearchSourceFields, -} from '../../../data/common'; +import { DEFAULT_QUERY_LANGUAGE, SerializedSearchSourceFields } from '../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../data_views/common'; import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, @@ -47,7 +44,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; doc.references.push({ name: searchSource.indexRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: searchSource.index, }); delete searchSource.index; @@ -60,7 +57,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { filterRow.meta.indexRefName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; doc.references.push({ name: filterRow.meta.indexRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); delete filterRow.meta.index; @@ -658,7 +655,7 @@ const migrateControls: SavedObjectMigrationFn = (doc) => { control.indexPatternRefName = `control_${i}_index_pattern`; doc.references.push({ name: control.indexPatternRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; @@ -1103,7 +1100,7 @@ export const replaceIndexPatternReference: SavedObjectMigrationFn = (d references: Array.isArray(doc.references) ? doc.references.map((reference) => { if (reference.type === 'index_pattern') { - reference.type = INDEX_PATTERN_SAVED_OBJECT_TYPE; + reference.type = DATA_VIEW_SAVED_OBJECT_TYPE; } return reference; }) diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 2bc25cfb3c346..ce38bbf55ebdf 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -15,6 +15,7 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, + { "path": "../data_views/tsconfig.json" }, { "path": "../expressions/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, From 93116d0990ec088f00fd970d0c639fe8c80ea9de Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 21 Mar 2022 13:38:48 +0000 Subject: [PATCH 25/38] skip flaky suite (#100968) --- x-pack/test/accessibility/apps/spaces.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 3f6e3f5137c32..78e5dd1f2f2c3 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -18,7 +18,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const toasts = getService('toasts'); - describe('Kibana spaces page meets a11y validations', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100968 + describe.skip('Kibana spaces page meets a11y validations', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); await PageObjects.common.navigateToApp('home'); From 395c65feca8f56cc0b3fac1490d6bfdb685dffd5 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 21 Mar 2022 14:44:52 +0100 Subject: [PATCH 26/38] stabilize confirm dialog (#128141) --- test/functional/services/field_editor.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index d6d2f2606e29d..3014ec79941c5 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -11,6 +11,7 @@ import { FtrService } from '../ftr_provider_context'; export class FieldEditorService extends FtrService { private readonly browser = this.ctx.getService('browser'); private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); public async setName(name: string, clearFirst = false, typeCharByChar = false) { await this.testSubjects.setValue('nameField > input', name, { @@ -50,12 +51,16 @@ export class FieldEditorService extends FtrService { } public async confirmSave() { - await this.testSubjects.setValue('saveModalConfirmText', 'change'); - await this.testSubjects.click('confirmModalConfirmButton'); + await this.retry.try(async () => { + await this.testSubjects.setValue('saveModalConfirmText', 'change'); + await this.testSubjects.clickWhenNotDisabled('confirmModalConfirmButton', { timeout: 1000 }); + }); } public async confirmDelete() { - await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); - await this.testSubjects.click('confirmModalConfirmButton'); + await this.retry.try(async () => { + await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); + await this.testSubjects.clickWhenNotDisabled('confirmModalConfirmButton', { timeout: 1000 }); + }); } } From 279d3e5efd6b97c9a7ed9775875a1f792e8ee519 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 21 Mar 2022 15:50:19 +0200 Subject: [PATCH 27/38] [Timelion, Vega, TSVB] Migrate to dataViews service (#128127) * Timelion dataview service * TSVB dataview service * Fix TSVB tests * Vega DataViews service --- src/plugins/vis_types/timelion/kibana.json | 2 +- .../timelion_expression_input_helpers.test.ts | 4 +- .../public/helpers/arg_value_suggestions.ts | 13 +++--- .../public/helpers/plugin_services.ts | 5 ++- .../vis_types/timelion/public/plugin.ts | 6 ++- .../vis_types/timelion/server/plugin.ts | 2 + .../vis_types/timelion/server/routes/run.ts | 4 +- src/plugins/vis_types/timelion/tsconfig.json | 1 + .../common/index_patterns_utils.test.ts | 12 +++--- .../timeseries/common/index_patterns_utils.ts | 4 +- .../timeseries/common/types/index.ts | 5 ++- src/plugins/vis_types/timeseries/kibana.json | 2 +- .../application/components/annotation_row.tsx | 8 ++-- .../components/annotations_editor.tsx | 6 +-- .../application/components/index_pattern.js | 8 ++-- .../lib/convert_series_to_datatable.test.ts | 18 ++++---- .../lib/convert_series_to_datatable.ts | 12 +++--- .../index_pattern_select/combo_box_select.tsx | 8 ++-- .../index_pattern_select.tsx | 4 +- .../lib/index_pattern_select/types.ts | 4 +- .../application/components/markdown_editor.js | 6 +-- .../components/panel_config/types.ts | 4 +- .../components/query_bar_wrapper.tsx | 10 ++--- .../components/timeseries_visualization.tsx | 6 +-- .../application/components/vis_editor.tsx | 4 +- .../public/application/editor_controller.tsx | 4 +- .../public/application/lib/fetch_fields.ts | 5 +-- .../timeseries/public/metrics_type.test.ts | 42 +++++++++---------- .../timeseries/public/metrics_type.ts | 18 ++++---- .../vis_types/timeseries/public/plugin.ts | 6 ++- .../vis_types/timeseries/public/services.ts | 4 ++ .../get_datasource_info.test.ts | 12 +++--- .../trigger_action/get_datasource_info.ts | 4 +- .../public/trigger_action/get_field_type.ts | 4 +- .../public/trigger_action/index.test.ts | 12 +++--- .../lib/cached_index_pattern_fetcher.test.ts | 10 ++--- .../lib/cached_index_pattern_fetcher.ts | 4 +- .../search_strategies/lib/fields_fetcher.ts | 4 +- .../abstract_search_strategy.test.ts | 4 +- .../strategies/abstract_search_strategy.ts | 4 +- .../strategies/default_search_strategy.ts | 4 +- .../strategies/rollup_search_strategy.test.ts | 4 +- .../strategies/rollup_search_strategy.ts | 8 ++-- .../vis_types/timeseries/server/plugin.ts | 10 +++-- .../vis_types/timeseries/server/types.ts | 5 ++- .../vis_types/timeseries/tsconfig.json | 1 + src/plugins/vis_types/vega/kibana.json | 2 +- .../vega/public/data_model/search_api.test.ts | 27 ++++++------ .../vega/public/data_model/search_api.ts | 3 +- .../public/lib/extract_index_pattern.test.ts | 8 ++-- .../vega/public/lib/extract_index_pattern.ts | 12 +++--- src/plugins/vis_types/vega/public/plugin.ts | 6 ++- src/plugins/vis_types/vega/public/services.ts | 4 ++ .../vega/public/vega_request_handler.ts | 7 ++-- .../vega/public/vega_view/vega_base_view.js | 8 ++-- .../vega_view/vega_map_view/view.test.ts | 14 ++++++- .../vega/public/vega_visualization.test.js | 7 ++-- src/plugins/vis_types/vega/tsconfig.json | 1 + 58 files changed, 227 insertions(+), 199 deletions(-) diff --git a/src/plugins/vis_types/timelion/kibana.json b/src/plugins/vis_types/timelion/kibana.json index 7f40f965e754b..d1af2e9cd792b 100644 --- a/src/plugins/vis_types/timelion/kibana.json +++ b/src/plugins/vis_types/timelion/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions", "charts"], + "requiredPlugins": ["visualizations", "data", "expressions", "charts", "dataViews"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { "name": "Vis Editors", diff --git a/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts b/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts index a25c3484d9ee8..701da1d6a6fb7 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts +++ b/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts @@ -9,11 +9,11 @@ import { SUGGESTION_TYPE, suggest } from './timelion_expression_input_helpers'; import { getArgValueSuggestions } from '../helpers/arg_value_suggestions'; import { setIndexPatterns } from '../helpers/plugin_services'; -import { IndexPatternsContract } from 'src/plugins/data/public'; +import { DataViewsContract } from 'src/plugins/data_views/public'; import { ITimelionFunction } from '../../common/types'; describe('Timelion expression suggestions', () => { - setIndexPatterns({} as IndexPatternsContract); + setIndexPatterns({} as DataViewsContract); const argValueSuggestions = getArgValueSuggestions(); diff --git a/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts index b2f60b4092bf7..494135280343e 100644 --- a/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts @@ -7,10 +7,11 @@ */ import { get } from 'lodash'; +import { isNestedField } from '../../../..//data_views/common'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; -import { indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES } from '../../../../data/public'; +import { KBN_FIELD_TYPES } from '../../../../data/public'; export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); @@ -71,9 +72,7 @@ export function getArgValueSuggestions() { .getByType(KBN_FIELD_TYPES.NUMBER) .filter( (field) => - field.aggregatable && - containsFieldName(valueSplit[1], field) && - !indexPatternsUtils.isNestedField(field) + field.aggregatable && containsFieldName(valueSplit[1], field) && !isNestedField(field) ) .map((field) => { const suggestionValue = field.name.replaceAll(':', '\\:'); @@ -103,7 +102,7 @@ export function getArgValueSuggestions() { KBN_FIELD_TYPES.STRING, ].includes(field.type as KBN_FIELD_TYPES) && containsFieldName(partial, field) && - !indexPatternsUtils.isNestedField(field) + !isNestedField(field) ) .map((field) => ({ name: field.name, help: field.type, insertText: field.name })); }, @@ -115,9 +114,7 @@ export function getArgValueSuggestions() { return indexPattern.fields .getByType(KBN_FIELD_TYPES.DATE) - .filter( - (field) => containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) - ) + .filter((field) => containsFieldName(partial, field) && !isNestedField(field)) .map((field) => ({ name: field.name, insertText: field.name })); }, }, diff --git a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts index c88097efa9bf1..bcd618e6e832b 100644 --- a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts +++ b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts @@ -6,12 +6,13 @@ * Side Public License, v 1. */ -import type { IndexPatternsContract, ISearchStart } from 'src/plugins/data/public'; +import type { ISearchStart } from 'src/plugins/data/public'; +import type { DataViewsContract } from 'src/plugins/data_views/public'; import type { ChartsPluginStart } from 'src/plugins/charts/public'; import { createGetterSetter } from '../../../../kibana_utils/public'; export const [getIndexPatterns, setIndexPatterns] = - createGetterSetter('IndexPatterns'); + createGetterSetter('dataViews'); export const [getDataSearch, setDataSearch] = createGetterSetter('Search'); diff --git a/src/plugins/vis_types/timelion/public/plugin.ts b/src/plugins/vis_types/timelion/public/plugin.ts index fb2b1df6f522e..20a6857771cc4 100644 --- a/src/plugins/vis_types/timelion/public/plugin.ts +++ b/src/plugins/vis_types/timelion/public/plugin.ts @@ -21,6 +21,7 @@ import type { DataPublicPluginStart, TimefilterContract, } from 'src/plugins/data/public'; +import type { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import type { VisualizationsSetup } from 'src/plugins/visualizations/public'; import type { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; @@ -52,6 +53,7 @@ export interface TimelionVisSetupDependencies { /** @internal */ export interface TimelionVisStartDependencies { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; charts: ChartsPluginStart; } @@ -88,8 +90,8 @@ export class TimelionVisPlugin visualizations.createBaseVisualization(getTimelionVisDefinition(dependencies)); } - public start(core: CoreStart, { data, charts }: TimelionVisStartDependencies) { - setIndexPatterns(data.indexPatterns); + public start(core: CoreStart, { data, charts, dataViews }: TimelionVisStartDependencies) { + setIndexPatterns(dataViews); setDataSearch(data.search); setCharts(charts); diff --git a/src/plugins/vis_types/timelion/server/plugin.ts b/src/plugins/vis_types/timelion/server/plugin.ts index 37308e337ef46..12c2dabf62b58 100644 --- a/src/plugins/vis_types/timelion/server/plugin.ts +++ b/src/plugins/vis_types/timelion/server/plugin.ts @@ -13,6 +13,7 @@ import type { PluginStart, DataRequestHandlerContext, } from '../../../../../src/plugins/data/server'; +import type { PluginStart as DataViewPluginStart } from '../../../../../src/plugins/data_views/server'; import { CoreSetup, PluginInitializerContext, Plugin } from '../../../../../src/core/server'; import { configSchema } from '../config'; import loadFunctions from './lib/load_functions'; @@ -23,6 +24,7 @@ import { getUiSettings } from './ui_settings'; export interface TimelionPluginStartDeps { data: PluginStart; + dataViews: DataViewPluginStart; } /** diff --git a/src/plugins/vis_types/timelion/server/routes/run.ts b/src/plugins/vis_types/timelion/server/routes/run.ts index d615302253a1d..325c675011d13 100644 --- a/src/plugins/vis_types/timelion/server/routes/run.ts +++ b/src/plugins/vis_types/timelion/server/routes/run.ts @@ -76,9 +76,9 @@ export function runRoute( }, }, router.handleLegacyErrors(async (context, request, response) => { - const [, { data }] = await core.getStartServices(); + const [, { dataViews }] = await core.getStartServices(); const uiSettings = await context.core.uiSettings.client.getAll(); - const indexPatternsService = await data.indexPatterns.indexPatternsServiceFactory( + const indexPatternsService = await dataViews.dataViewsServiceFactory( context.core.savedObjects.client, context.core.elasticsearch.client.asCurrentUser ); diff --git a/src/plugins/vis_types/timelion/tsconfig.json b/src/plugins/vis_types/timelion/tsconfig.json index 8613f381e5e4f..3ce35e4ff1f5e 100644 --- a/src/plugins/vis_types/timelion/tsconfig.json +++ b/src/plugins/vis_types/timelion/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../../core/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, { "path": "../../kibana_react/tsconfig.json" }, diff --git a/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts index e9f3be64079ac..0d4b293a3242b 100644 --- a/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts +++ b/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts @@ -12,7 +12,7 @@ import { fetchIndexPattern, } from './index_patterns_utils'; import { Panel } from './types'; -import { IndexPattern, IndexPatternsService } from '../../../data/common'; +import type { DataView, DataViewsService } from '../../../data_views/public'; describe('isStringTypeIndexPattern', () => { test('should returns true on string-based index', () => { @@ -54,8 +54,8 @@ describe('extractIndexPatterns', () => { }); describe('fetchIndexPattern', () => { - let mockedIndices: IndexPattern[] | []; - let indexPatternsService: IndexPatternsService; + let mockedIndices: DataView[] | []; + let indexPatternsService: DataViewsService; beforeEach(() => { mockedIndices = []; @@ -64,7 +64,7 @@ describe('fetchIndexPattern', () => { getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), get: jest.fn(() => Promise.resolve(mockedIndices[0])), find: jest.fn(() => Promise.resolve(mockedIndices || [])), - } as unknown as IndexPatternsService; + } as unknown as DataViewsService; }); test('should return default index on no input value', async () => { @@ -87,7 +87,7 @@ describe('fetchIndexPattern', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await fetchIndexPattern('indexTitle', indexPatternsService, { fetchKibanaIndexForStringIndexes: true, @@ -125,7 +125,7 @@ describe('fetchIndexPattern', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await fetchIndexPattern({ id: 'indexId' }, indexPatternsService); diff --git a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts index 0a65e9e16d130..4c4abf0023de0 100644 --- a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import type { Panel, IndexPatternValue, FetchedIndexPattern } from '../common/types'; -import { IndexPatternsService } from '../../../data/common'; +import { DataViewsService } from '../../../data_views/common'; export const isStringTypeIndexPattern = ( indexPatternValue: IndexPatternValue @@ -45,7 +45,7 @@ export const extractIndexPatternValues = (panel: Panel, defaultIndexId?: string) export const fetchIndexPattern = async ( indexPatternValue: IndexPatternValue | undefined, - indexPatternsService: Pick, + indexPatternsService: Pick, options: { fetchKibanaIndexForStringIndexes: boolean; } = { diff --git a/src/plugins/vis_types/timeseries/common/types/index.ts b/src/plugins/vis_types/timeseries/common/types/index.ts index 001ea02eb355a..2fa775765b496 100644 --- a/src/plugins/vis_types/timeseries/common/types/index.ts +++ b/src/plugins/vis_types/timeseries/common/types/index.ts @@ -7,7 +7,8 @@ */ import { Filter } from '@kbn/es-query'; -import { IndexPattern, KBN_FIELD_TYPES, Query } from '../../../../data/common'; +import { KBN_FIELD_TYPES, Query } from '../../../../data/common'; +import type { DataView } from '../../../../data_views/public'; import { Panel } from './panel_model'; export type { Metric, Series, Panel, MetricType } from './panel_model'; @@ -22,7 +23,7 @@ export type { } from './vis_data'; export interface FetchedIndexPattern { - indexPattern: IndexPattern | undefined | null; + indexPattern: DataView | undefined | null; indexPatternString: string | undefined; } diff --git a/src/plugins/vis_types/timeseries/kibana.json b/src/plugins/vis_types/timeseries/kibana.json index 66c5b416a0d96..f5d808ab152d2 100644 --- a/src/plugins/vis_types/timeseries/kibana.json +++ b/src/plugins/vis_types/timeseries/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "inspector"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "inspector", "dataViews"], "optionalPlugins": ["home","usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact", "fieldFormats"], "owner": { diff --git a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx index 562fb75089e19..b571c958e1a08 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { KBN_FIELD_TYPES, Query } from '../../../../../../plugins/data/public'; import { AddDeleteButtons } from './add_delete_buttons'; @@ -72,7 +72,7 @@ export const AnnotationRow = ({ useEffect(() => { const updateFetchedIndex = async (index: IndexPatternValue) => { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); let fetchedIndexPattern: IndexPatternSelectProps['fetchedIndex'] = { indexPattern: undefined, indexPatternString: undefined, @@ -80,12 +80,12 @@ export const AnnotationRow = ({ try { fetchedIndexPattern = index - ? await fetchIndexPattern(index, indexPatterns, { + ? await fetchIndexPattern(index, dataViews, { fetchKibanaIndexForStringIndexes: true, }) : { ...fetchedIndexPattern, - defaultIndex: await indexPatterns.getDefault(), + defaultIndex: await dataViews.getDefault(), }; } catch { // nothing to be here diff --git a/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx index 12e69e9043aae..006682d4bfa37 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; import { EuiSpacer, EuiTitle, EuiButton, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import { AnnotationRow } from './annotation_row'; import { collectionActions, CollectionActionsProps } from './lib/collection_actions'; @@ -22,10 +22,10 @@ interface AnnotationsEditorProps { fields: VisFields; model: Panel; onChange: (partialModel: Partial) => void; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } -export const newAnnotation = (defaultIndexPattern?: IndexPattern) => () => ({ +export const newAnnotation = (defaultIndexPattern?: DataView) => () => ({ id: uuid.v1(), color: '#F00', index_pattern: diff --git a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js index 7b3ae5f3e16ef..26d8a91824a83 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js @@ -36,7 +36,7 @@ import { isTimerangeModeEnabled } from '../../../common/check_ui_restrictions'; import { VisDataContext } from '../contexts/vis_data_context'; import { PanelModelContext } from '../contexts/panel_model_context'; import { FormValidationContext } from '../contexts/form_validation_context'; -import { getDataStart, getUISettings } from '../../services'; +import { getUISettings, getDataViewsStart } from '../../services'; import { UI_SETTINGS } from '../../../../../data/common'; import { fetchIndexPattern } from '../../../common/index_patterns_utils'; @@ -143,7 +143,7 @@ export const IndexPattern = ({ useEffect(() => { async function fetchIndex() { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); let fetchedIndexPattern = { indexPattern: undefined, indexPatternString: undefined, @@ -153,12 +153,12 @@ export const IndexPattern = ({ try { fetchedIndexPattern = indexPatternToFetch - ? await fetchIndexPattern(indexPatternToFetch, indexPatterns, { + ? await fetchIndexPattern(indexPatternToFetch, dataViews, { fetchKibanaIndexForStringIndexes: true, }) : { ...fetchedIndexPattern, - defaultIndex: await indexPatterns.getDefault(), + defaultIndex: await dataViews.getDefault(), }; } catch { // nothing to be here diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts index 08ee275d144ea..8172d5d1a211b 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; +import { DataView, DataViewField } from 'src/plugins/data_views/public'; import { PanelData } from '../../../../common/types'; import { TimeseriesVisParams } from '../../../types'; import { convertSeriesToDataTable, addMetaToColumns } from './convert_series_to_datatable'; @@ -14,7 +14,6 @@ jest.mock('../../../services', () => { return { getDataStart: jest.fn(() => { return { - indexPatterns: jest.fn(), query: { timefilter: { timefilter: { @@ -29,29 +28,30 @@ jest.mock('../../../services', () => { }, }; }), + getDataViewsStart: jest.fn(), }; }); describe('convert series to datatables', () => { - let indexPattern: IndexPattern; + let indexPattern: DataView; beforeEach(() => { - const fieldMap: Record = { - test1: { name: 'test1', spec: { type: 'date', name: 'test1' } } as IndexPatternField, + const fieldMap: Record = { + test1: { name: 'test1', spec: { type: 'date', name: 'test1' } } as DataViewField, test2: { name: 'test2', spec: { type: 'number', name: 'Average of test2' }, - } as IndexPatternField, - test3: { name: 'test3', spec: { type: 'boolean', name: 'test3' } } as IndexPatternField, + } as DataViewField, + test3: { name: 'test3', spec: { type: 'boolean', name: 'test3' } } as DataViewField, }; - const getFieldByName = (name: string): IndexPatternField | undefined => fieldMap[name]; + const getFieldByName = (name: string): DataViewField | undefined => fieldMap[name]; indexPattern = { id: 'index1', title: 'index1', timeFieldName: 'timestamp', getFieldByName, - } as IndexPattern; + } as DataView; }); describe('addMetaColumns()', () => { diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts index 62397e3b1d8c2..3d4c1cb2a9870 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from 'src/plugins/data_views/public'; import { DatatableRow, DatatableColumn, DatatableColumnType } from 'src/plugins/expressions/public'; import { Query } from 'src/plugins/data/common'; import { TimeseriesVisParams } from '../../../types'; @@ -13,7 +13,7 @@ import type { PanelData, Metric } from '../../../../common/types'; import { getMultiFieldLabel, getFieldsForTerms } from '../../../../common/fields_utils'; import { BUCKET_TYPES, TSVB_METRIC_TYPES } from '../../../../common/enums'; import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; -import { getDataStart } from '../../../services'; +import { getDataStart, getDataViewsStart } from '../../../services'; import { X_ACCESSOR_INDEX } from '../../visualizations/constants'; import type { TSVBTables } from './types'; @@ -33,7 +33,7 @@ interface TSVBColumns { export const addMetaToColumns = ( columns: TSVBColumns[], - indexPattern: IndexPattern + indexPattern: DataView ): DatatableColumn[] => { return columns.map((column) => { const field = indexPattern.getFieldByName(column.name); @@ -86,17 +86,17 @@ const hasSeriesAgg = (metrics: Metric[]) => { export const convertSeriesToDataTable = async ( model: TimeseriesVisParams, series: PanelData[], - initialIndexPattern: IndexPattern + initialIndexPattern: DataView ) => { const tables: TSVBTables = {}; - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { const layer = model.series[layerIdx]; let usedIndexPattern = initialIndexPattern; // The user can overwrite the index pattern of a layer. // In that case, the index pattern should be fetched again. if (layer.override_index_pattern) { - const { indexPattern } = await fetchIndexPattern(layer.series_index_pattern, indexPatterns); + const { indexPattern } = await fetchIndexPattern(layer.series_index_pattern, dataViews); if (indexPattern) { usedIndexPattern = indexPattern; } diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx index 6209de68cea8b..be32104289d09 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx @@ -8,16 +8,16 @@ import React, { useCallback, useState, useEffect } from 'react'; import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui'; -import { getDataStart } from '../../../../services'; +import { getDataViewsStart } from '../../../../services'; import { SwitchModePopover } from './switch_mode_popover'; import type { SelectIndexComponentProps } from './types'; import type { IndexPatternValue } from '../../../../../common/types'; -import type { IndexPatternsService } from '../../../../../../../data/public'; +import type { DataViewsService } from '../../../../../../../data_views/public'; /** @internal **/ -type IdsWithTitle = Awaited>; +type IdsWithTitle = Awaited>; /** @internal **/ type SelectedOptions = EuiComboBoxProps['selectedOptions']; @@ -65,7 +65,7 @@ export const ComboBoxSelect = ({ useEffect(() => { async function fetchIndexes() { - setAvailableIndexes(await getDataStart().indexPatterns.getIdsWithTitle()); + setAvailableIndexes(await getDataViewsStart().getIdsWithTitle()); } fetchIndexes(); diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx index 6c095a9074bb7..6cc1c8d1e6968 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -19,7 +19,7 @@ import { ComboBoxSelect } from './combo_box_select'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; import { USE_KIBANA_INDEXES_KEY } from '../../../../../common/constants'; -import { IndexPattern } from '../../../../../../../data/common'; +import type { DataView } from '../../../../../../../data_views/public'; export interface IndexPatternSelectProps { indexPatternName: string; @@ -28,7 +28,7 @@ export interface IndexPatternSelectProps { allowIndexSwitchingMode?: boolean; fetchedIndex: | (FetchedIndexPattern & { - defaultIndex?: IndexPattern | null; + defaultIndex?: DataView | null; }) | null; } diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts index 244e95e8db9dd..e922fd6c7ed0a 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -7,12 +7,12 @@ */ import type { Assign } from '@kbn/utility-types'; import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; -import type { IndexPattern } from '../../../../../../../data/common'; +import type { DataView } from '../../../../../../../data_views/public'; /** @internal **/ export interface SelectIndexComponentProps { fetchedIndex: FetchedIndexPattern & { - defaultIndex?: IndexPattern | null; + defaultIndex?: DataView | null; }; onIndexChange: (value: IndexPatternValue) => void; onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; diff --git a/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js b/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js index 27a6f75e7f1f7..8dd92b97e0ee7 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js +++ b/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js @@ -20,7 +20,7 @@ import { CodeEditor, MarkdownLang } from '../../../../../kibana_react/public'; import { EuiText, EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { fetchIndexPattern } from '../../../common/index_patterns_utils'; export class MarkdownEditor extends Component { @@ -46,8 +46,8 @@ export class MarkdownEditor extends Component { }; async componentDidMount() { - const { indexPatterns } = getDataStart(); - const { indexPattern } = await fetchIndexPattern(this.props.model.index_pattern, indexPatterns); + const dataViews = getDataViewsStart(); + const { indexPattern } = await fetchIndexPattern(this.props.model.index_pattern, dataViews); this.setState({ fieldFormatMap: indexPattern?.fieldFormatMap }); } diff --git a/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts b/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts index ecbc5af601be7..ec55121a14e9f 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; import { IUiSettingsClient } from 'kibana/public'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import type { TimeseriesVisData } from '../../../../common/types'; import { TimeseriesVisParams } from '../../../types'; import { VisFields } from '../../lib/fetch_fields'; @@ -19,7 +19,7 @@ export interface PanelConfigProps { visData$: Observable; getConfig: IUiSettingsClient['get']; onChange: (partialModel: Partial) => void; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } export enum PANEL_CONFIG_TABS { diff --git a/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx b/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx index 849415838c5e6..ad6abe46ac859 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx @@ -12,7 +12,7 @@ import { CoreStartContext } from '../contexts/query_input_bar_context'; import type { IndexPatternValue } from '../../../common/types'; import { QueryStringInput, QueryStringInputProps } from '../../../../../../plugins/data/public'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { fetchIndexPattern, isStringTypeIndexPattern } from '../../../common/index_patterns_utils'; type QueryBarWrapperProps = Pick & { @@ -27,7 +27,7 @@ export function QueryBarWrapper({ indexPatterns, 'data-test-subj': dataTestSubj, }: QueryBarWrapperProps) { - const { indexPatterns: indexPatternsService } = getDataStart(); + const dataViews = getDataViewsStart(); const [indexes, setIndexes] = useState([]); const coreStartContext = useContext(CoreStartContext); @@ -41,14 +41,14 @@ export function QueryBarWrapper({ if (isStringTypeIndexPattern(index)) { i.push(index); } else if (index?.id) { - const { indexPattern } = await fetchIndexPattern(index, indexPatternsService); + const { indexPattern } = await fetchIndexPattern(index, dataViews); if (indexPattern) { i.push(indexPattern); } } } else { - const defaultIndex = await indexPatternsService.getDefault(); + const defaultIndex = await dataViews.getDefault(); if (defaultIndex) { i.push(defaultIndex); @@ -59,7 +59,7 @@ export function QueryBarWrapper({ } fetchIndexes(); - }, [indexPatterns, indexPatternsService]); + }, [indexPatterns, dataViews]); return ( { - fetchIndexPattern(model.index_pattern, getDataStart().indexPatterns).then( - (fetchedIndexPattern) => setIndexPattern(fetchedIndexPattern.indexPattern) + fetchIndexPattern(model.index_pattern, getDataViewsStart()).then((fetchedIndexPattern) => + setIndexPattern(fetchedIndexPattern.indexPattern) ); }, [model.index_pattern]); diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx index 59710cbcff616..4b3d51ad5d42a 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx @@ -12,7 +12,7 @@ import { share } from 'rxjs/operators'; import { isEqual, isEmpty, debounce } from 'lodash'; import { EventEmitter } from 'events'; import type { IUiSettingsClient } from 'kibana/public'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import type { Vis, VisualizeEmbeddableContract, @@ -47,7 +47,7 @@ export interface TimeseriesEditorProps { query: EditorRenderProps['query']; uiState: EditorRenderProps['uiState']; vis: Vis; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } interface TimeseriesEditorState { diff --git a/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx b/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx index bdf265b37b26c..01958e77c78d5 100644 --- a/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx +++ b/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx @@ -15,7 +15,7 @@ import type { IEditorController, EditorRenderProps, } from 'src/plugins/visualizations/public'; -import { getUISettings, getI18n, getCoreStart, getDataStart } from '../services'; +import { getUISettings, getI18n, getCoreStart, getDataViewsStart } from '../services'; import { VisEditor } from './components/vis_editor_lazy'; import type { TimeseriesVisParams } from '../types'; import { KibanaThemeProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -32,7 +32,7 @@ export class EditorController implements IEditorController { async render({ timeRange, uiState, filters, query }: EditorRenderProps) { const I18nContext = getI18n().Context; - const defaultIndexPattern = (await getDataStart().dataViews.getDefault()) || undefined; + const defaultIndexPattern = (await getDataViewsStart().getDefault()) || undefined; render( diff --git a/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts index 71e38be302579..eaf913a8a8533 100644 --- a/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { getCoreStart, getDataStart } from '../../services'; +import { getCoreStart, getDataViewsStart } from '../../services'; import { ROUTES } from '../../../common/constants'; import type { SanitizedFieldType, IndexPatternValue } from '../../../common/types'; import { getIndexPatternKey } from '../../../common/index_patterns_utils'; @@ -21,7 +21,6 @@ export async function fetchFields( ): Promise { const patterns = Array.isArray(indexes) ? indexes : [indexes]; const coreStart = getCoreStart(); - const dataStart = getDataStart(); const defaultIndex = coreStart.uiSettings.get('defaultIndex'); try { @@ -29,7 +28,7 @@ export async function fetchFields( patterns.map(async (pattern) => { if (typeof pattern !== 'string' && pattern?.id) { return toSanitizedFieldType( - (await dataStart.indexPatterns.get(pattern.id)).getNonScriptedFields() + (await getDataViewsStart().get(pattern.id)).getNonScriptedFields() ); } else { return coreStart.http.get(ROUTES.FIELDS, { diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.test.ts b/src/plugins/vis_types/timeseries/public/metrics_type.test.ts index f9eda5a18b79d..d87dfa23f7dee 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.test.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.test.ts @@ -7,36 +7,34 @@ */ import { cloneDeep } from 'lodash'; -import { DataViewsContract, IndexPattern } from 'src/plugins/data_views/public'; -import { setDataStart } from './services'; +import { DataView } from 'src/plugins/data_views/public'; +import { setDataViewsStart } from './services'; import type { TimeseriesVisParams } from './types'; import type { Vis } from 'src/plugins/visualizations/public'; import { metricsVisDefinition } from './metrics_type'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; describe('metricsVisDefinition', () => { describe('getUsedIndexPattern', () => { - const indexPattern1 = { id: '1', title: 'pattern1' } as unknown as IndexPattern; - const indexPattern2 = { id: '2', title: 'pattern2' } as unknown as IndexPattern; + const indexPattern1 = { id: '1', title: 'pattern1' } as unknown as DataView; + const indexPattern2 = { id: '2', title: 'pattern2' } as unknown as DataView; let defaultParams: TimeseriesVisParams; beforeEach(async () => { - setDataStart({ - indexPatterns: { - async getDefault() { - return indexPattern1; - }, - async find(title: string) { - if (title === 'pattern1') return [indexPattern1]; - if (title === 'pattern2') return [indexPattern2]; - return []; - }, - async get(id: string) { - if (id === '1') return indexPattern1; - if (id === '2') return indexPattern2; - throw new Error(); - }, - } as unknown as DataViewsContract, - } as DataPublicPluginStart); + setDataViewsStart({ + async getDefault() { + return indexPattern1; + }, + async find(title: string) { + if (title === 'pattern1') return [indexPattern1]; + if (title === 'pattern2') return [indexPattern2]; + return []; + }, + async get(id: string) { + if (id === '1') return indexPattern1; + if (id === '2') return indexPattern2; + throw new Error(); + }, + } as DataViewsPublicPluginStart); defaultParams = ( await metricsVisDefinition.setup!({ params: cloneDeep(metricsVisDefinition.visConfig.defaults), diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index 0c5b365938058..7b1f6e4bd5eb3 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; -import { DataViewsContract, IndexPattern } from 'src/plugins/data_views/public'; +import type { DataViewsContract, DataView } from 'src/plugins/data_views/public'; import { TSVB_EDITOR_NAME } from './application/editor_controller'; import { PANEL_TYPES, TOOLTIP_MODES } from '../common/enums'; import { @@ -24,7 +24,7 @@ import { VisParams, VisTypeDefinition, } from '../../../visualizations/public'; -import { getDataStart } from './services'; +import { getDataViewsStart } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; import type { IndexPatternValue, Panel } from '../common/types'; import { RequestAdapter } from '../../../inspector/public'; @@ -55,9 +55,9 @@ export const withReplacedIds = ( async function withDefaultIndexPattern( vis: Vis ): Promise> { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); - const defaultIndex = await indexPatterns.getDefault(); + const defaultIndex = await dataViews.getDefault(); if (!defaultIndex || !defaultIndex.id || vis.params.index_pattern) return vis; vis.params.index_pattern = { id: defaultIndex.id, @@ -79,16 +79,16 @@ async function resolveIndexPattern( } } -async function getUsedIndexPatterns(params: VisParams): Promise { - const { indexPatterns } = getDataStart(); +async function getUsedIndexPatterns(params: VisParams): Promise { + const dataViews = getDataViewsStart(); - const defaultIndex = await indexPatterns.getDefault(); - const resolvedIndexPatterns: IndexPattern[] = []; + const defaultIndex = await dataViews.getDefault(); + const resolvedIndexPatterns: DataView[] = []; const indexPatternValues = extractIndexPatternValues(params as Panel, defaultIndex?.id); ( await Promise.all( indexPatternValues.map((indexPatternValue) => - resolveIndexPattern(indexPatternValue, indexPatterns) + resolveIndexPattern(indexPatternValue, dataViews) ) ) ).forEach((patterns) => patterns && resolvedIndexPatterns.push(...patterns)); diff --git a/src/plugins/vis_types/timeseries/public/plugin.ts b/src/plugins/vis_types/timeseries/public/plugin.ts index 3370cc8a29275..e669e9132b47a 100644 --- a/src/plugins/vis_types/timeseries/public/plugin.ts +++ b/src/plugins/vis_types/timeseries/public/plugin.ts @@ -19,9 +19,11 @@ import { setFieldFormats, setCoreStart, setDataStart, + setDataViewsStart, setCharts, } from './services'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; import { ChartsPluginStart } from '../../../charts/public'; import { getTimeseriesVisRenderer } from './timeseries_vis_renderer'; @@ -34,6 +36,7 @@ export interface MetricsPluginSetupDependencies { /** @internal */ export interface MetricsPluginStartDependencies { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; charts: ChartsPluginStart; } @@ -58,11 +61,12 @@ export class MetricsPlugin implements Plugin { visualizations.createBaseVisualization(metricsVisDefinition); } - public start(core: CoreStart, { data, charts }: MetricsPluginStartDependencies) { + public start(core: CoreStart, { data, charts, dataViews }: MetricsPluginStartDependencies) { setCharts(charts); setI18n(core.i18n); setFieldFormats(data.fieldFormats); setDataStart(data); + setDataViewsStart(dataViews); setCoreStart(core); } } diff --git a/src/plugins/vis_types/timeseries/public/services.ts b/src/plugins/vis_types/timeseries/public/services.ts index f76a9ed7c6389..329d3642e0ce4 100644 --- a/src/plugins/vis_types/timeseries/public/services.ts +++ b/src/plugins/vis_types/timeseries/public/services.ts @@ -10,6 +10,7 @@ import { I18nStart, IUiSettingsClient, CoreStart } from 'src/core/public'; import { createGetterSetter } from '../../../kibana_utils/public'; import { ChartsPluginStart } from '../../../charts/public'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -20,6 +21,9 @@ export const [getCoreStart, setCoreStart] = createGetterSetter('CoreS export const [getDataStart, setDataStart] = createGetterSetter('DataStart'); +export const [getDataViewsStart, setDataViewsStart] = + createGetterSetter('dataViews'); + export const [getI18n, setI18n] = createGetterSetter('I18n'); export const [getCharts, setCharts] = createGetterSetter('ChartsPluginStart'); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts index 5a3c545d80aa0..f9b02823804df 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts @@ -20,14 +20,12 @@ const dataViewsMap: Record = { const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; jest.mock('../services', () => { return { - getDataStart: jest.fn(() => { + getDataViewsStart: jest.fn(() => { return { - dataViews: { - getDefault: jest.fn(() => { - return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; - }), - get: getDataview, - }, + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, }; }), }; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts index 0b4d6e6eacd3a..b5c9addd81435 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts @@ -7,7 +7,7 @@ */ import { fetchIndexPattern, isStringTypeIndexPattern } from '../../common/index_patterns_utils'; import type { IndexPatternValue } from '../../common/types'; -import { getDataStart } from '../services'; +import { getDataViewsStart } from '../services'; export const getDataSourceInfo = async ( modelIndexPattern: IndexPatternValue, @@ -15,7 +15,7 @@ export const getDataSourceInfo = async ( isOverwritten: boolean, overwrittenIndexPattern: IndexPatternValue | undefined ) => { - const { dataViews } = getDataStart(); + const dataViews = getDataViewsStart(); let indexPatternId = modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : ''; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts index c71955942c91c..9e508f895e914 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts @@ -5,10 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { getDataStart } from '../services'; +import { getDataViewsStart } from '../services'; export const getFieldType = async (indexPatternId: string, fieldName: string) => { - const { dataViews } = getDataStart(); + const dataViews = getDataViewsStart(); const dataView = await dataViews.get(indexPatternId); const field = await dataView.getFieldByName(fieldName); return field?.type; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts index e12077c0239e5..97189e4f9c826 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts @@ -22,14 +22,12 @@ const dataViewsMap: Record = { const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; jest.mock('../services', () => { return { - getDataStart: jest.fn(() => { + getDataViewsStart: jest.fn(() => { return { - dataViews: { - getDefault: jest.fn(() => { - return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; - }), - get: getDataview, - }, + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, }; }), }; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts index fe2c4604c48f6..f407b224ac7cd 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { DataView, DataViewsService } from 'src/plugins/data_views/common'; import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getCachedIndexPatternFetcher, @@ -16,7 +16,7 @@ import { jest.mock('../../../../common/index_patterns_utils'); describe('CachedIndexPatternFetcher', () => { - let mockedIndices: IndexPattern[] | []; + let mockedIndices: DataView[] | []; let cachedIndexPatternFetcher: CachedIndexPatternFetcher; beforeEach(() => { @@ -26,7 +26,7 @@ describe('CachedIndexPatternFetcher', () => { getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), get: jest.fn(() => Promise.resolve(mockedIndices[0])), find: jest.fn(() => Promise.resolve(mockedIndices || [])), - } as unknown as IndexPatternsService; + } as unknown as DataViewsService; (fetchIndexPattern as jest.Mock).mockClear(); @@ -74,7 +74,7 @@ describe('CachedIndexPatternFetcher', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await cachedIndexPatternFetcher({ id: 'indexId' }); @@ -106,7 +106,7 @@ describe('CachedIndexPatternFetcher', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; await cachedIndexPatternFetcher({ id: 'indexId' }); await cachedIndexPatternFetcher({ id: 'indexId' }); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index 2a3738878c97a..9bab0e520e74b 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -8,11 +8,11 @@ import { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_patterns_utils'; -import type { IndexPatternsService } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; export const getCachedIndexPatternFetcher = ( - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, globalOptions: { fetchKibanaIndexForStringIndexes: boolean; } = { diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index cf7bc42fc6db3..788ce2ff44a3c 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -10,12 +10,12 @@ import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; import type { SearchStrategy, SearchCapabilities } from '../index'; -import type { IndexPatternsService } from '../../../../../../data/common'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; import type { IndexPatternValue } from '../../../../common/types'; export interface FieldsFetcherServices { - indexPatternsService: IndexPatternsService; + indexPatternsService: DataViewsService; cachedIndexPatternFetcher: CachedIndexPatternFetcher; searchStrategy: SearchStrategy; capabilities: SearchCapabilities; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index f52d1bd9b7427..8677a44941c43 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IndexPatternsService } from '../../../../../../data/common'; +import { DataViewsService } from '../../../../../../data_views/common'; import { from } from 'rxjs'; import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; @@ -56,7 +56,7 @@ describe('AbstractSearchStrategy', () => { { getDefault: jest.fn(), getFieldsForWildcard: jest.fn(() => Promise.resolve(mockedFields)), - } as unknown as IndexPatternsService, + } as unknown as DataViewsService, (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher ); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 58c67f84a9373..de770b30fb823 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -8,7 +8,7 @@ import { tap } from 'rxjs/operators'; import { omit } from 'lodash'; import type { Observable } from 'rxjs'; -import { IndexPatternsService } from '../../../../../../data/server'; +import { DataViewsService } from '../../../../../../data_views/common'; import { toSanitizedFieldType } from '../../../../common/fields_utils'; import type { FetchedIndexPattern, TrackedEsSearches } from '../../../../common/types'; @@ -90,7 +90,7 @@ export abstract class AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, capabilities?: unknown, options?: Partial<{ type: string; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index ff1c3c0ac71ee..9b683b87c90c7 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -9,7 +9,7 @@ import { AbstractSearchStrategy } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; -import type { IndexPatternsService } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { FetchedIndexPattern } from '../../../../common/types'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -36,7 +36,7 @@ export class DefaultSearchStrategy extends AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, capabilities?: unknown ) { return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index b74bb97c3d649..32adce54fc597 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -8,7 +8,7 @@ import { RollupSearchStrategy } from './rollup_search_strategy'; -import type { IndexPatternsService } from '../../../../../../data/common'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -156,7 +156,7 @@ describe('Rollup Search Strategy', () => { test('should return fields for wildcard', async () => { const fields = await rollupSearchStrategy.getFieldsForWildcard( { indexPatternString: 'indexPattern', indexPattern: undefined }, - {} as IndexPatternsService, + {} as DataViewsService, (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher, { fieldsCapabilities, diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 4d6d9d832a2e0..32c9952e235d5 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import { - getCapabilitiesForRollupIndices, - IndexPatternsService, -} from '../../../../../../data/server'; +import { getCapabilitiesForRollupIndices } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; @@ -93,7 +91,7 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, getCachedIndexPatternFetcher: CachedIndexPatternFetcher, capabilities?: unknown ) { diff --git a/src/plugins/vis_types/timeseries/server/plugin.ts b/src/plugins/vis_types/timeseries/server/plugin.ts index 248016f1a9836..36c8558afcd08 100644 --- a/src/plugins/vis_types/timeseries/server/plugin.ts +++ b/src/plugins/vis_types/timeseries/server/plugin.ts @@ -23,7 +23,8 @@ import { getVisData } from './lib/get_vis_data'; import { UsageCollectionSetup } from '../../../usage_collection/server'; import { HomeServerPluginSetup } from '../../../home/server'; import { PluginStart } from '../../../data/server'; -import { IndexPatternsService } from '../../../data/common'; +import type { DataViewsService } from '../../../data_views/common'; +import type { PluginStart as DataViewsPublicPluginStart } from '../../../data_views/server'; import { visDataRoutes } from './routes/vis'; import { fieldsRoutes } from './routes/fields'; import { getUiSettings } from './ui_settings'; @@ -53,6 +54,7 @@ interface VisTypeTimeseriesPluginSetupDependencies { interface VisTypeTimeseriesPluginStartDependencies { data: PluginStart; + dataViews: DataViewsPublicPluginStart; } export interface VisTypeTimeseriesSetup { @@ -73,7 +75,7 @@ export interface Framework { searchStrategyRegistry: SearchStrategyRegistry; getIndexPatternsService: ( requestContext: VisTypeTimeseriesRequestHandlerContext - ) => Promise; + ) => Promise; getFieldFormatsService: (uiSettings: IUiSettingsClient) => Promise; getEsShardTimeout: () => Promise; } @@ -109,9 +111,9 @@ export class VisTypeTimeseriesPlugin implements Plugin { ) .toPromise(), getIndexPatternsService: async (requestContext) => { - const [, { data }] = await core.getStartServices(); + const [, { dataViews }] = await core.getStartServices(); - return await data.indexPatterns.indexPatternsServiceFactory( + return await dataViews.dataViewsServiceFactory( requestContext.core.savedObjects.client, requestContext.core.elasticsearch.client.asCurrentUser ); diff --git a/src/plugins/vis_types/timeseries/server/types.ts b/src/plugins/vis_types/timeseries/server/types.ts index ab01f09c75f1e..8eeb4b5a68f89 100644 --- a/src/plugins/vis_types/timeseries/server/types.ts +++ b/src/plugins/vis_types/timeseries/server/types.ts @@ -10,7 +10,8 @@ import { Observable } from 'rxjs'; import { EsQueryConfig } from '@kbn/es-query'; import { SharedGlobalConfig } from 'kibana/server'; import type { IRouter, IUiSettingsClient, KibanaRequest } from 'src/core/server'; -import type { DataRequestHandlerContext, IndexPatternsService } from '../../../data/server'; +import type { DataViewsService } from '../../../data_views/common'; +import type { DataRequestHandlerContext } from '../../../data/server'; import type { FieldFormatsRegistry } from '../../../field_formats/common'; import type { Series, VisPayload } from '../common/types'; import type { SearchStrategyRegistry } from './lib/search_strategies'; @@ -31,7 +32,7 @@ export interface VisTypeTimeseriesRequestServices { esShardTimeout: number; esQueryConfig: EsQueryConfig; uiSettings: IUiSettingsClient; - indexPatternsService: IndexPatternsService; + indexPatternsService: DataViewsService; searchStrategyRegistry: SearchStrategyRegistry; cachedIndexPatternFetcher: CachedIndexPatternFetcher; fieldFormatService: FieldFormatsRegistry; diff --git a/src/plugins/vis_types/timeseries/tsconfig.json b/src/plugins/vis_types/timeseries/tsconfig.json index 4462beae8c7be..87d85626c7ab1 100644 --- a/src/plugins/vis_types/timeseries/tsconfig.json +++ b/src/plugins/vis_types/timeseries/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../../core/tsconfig.json" }, { "path": "../../charts/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../dashboard/tsconfig.json" }, diff --git a/src/plugins/vis_types/vega/kibana.json b/src/plugins/vis_types/vega/kibana.json index cedd73cc6d398..d3e0da54d848f 100644 --- a/src/plugins/vis_types/vega/kibana.json +++ b/src/plugins/vis_types/vega/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsEms", "expressions", "inspector"], + "requiredPlugins": ["data", "visualizations", "mapsEms", "expressions", "inspector", "dataViews"], "optionalPlugins": ["home","usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.test.ts b/src/plugins/vis_types/vega/public/data_model/search_api.test.ts index 979f7f05cdf1d..4ca87386e4054 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.test.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.test.ts @@ -7,16 +7,17 @@ */ import { extendSearchParamsWithRuntimeFields } from './search_api'; -import { dataPluginMock } from '../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../data_views/public/mocks'; -import { getSearchParamsFromRequest, DataPublicPluginStart } from '../../../../data/public'; +import { getSearchParamsFromRequest } from '../../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../../data_views/public'; const mockComputedFields = ( - dataStart: DataPublicPluginStart, + dataViewsStart: DataViewsPublicPluginStart, index: string, runtimeFields: Record ) => { - dataStart.indexPatterns.find = jest.fn().mockReturnValue([ + dataViewsStart.find = jest.fn().mockReturnValue([ { title: index, getComputedFields: () => ({ @@ -28,21 +29,20 @@ const mockComputedFields = ( }; describe('extendSearchParamsWithRuntimeFields', () => { - let dataStart: DataPublicPluginStart; + let dataViewsStart: DataViewsPublicPluginStart; beforeEach(() => { - dataStart = dataPluginMock.createStartContract(); + dataViewsStart = dataViewPluginMocks.createStartContract(); }); test('should inject default runtime_mappings for known indexes', async () => { const requestParams = {}; const runtimeFields = { foo: {} }; - mockComputedFields(dataStart, 'index', runtimeFields); + mockComputedFields(dataViewsStart, 'index', runtimeFields); - expect( - await extendSearchParamsWithRuntimeFields(dataStart.indexPatterns, requestParams, 'index') - ).toMatchInlineSnapshot(` + expect(await extendSearchParamsWithRuntimeFields(dataViewsStart, requestParams, 'index')) + .toMatchInlineSnapshot(` Object { "body": Object { "runtime_mappings": Object { @@ -63,11 +63,10 @@ describe('extendSearchParamsWithRuntimeFields', () => { } as unknown as ReturnType; const runtimeFields = { foo: {} }; - mockComputedFields(dataStart, 'index', runtimeFields); + mockComputedFields(dataViewsStart, 'index', runtimeFields); - expect( - await extendSearchParamsWithRuntimeFields(dataStart.indexPatterns, requestParams, 'index') - ).toMatchInlineSnapshot(` + expect(await extendSearchParamsWithRuntimeFields(dataViewsStart, requestParams, 'index')) + .toMatchInlineSnapshot(` Object { "body": Object { "runtime_mappings": Object { diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index 6a7ee55b299d0..19889edf11a82 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -15,6 +15,7 @@ import { DataPublicPluginStart, IEsSearchResponse, } from '../../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../../data_views/public'; import { search as dataPluginSearch } from '../../../../data/public'; import type { VegaInspectorAdapters } from '../vega_inspector'; import type { RequestResponder } from '../../../../inspector/public'; @@ -48,7 +49,7 @@ export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; injectedMetadata: CoreStart['injectedMetadata']; search: DataPublicPluginStart['search']; - indexPatterns: DataPublicPluginStart['indexPatterns']; + indexPatterns: DataViewsPublicPluginStart; } export class SearchAPI { diff --git a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts index 8d9ab0e10ebc9..70a03a5de995f 100644 --- a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts +++ b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts @@ -6,19 +6,19 @@ * Side Public License, v 1. */ -import { dataPluginMock } from '../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../data_views/public/mocks'; import { extractIndexPatternsFromSpec } from './extract_index_pattern'; -import { setData } from '../services'; +import { setDataViews } from '../services'; import type { VegaSpec } from '../data_model/types'; const getMockedSpec = (mockedObj: any) => mockedObj as unknown as VegaSpec; describe('extractIndexPatternsFromSpec', () => { - const dataStart = dataPluginMock.createStartContract(); + const dataViewsStart = dataViewPluginMocks.createStartContract(); beforeAll(() => { - setData(dataStart); + setDataViews(dataViewsStart); }); test('should not throw errors if no index is specified', async () => { diff --git a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts index 0d25db665ce7f..a2d22dce614cb 100644 --- a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts +++ b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts @@ -7,13 +7,13 @@ */ import { flatten } from 'lodash'; -import { getData } from '../services'; +import { getDataViews } from '../services'; import type { Data, VegaSpec } from '../data_model/types'; -import type { IndexPattern } from '../../../../data/public'; +import type { DataView } from '../../../../data_views/public'; export const extractIndexPatternsFromSpec = async (spec: VegaSpec) => { - const { indexPatterns } = getData(); + const dataViews = getDataViews(); let data: Data[] = []; if (Array.isArray(spec.data)) { @@ -22,11 +22,11 @@ export const extractIndexPatternsFromSpec = async (spec: VegaSpec) => { data = [spec.data]; } - return flatten( + return flatten( await Promise.all( - data.reduce>>((accumulator, currentValue) => { + data.reduce>>((accumulator, currentValue) => { if (currentValue.url?.index) { - accumulator.push(indexPatterns.find(currentValue.url.index)); + accumulator.push(dataViews.find(currentValue.url.index)); } return accumulator; diff --git a/src/plugins/vis_types/vega/public/plugin.ts b/src/plugins/vis_types/vega/public/plugin.ts index bf08b464adccd..e33f6141e7358 100644 --- a/src/plugins/vis_types/vega/public/plugin.ts +++ b/src/plugins/vis_types/vega/public/plugin.ts @@ -9,12 +9,14 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; import { Plugin as ExpressionsPublicPlugin } from '../../../expressions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../data_views/public'; import { VisualizationsSetup } from '../../../visualizations/public'; import { Setup as InspectorSetup } from '../../../inspector/public'; import { setNotifications, setData, + setDataViews, setInjectedVars, setUISettings, setInjectedMetadata, @@ -54,6 +56,7 @@ export interface VegaPluginSetupDependencies { export interface VegaPluginStartDependencies { data: DataPublicPluginStart; mapsEms: MapsEmsPluginPublicStart; + dataViews: DataViewsPublicPluginStart; } /** @internal */ @@ -91,9 +94,10 @@ export class VegaPlugin implements Plugin { visualizations.createBaseVisualization(createVegaTypeDefinition()); } - public start(core: CoreStart, { data, mapsEms }: VegaPluginStartDependencies) { + public start(core: CoreStart, { data, mapsEms, dataViews }: VegaPluginStartDependencies) { setNotifications(core.notifications); setData(data); + setDataViews(dataViews); setInjectedMetadata(core.injectedMetadata); setDocLinks(core.docLinks); setMapsEms(mapsEms); diff --git a/src/plugins/vis_types/vega/public/services.ts b/src/plugins/vis_types/vega/public/services.ts index 5f340c1fb972b..af162c204acda 100644 --- a/src/plugins/vis_types/vega/public/services.ts +++ b/src/plugins/vis_types/vega/public/services.ts @@ -9,11 +9,15 @@ import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; import { createGetterSetter } from '../../../kibana_utils/public'; import type { MapsEmsPluginPublicStart } from '../../../maps_ems/public'; export const [getData, setData] = createGetterSetter('Data'); +export const [getDataViews, setDataViews] = + createGetterSetter('DataViews'); + export const [getNotifications, setNotifications] = createGetterSetter('Notifications'); diff --git a/src/plugins/vis_types/vega/public/vega_request_handler.ts b/src/plugins/vis_types/vega/public/vega_request_handler.ts index bf4aeb790c9f1..f632c8e93965c 100644 --- a/src/plugins/vis_types/vega/public/vega_request_handler.ts +++ b/src/plugins/vis_types/vega/public/vega_request_handler.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import type { KibanaExecutionContext } from 'src/core/public'; -import { DataView } from 'src/plugins/data/common'; +import type { DataView } from 'src/plugins/data_views/common'; import { Filter, buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig, TimeRange, Query } from '../../../data/public'; @@ -15,7 +15,7 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; -import { getData, getInjectedMetadata } from './services'; +import { getData, getInjectedMetadata, getDataViews } from './services'; import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { @@ -48,7 +48,8 @@ export function createVegaRequestHandler( searchSessionId, executionContext, }: VegaRequestHandlerParams) { - const { dataViews, search } = getData(); + const { search } = getData(); + const dataViews = getDataViews(); if (!searchAPI) { searchAPI = new SearchAPI( diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js index c6ec924f07d9d..de2e0f57a111b 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js @@ -18,7 +18,7 @@ import { i18n } from '@kbn/i18n'; import { buildQueryFilter, compareFilters } from '@kbn/es-query'; import { TooltipHandler } from './vega_tooltip'; -import { getEnableExternalUrls, getData } from '../services'; +import { getEnableExternalUrls, getDataViews } from '../services'; import { extractIndexPatternsFromSpec } from '../lib/extract_index_pattern'; scheme('elastic', euiPaletteColorBlind()); @@ -156,11 +156,11 @@ export class VegaBaseView { * @returns {Promise} index id */ async findIndex(index) { - const { indexPatterns } = getData(); + const dataViews = getDataViews(); let idxObj; if (index) { - [idxObj] = await indexPatterns.find(index); + [idxObj] = await dataViews.find(index); if (!idxObj) { throw new Error( i18n.translate('visTypeVega.vegaParser.baseView.indexNotFoundErrorMessage', { @@ -175,7 +175,7 @@ export class VegaBaseView { ); if (!idxObj) { - const defaultIdx = await indexPatterns.getDefault(); + const defaultIdx = await dataViews.getDefault(); if (defaultIdx) { idxObj = defaultIdx; diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts index 963fc1751396a..a09e92fe7dd80 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts @@ -16,9 +16,17 @@ import { SearchAPI } from '../../data_model/search_api'; import vegaMap from '../../test_utils/vega_map_test.json'; import { coreMock } from '../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../../data_views/public/mocks'; + import type { IServiceSettings } from '../vega_map_view/service_settings/service_settings_types'; -import { setInjectedVars, setData, setNotifications, setUISettings } from '../../services'; +import { + setInjectedVars, + setData, + setNotifications, + setUISettings, + setDataViews, +} from '../../services'; import { initVegaLayer, initTmsRasterLayer } from './layers'; import { mapboxgl } from '@kbn/mapbox-gl'; @@ -59,6 +67,7 @@ describe('vega_map_view/view', () => { const coreStart = coreMock.createStart(); const dataPluginStart = dataPluginMock.createStartContract(); + const dataViewsStart = dataViewPluginMocks.createStartContract(); const mockGetServiceSettings = async () => { return { getAttributionsFromTMSServce() { @@ -98,6 +107,7 @@ describe('vega_map_view/view', () => { enableExternalUrls: true, }); setData(dataPluginStart); + setDataViews(dataViewsStart); setNotifications(coreStart.notifications); setUISettings(coreStart.uiSettings); @@ -125,7 +135,7 @@ describe('vega_map_view/view', () => { JSON.stringify(vegaMap), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), diff --git a/src/plugins/vis_types/vega/public/vega_visualization.test.js b/src/plugins/vis_types/vega/public/vega_visualization.test.js index 2412e26014e51..af5b6e0d61ada 100644 --- a/src/plugins/vis_types/vega/public/vega_visualization.test.js +++ b/src/plugins/vis_types/vega/public/vega_visualization.test.js @@ -21,12 +21,12 @@ import { SearchAPI } from './data_model/search_api'; import { setInjectedVars, setData, setNotifications } from './services'; import { coreMock } from '../../../../core/public/mocks'; import { dataPluginMock } from '../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../data_views/public/mocks'; jest.mock('./default_spec', () => ({ getDefaultSpec: () => jest.requireActual('./test_utils/default.spec.json'), })); -// FLAKY: https://github.com/elastic/kibana/issues/71713 describe('VegaVisualizations', () => { let domNode; let VegaVisualization; @@ -39,6 +39,7 @@ describe('VegaVisualizations', () => { const coreStart = coreMock.createStart(); const dataPluginStart = dataPluginMock.createStartContract(); + const dataViewsPluginStart = dataViewPluginMocks.createStartContract(); const setupDOM = (width = 512, height = 512) => { mockedWidthValue = width; @@ -94,7 +95,7 @@ describe('VegaVisualizations', () => { JSON.stringify(vegaliteGraph), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), @@ -127,7 +128,7 @@ describe('VegaVisualizations', () => { JSON.stringify(vegaGraph), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), diff --git a/src/plugins/vis_types/vega/tsconfig.json b/src/plugins/vis_types/vega/tsconfig.json index ed7690ac70d1a..ccb4bbfb34454 100644 --- a/src/plugins/vis_types/vega/tsconfig.json +++ b/src/plugins/vis_types/vega/tsconfig.json @@ -17,6 +17,7 @@ "references": [ { "path": "../../../core/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../maps_ems/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, From fadfe2ecc05b35ee3c0ce0810e66f75041ab226c Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Mon, 21 Mar 2022 15:42:06 +0100 Subject: [PATCH 28/38] [Infra UI] Add infrastructure metrics tables (#122352) --- .../infra/public/apps/common_providers.tsx | 11 +- .../README.md | 66 +++++++ .../container_metrics_table.stories.tsx | 94 ++++++++++ .../container_metrics_table.test.tsx | 127 +++++++++++++ .../container/container_metrics_table.tsx | 135 ++++++++++++++ .../create_lazy_container_metrics_table.tsx | 31 ++++ .../container/index.ts | 9 + .../integrated_container_metrics_table.tsx | 37 ++++ .../container/use_container_metrics_table.ts | 126 +++++++++++++ .../host/create_lazy_host_metrics_table.tsx | 29 +++ .../host/host_metrics_table.stories.tsx | 99 ++++++++++ .../host/host_metrics_table.test.tsx | 129 +++++++++++++ .../host/host_metrics_table.tsx | 143 ++++++++++++++ .../host/index.ts | 9 + .../host/integrated_host_metrics_table.tsx | 37 ++++ .../host/use_host_metrics_table.ts | 122 ++++++++++++ .../index.ts | 10 + .../pod/create_lazy_pod_metrics_table.tsx | 29 +++ .../pod/index.ts | 9 + .../pod/integrated_pod_metrics_table.tsx | 37 ++++ .../pod/pod_metrics_table.stories.tsx | 94 ++++++++++ .../pod/pod_metrics_table.test.tsx | 122 ++++++++++++ .../pod/pod_metrics_table.tsx | 133 +++++++++++++ .../pod/use_pod_metrics_table.ts | 123 +++++++++++++ .../shared/components/index.ts | 11 ++ .../components/metrics_node_details_link.tsx | 39 ++++ .../shared/components/number_cell.tsx | 35 ++++ .../shared/components/stepwise_pagination.tsx | 41 +++++ .../shared/components/uptime_cell.tsx | 59 ++++++ .../shared/hooks/index.ts | 11 ++ .../hooks/metrics_to_api_options.test.ts | 90 +++++++++ .../shared/hooks/metrics_to_api_options.ts | 101 ++++++++++ .../hooks/use_infrastructure_node_metrics.ts | 174 ++++++++++++++++++ .../shared/index.ts | 15 ++ .../shared/types.ts | 23 +++ .../test_helpers.ts | 38 ++++ .../public/metrics_overview_fetchers.test.ts | 2 +- x-pack/plugins/infra/public/plugin.ts | 18 +- x-pack/plugins/infra/public/types.ts | 22 ++- .../utils/logs_overview_fetches.test.ts | 2 +- 40 files changed, 2433 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts create mode 100644 x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx index 81852659e6087..cdfe338fa38b3 100644 --- a/x-pack/plugins/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -43,11 +43,18 @@ export const CommonInfraProviders: React.FC<{ ); }; -export const CoreProviders: React.FC<{ +export interface CoreProvidersProps { core: CoreStart; plugins: InfraClientStartDeps; theme$: AppMountParameters['theme$']; -}> = ({ children, core, plugins, theme$ }) => { +} + +export const CoreProviders: React.FC = ({ + children, + core, + plugins, + theme$, +}) => { const { Provider: KibanaContextProviderForPlugin } = useMemo( () => createKibanaContextForPlugin(core, plugins), [core, plugins] diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md new file mode 100644 index 0000000000000..6ab4c8d551e77 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md @@ -0,0 +1,66 @@ +# Infrastructure Node Metrics Tables + +This folder contains components that are used to display metrics about infrastructure components. +Currently there are tables for containers, pods and hosts. + +For use within the Infra plugin, make use of the `useXMetricsTable` hooks and their matching +`XMetricsTable` components. + +For use outside of the Infra plugin, consume these components via the lazy components exposed on the +start contract of the plugin. + +## The shared data hook + +All tables get their data from the Metrics Explorer API by making use of the +`useInfrastructureNodeMetrics` hook. The key input to the hook is the `MetricsMap` which defines +which metrics should be requested (by field and type of aggregation). By passing a `MetricsMap` to +the helper `metricsToApiOptions` we get back the options we need to pass to +`useInfrastructureNodeMetrics`. `metricsToApiOptions` also returns a mapping object that is used to +translate the field name to the format the API returns (e.g. `metric_0`). +The `MetricsMap` type is mostly to ensure that the object key and the `field` value match to avoid +mistakes when re-ordering the metrics being used. + +`useInfrastructureNodeMetrics` also expects a timerange and a filter (in ES DSL) and a function +to transform the Metrics Explorer response to something more suitable for the table component to +work with. + +Internally, the hook manages loading the source, making the API request, sorting and paginating the +response. It also manages the loading state. + +Currently, it does a large request and then does sorting and pagination on the client. In the future +we should replace this with a terms aggregation in the API instead, to do more work in +Elasticsearch. + +## Hooks and tables per node type + +For each node type there is a stateless table and a hook to load the data in the right format for +the table. + +Within the hook file we find the `MetricsMap` definition for each node type and the transformation +function. The transformation function makes use of the `metricByField` to unpack the API response +in a type safe way. +The body of the hook sets up the page and sort state, then invokes the shared data hook. + +The table itself is a fairly simple component that uses EUI components to render a table with +pagination. It makes use of components found in the shared folder for the things that are common +across each node type such as the pagination and Node Details page link. + +When using the hook it is important to wrap the timerange and filter clause DSL parameters in +something like `useMemo` to avoid a re-rendering loop. + +## The embeddable component factories + +To make it as easy as possible to consume these tables we expose them fully integrated on our start +contract. The component that is exposed lazily loads our component, adds in all of our providers +and calls the node type specific hook and passes the result to the node type specific table +component. Integration should be as simple as dropping in the component in a React hierarchy. + +The `createLazyXMetricsTable` factory function accepts our Kibana dependencies and return a new +component that lazily renders our integrated component, capturing our dependencies in a closure +during plugin start. + +The integrated component passes these dependencies to the providers the table needs in context as +well as any props that were passed to the lazy component (such as the time range and filter). +If needed, the lazy component can also accept a property called `sourceId` to modify which Infra +source configuration is used, the default is `default`. +Finally the component calls the node specific hook and renders the node specific table. diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx new file mode 100644 index 0000000000000..44bfa1be3e331 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { ContainerMetricsTable } from './container_metrics_table'; +import type { ContainerMetricsTableProps } from './container_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Container', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: ContainerMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + containers: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + uptime: 23000000, + averageCpuUsagePercent: 99, + averageMemoryUsageMegabytes: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + uptime: 43000000, + averageCpuUsagePercent: 72, + averageMemoryUsageMegabytes: 68, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + uptime: 53000000, + averageCpuUsagePercent: 54, + averageMemoryUsageMegabytes: 132, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + uptime: 63000000, + averageCpuUsagePercent: 34, + averageMemoryUsageMegabytes: 264, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + uptime: 83000000, + averageCpuUsagePercent: 13, + averageMemoryUsageMegabytes: 512, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: ContainerMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx new file mode 100644 index 0000000000000..09e38681062dc --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyContainerMetricsTable } from './create_lazy_container_metrics_table'; +import IntegratedContainerMetricsTable from './integrated_container_metrics_table'; +import { metricByField } from './use_container_metrics_table'; + +describe('ContainerMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'host.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyContainerMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyContainerMetricsTable = createLazyContainerMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('containerMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('containerMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedContainerMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-container/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createContainer('some-container', 23000000, 76, 3671700000), + createContainer('some-other-container', 32000000, 67, 716300000), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createContainer( + name: string, + uptimeMs: number, + cpuUsagePct: number, + memoryUsageBytes: number +) { + return { + id: name, + rows: [ + { + [metricByField['kubernetes.container.start_time']]: uptimeMs, + [metricByField['kubernetes.container.cpu.usage.node.pct']]: cpuUsagePct, + [metricByField['kubernetes.container.memory.usage.bytes']]: memoryUsageBytes, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx new file mode 100644 index 0000000000000..02c7d0501cdef --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared'; +import type { SortState } from '../shared'; +import type { ContainerNodeMetricsRow } from './use_container_metrics_table'; + +export interface ContainerMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + containers: ContainerNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { + const { + timerange, + isLoading, + containers, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => containerNodeColumns(timerange), [timerange]); + + const sortSettings: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = useCallback( + ({ sort }: EuiCriteria) => { + if (!sort) { + return; + } + + setSortState(sort); + setCurrentPageIndex(0); + }, + [setSortState, setCurrentPageIndex] + ); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function containerNodeColumns( + timerange: ContainerMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => { + return ; + }, + }, + { + name: 'Uptime', + field: 'uptime', + align: 'right', + render: (uptime: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory usage(avg.)', + field: 'averageMemoryUsageMegabytes', + align: 'right', + render: (averageMemoryUsageMegabytes: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx new file mode 100644 index 0000000000000..1ca52d2906a11 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedContainerMetricsTable = lazy( + () => import('./integrated_container_metrics_table') +); + +export function createLazyContainerMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts new file mode 100644 index 0000000000000..2497c19bde187 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ContainerMetricsTable } from './container_metrics_table'; +export { useContainerMetricsTable } from './use_container_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx new file mode 100644 index 0000000000000..4fb2101e8e22e --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { ContainerMetricsTable } from './container_metrics_table'; +import { useContainerMetricsTable } from './use_container_metrics_table'; + +function HookedContainerMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const containerMetricsTableProps = useContainerMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function ContainerMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default ContainerMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts new file mode 100644 index 0000000000000..23c95c665aa91 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts @@ -0,0 +1,126 @@ +/* + * 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 { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type ContainerMetricsField = + | 'kubernetes.container.start_time' + | 'kubernetes.container.cpu.usage.node.pct' + | 'kubernetes.container.memory.usage.bytes'; + +const containerMetricsMap: MetricsMap = { + 'kubernetes.container.start_time': { + aggregation: 'max', + field: 'kubernetes.container.start_time', + }, + 'kubernetes.container.cpu.usage.node.pct': { + aggregation: 'avg', + field: 'kubernetes.container.cpu.usage.node.pct', + }, + 'kubernetes.container.memory.usage.bytes': { + aggregation: 'avg', + field: 'kubernetes.container.memory.usage.bytes', + }, +}; + +const { options: containerMetricsOptions, metricByField } = metricsToApiOptions( + containerMetricsMap, + 'container.id' +); +export { metricByField }; + +export interface ContainerNodeMetricsRow { + name: string; + uptime: number | null; + averageCpuUsagePercent: number | null; + averageMemoryUsageMegabytes: number | null; +} + +export function useContainerMetricsTable({ + timerange, + filterClauseDsl, +}: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: containers, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: containerMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToContainerNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + containers, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToContainerNodeMetricsRow(series: MetricsExplorerSeries): ContainerNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; + } + + let uptime: number = 0; + let averageCpuUsagePercent: number = 0; + let averageMemoryUsageMegabytes: number = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + uptime += metricValues.uptime ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + averageMemoryUsageMegabytes += metricValues.averageMemoryUsageMegabytes ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + uptime: uptime / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + averageMemoryUsageMegabytes: Math.floor( + averageMemoryUsageMegabytes / bucketCount / bytesPerMegabyte + ), + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + uptime: row[metricByField['kubernetes.container.start_time']] as number | null, + averageCpuUsagePercent: row[metricByField['kubernetes.container.cpu.usage.node.pct']] as + | number + | null, + averageMemoryUsageMegabytes: row[metricByField['kubernetes.container.memory.usage.bytes']] as + | number + | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx new file mode 100644 index 0000000000000..39980ebf3604b --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedHostMetricsTable = lazy(() => import('./integrated_host_metrics_table')); + +export function createLazyHostMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx new file mode 100644 index 0000000000000..5c80223e31e1c --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx @@ -0,0 +1,99 @@ +/* + * 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 { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { HostMetricsTable } from './host_metrics_table'; +import type { HostMetricsTableProps } from './host_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Host', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: HostMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + hosts: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + cpuCount: 2, + averageCpuUsagePercent: 99, + totalMemoryMegabytes: 1024, + averageMemoryUsagePercent: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + cpuCount: 4, + averageCpuUsagePercent: 74, + totalMemoryMegabytes: 2450, + averageMemoryUsagePercent: 13, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + cpuCount: 8, + averageCpuUsagePercent: 56, + totalMemoryMegabytes: 4810, + averageMemoryUsagePercent: 74, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + cpuCount: 16, + averageCpuUsagePercent: 34, + totalMemoryMegabytes: 8123, + averageMemoryUsagePercent: 56, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + cpuCount: 32, + averageCpuUsagePercent: 13, + totalMemoryMegabytes: 16792, + averageMemoryUsagePercent: 99, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: HostMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx new file mode 100644 index 0000000000000..fd2a010e32321 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx @@ -0,0 +1,129 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyHostMetricsTable } from './create_lazy_host_metrics_table'; +import IntegratedHostMetricsTable from './integrated_host_metrics_table'; +import { metricByField } from './use_host_metrics_table'; + +describe('HostMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'host.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyHostMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyHostMetricsTable = createLazyHostMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('hostMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('hostMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedHostMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-host/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createHost('some-host', 6, 76, 3671700000, 24), + createHost('some-other-host', 12, 67, 7176300000, 42), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createHost( + name: string, + coreCount: number, + cpuUsagePct: number, + memoryBytes: number, + memoryUsagePct: number +) { + return { + id: name, + rows: [ + { + [metricByField['system.cpu.cores']]: coreCount, + [metricByField['system.cpu.total.norm.pct']]: cpuUsagePct, + [metricByField['system.memory.total']]: memoryBytes, + [metricByField['system.memory.used.pct']]: memoryUsagePct, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx new file mode 100644 index 0000000000000..d878fc091722b --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination } from '../shared'; +import type { SortState } from '../shared'; +import type { HostNodeMetricsRow } from './use_host_metrics_table'; + +export interface HostMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + hosts: HostNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const HostMetricsTable = (props: HostMetricsTableProps) => { + const { + timerange, + isLoading, + hosts, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => hostMetricsColumns(timerange), [timerange]); + + const sortSettings: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = useCallback( + ({ sort }: EuiCriteria) => { + if (!sort) { + return; + } + + setSortState(sort); + setCurrentPageIndex(0); + }, + [setSortState, setCurrentPageIndex] + ); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function hostMetricsColumns( + timerange: HostMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => ( + + ), + }, + { + name: '# of CPUs', + field: 'cpuCount', + align: 'right', + render: (cpuCount: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory total (avg.)', + field: 'totalMemoryMegabytes', + align: 'right', + render: (totalMemoryMegabytes: number) => ( + + ), + }, + { + name: 'Memory usage (avg.)', + field: 'averageMemoryUsagePercent', + align: 'right', + render: (averageMemoryUsagePercent: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts new file mode 100644 index 0000000000000..6200127580f96 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { HostMetricsTable } from './host_metrics_table'; +export { useHostMetricsTable } from './use_host_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx new file mode 100644 index 0000000000000..ca274a1ef805f --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { HostMetricsTable } from './host_metrics_table'; +import { useHostMetricsTable } from './use_host_metrics_table'; + +function HookedHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const hostMetricsTableProps = useHostMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function HostMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default HostMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts new file mode 100644 index 0000000000000..dddd5ad03c7b0 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts @@ -0,0 +1,122 @@ +/* + * 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 { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type HostMetricsField = + | 'system.cpu.cores' + | 'system.cpu.total.norm.pct' + | 'system.memory.total' + | 'system.memory.used.pct'; + +const hostMetricsMap: MetricsMap = { + 'system.cpu.cores': { aggregation: 'max', field: 'system.cpu.cores' }, + 'system.cpu.total.norm.pct': { + aggregation: 'avg', + field: 'system.cpu.total.norm.pct', + }, + 'system.memory.total': { aggregation: 'max', field: 'system.memory.total' }, + 'system.memory.used.pct': { + aggregation: 'avg', + field: 'system.memory.used.pct', + }, +}; + +const { options: hostMetricsOptions, metricByField } = metricsToApiOptions( + hostMetricsMap, + 'host.name' +); +export { metricByField }; + +export interface HostNodeMetricsRow { + name: string; + cpuCount: number | null; + averageCpuUsagePercent: number | null; + totalMemoryMegabytes: number | null; + averageMemoryUsagePercent: number | null; +} + +export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: hosts, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: hostMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToHostNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + hosts, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToHostNodeMetricsRow(series: MetricsExplorerSeries): HostNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + cpuCount: null, + averageCpuUsagePercent: null, + totalMemoryMegabytes: null, + averageMemoryUsagePercent: null, + }; + } + + let cpuCount = 0; + let averageCpuUsagePercent = 0; + let totalMemoryMegabytes = 0; + let averageMemoryUsagePercent = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + cpuCount += metricValues.cpuCount ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + totalMemoryMegabytes += metricValues.totalMemoryMegabytes ?? 0; + averageMemoryUsagePercent += metricValues.averageMemoryUsagePercent ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + cpuCount: cpuCount / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + totalMemoryMegabytes: Math.floor(totalMemoryMegabytes / bucketCount / bytesPerMegabyte), + averageMemoryUsagePercent: averageMemoryUsagePercent / bucketCount, + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + cpuCount: row[metricByField['system.cpu.cores']] as number | null, + averageCpuUsagePercent: row[metricByField['system.cpu.total.norm.pct']] as number | null, + totalMemoryMegabytes: row[metricByField['system.memory.total']] as number | null, + averageMemoryUsagePercent: row[metricByField['system.memory.used.pct']] as number | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts new file mode 100644 index 0000000000000..cf6fe4b4d11be --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ContainerMetricsTable, useContainerMetricsTable } from './container'; +export { HostMetricsTable, useHostMetricsTable } from './host'; +export { PodMetricsTable, usePodMetricsTable } from './pod'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx new file mode 100644 index 0000000000000..d24ce323fc2be --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedPodMetricsTable = lazy(() => import('./integrated_pod_metrics_table')); + +export function createLazyPodMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts new file mode 100644 index 0000000000000..ddb679d255dc4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PodMetricsTable } from './pod_metrics_table'; +export { usePodMetricsTable } from './use_pod_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx new file mode 100644 index 0000000000000..5166e984ccb02 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { PodMetricsTable } from './pod_metrics_table'; +import { usePodMetricsTable } from './use_pod_metrics_table'; + +function HookedPodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const podMetricsTableProps = usePodMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function PodMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default PodMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx new file mode 100644 index 0000000000000..50a9a95f8b73e --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { PodMetricsTable } from './pod_metrics_table'; +import type { PodMetricsTableProps } from './pod_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Pod', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: PodMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + pods: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + uptime: 23000000, + averageCpuUsagePercent: 99, + averageMemoryUsageMegabytes: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + uptime: 43000000, + averageCpuUsagePercent: 72, + averageMemoryUsageMegabytes: 68, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + uptime: 53000000, + averageCpuUsagePercent: 54, + averageMemoryUsageMegabytes: 132, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + uptime: 63000000, + averageCpuUsagePercent: 34, + averageMemoryUsageMegabytes: 264, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + uptime: 83000000, + averageCpuUsagePercent: 13, + averageMemoryUsageMegabytes: 512, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: PodMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx new file mode 100644 index 0000000000000..ab4b449f5331b --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx @@ -0,0 +1,122 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyPodMetricsTable } from './create_lazy_pod_metrics_table'; +import IntegratedPodMetricsTable from './integrated_pod_metrics_table'; +import { metricByField } from './use_pod_metrics_table'; + +describe('PodMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'pod.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyPodMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyPodMetricsTable = createLazyPodMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('podMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('podMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedPodMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-pod/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createPod('some-pod', 23000000, 76, 3671700000), + createPod('some-other-pod', 32000000, 67, 716300000), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createPod(name: string, uptimeMs: number, cpuUsagePct: number, memoryUsageBytes: number) { + return { + id: name, + rows: [ + { + [metricByField['kubernetes.pod.start_time']]: uptimeMs, + [metricByField['kubernetes.pod.cpu.usage.node.pct']]: cpuUsagePct, + [metricByField['kubernetes.pod.memory.usage.bytes']]: memoryUsageBytes, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx new file mode 100644 index 0000000000000..3739d6b468292 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared'; +import type { SortState } from '../shared'; +import type { PodNodeMetricsRow } from './use_pod_metrics_table'; + +export interface PodMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + pods: PodNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const PodMetricsTable = (props: PodMetricsTableProps) => { + const { + timerange, + isLoading, + pods, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => podNodeColumns(timerange), [timerange]); + + const sorting: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = ({ + sort = { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + }: EuiCriteria) => { + setSortState(sort); + setCurrentPageIndex(0); + }; + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function podNodeColumns( + timerange: PodMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => { + return ; + }, + }, + { + name: 'Uptime', + field: 'uptime', + align: 'right', + render: (uptime: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory usage (avg.)', + field: 'averageMemoryUsageMegabytes', + align: 'right', + render: (averageMemoryUsageMegabytes: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts new file mode 100644 index 0000000000000..004ab2ab3ffff --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type PodMetricsField = + | 'kubernetes.pod.start_time' + | 'kubernetes.pod.cpu.usage.node.pct' + | 'kubernetes.pod.memory.usage.bytes'; + +const podMetricsMap: MetricsMap = { + 'kubernetes.pod.start_time': { + aggregation: 'max', + field: 'kubernetes.pod.start_time', + }, + 'kubernetes.pod.cpu.usage.node.pct': { + aggregation: 'avg', + field: 'kubernetes.pod.cpu.usage.node.pct', + }, + 'kubernetes.pod.memory.usage.bytes': { + aggregation: 'avg', + field: 'kubernetes.pod.memory.usage.bytes', + }, +}; + +const { options: podMetricsOptions, metricByField } = metricsToApiOptions( + podMetricsMap, + 'kubernetes.pod.name' +); +export { metricByField }; + +export interface PodNodeMetricsRow { + name: string; + uptime: number | null; + averageCpuUsagePercent: number | null; + averageMemoryUsageMegabytes: number | null; +} + +export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: pods, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: podMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToPodNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + pods, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToPodNodeMetricsRow(series: MetricsExplorerSeries): PodNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; + } + + let uptime: number = 0; + let averageCpuUsagePercent: number = 0; + let averageMemoryUsagePercent: number = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + uptime += metricValues.uptime ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + averageMemoryUsagePercent += metricValues.averageMemoryUsageMegabytes ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + uptime: uptime / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + averageMemoryUsageMegabytes: Math.floor( + averageMemoryUsagePercent / bucketCount / bytesPerMegabyte + ), + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + uptime: row[metricByField['kubernetes.pod.start_time']] as number | null, + averageCpuUsagePercent: row[metricByField['kubernetes.pod.cpu.usage.node.pct']] as + | number + | null, + averageMemoryUsageMegabytes: row[metricByField['kubernetes.pod.memory.usage.bytes']] as + | number + | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts new file mode 100644 index 0000000000000..fa4398a279e86 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsNodeDetailsLink } from './metrics_node_details_link'; +export { NumberCell } from './number_cell'; +export { StepwisePagination } from './stepwise_pagination'; +export { UptimeCell } from './uptime_cell'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx new file mode 100644 index 0000000000000..b51e1bc8b7707 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parse } from '@elastic/datemath'; +import { EuiLink } from '@elastic/eui'; +import React from 'react'; +import { useLinkProps } from '../../../../../../observability/public'; +import type { InventoryItemType } from '../../../../../common/inventory_models/types'; +import { getNodeDetailUrl } from '../../../../pages/link_to'; +import type { MetricsExplorerTimeOptions } from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +type ExtractStrict = Extract; + +interface MetricsNodeDetailsLinkProps { + id: string; + nodeType: ExtractStrict; + timerange: Pick; +} + +export const MetricsNodeDetailsLink = ({ + id, + nodeType, + timerange, +}: MetricsNodeDetailsLinkProps) => { + const linkProps = useLinkProps( + getNodeDetailUrl({ + nodeType, + nodeId: id, + from: parse(timerange.from)?.valueOf(), + to: parse(timerange.to)?.valueOf(), + }) + ); + + return {id}; +}; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx new file mode 100644 index 0000000000000..368484b4af43d --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiI18nNumber, EuiTextColor } from '@elastic/eui'; +import React from 'react'; + +interface NumberCellProps { + value?: number; + unit?: string; +} + +export function NumberCell({ value, unit }: NumberCellProps) { + if (value === null || value === undefined || isNaN(value)) { + return N/A; + } + + if (!unit) { + return ; + } + + return ( + + + {unit} + + ); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx new file mode 100644 index 0000000000000..71305061ccb55 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPagination } from '@elastic/eui'; +import React from 'react'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; + +interface CursorPaginationProps { + ariaLabel: string; + currentPageIndex: number; + pageCount: number; + setCurrentPageIndex: (nextPageIndex: number) => void; +} + +const EuiStepwisePagination = euiStyled(EuiPagination)` + [data-test-subj="pagination-button-first"], + [data-test-subj="pagination-button-last"] { + display: none; + } +`; + +export function StepwisePagination({ + ariaLabel, + pageCount, + currentPageIndex, + setCurrentPageIndex, +}: CursorPaginationProps) { + return ( + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx new file mode 100644 index 0000000000000..80b2ba4a755e1 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiTextColor } from '@elastic/eui'; +import React from 'react'; + +interface UptimeCellProps { + uptimeMs?: number; +} + +export function UptimeCell({ uptimeMs }: UptimeCellProps) { + if (uptimeMs === null || uptimeMs === undefined || isNaN(uptimeMs)) { + return N/A; + } + + return {formatUptime(uptimeMs)}; +} + +const MS_PER_MINUTE = 1000 * 60; +const MS_PER_HOUR = MS_PER_MINUTE * 60; +const MS_PER_DAY = MS_PER_HOUR * 24; + +function formatUptime(uptimeMs: number): string { + if (uptimeMs < MS_PER_HOUR) { + const minutes = Math.floor(uptimeMs / MS_PER_MINUTE); + + if (minutes > 0) { + return `${minutes}m`; + } + + return '< a minute'; + } + + if (uptimeMs < MS_PER_DAY) { + const hours = Math.floor(uptimeMs / MS_PER_HOUR); + const remainingUptimeMs = uptimeMs - hours * MS_PER_HOUR; + const minutes = Math.floor(remainingUptimeMs / MS_PER_MINUTE); + + if (minutes > 0) { + return `${hours}h ${minutes}m`; + } + + return `${hours}h`; + } + + const days = Math.floor(uptimeMs / MS_PER_DAY); + const remainingUptimeMs = uptimeMs - days * MS_PER_DAY; + const hours = Math.floor(remainingUptimeMs / MS_PER_HOUR); + + if (hours > 0) { + return `${days}d ${hours}h`; + } + + return `${days}d`; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts new file mode 100644 index 0000000000000..b3e04f1122998 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { metricsToApiOptions } from './metrics_to_api_options'; +export type { MetricsMap } from './metrics_to_api_options'; +export { useInfrastructureNodeMetrics } from './use_infrastructure_node_metrics'; +export type { SortState } from './use_infrastructure_node_metrics'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts new file mode 100644 index 0000000000000..da4ccc45ebf7d --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MetricsMap } from './metrics_to_api_options'; +import { metricsToApiOptions } from './metrics_to_api_options'; + +describe('metricsToApiOptions', () => { + type TestNodeTypeMetricsField = 'test.node.type.field1' | 'test.node.type.field2'; + + const testMetricsMapField1First: MetricsMap = { + 'test.node.type.field1': { + aggregation: 'max', + field: 'test.node.type.field1', + }, + 'test.node.type.field2': { + aggregation: 'avg', + field: 'test.node.type.field2', + }, + }; + + const testMetricsMapField1Second: MetricsMap = { + 'test.node.type.field2': { + aggregation: 'avg', + field: 'test.node.type.field2', + }, + 'test.node.type.field1': { + aggregation: 'max', + field: 'test.node.type.field1', + }, + }; + + const fields = ['test.node.type.field1', 'test.node.type.field2']; + + it('should join the grouping field with the metrics in the APIs expected format', () => { + const { options } = metricsToApiOptions( + testMetricsMapField1First, + 'test.node.type.groupingField' + ); + expect(options).toEqual({ + aggregation: 'avg', + groupBy: 'test.node.type.groupingField', + metrics: [ + { + field: 'test.node.type.field1', + aggregation: 'max', + }, + { + field: 'test.node.type.field2', + aggregation: 'avg', + }, + ], + }); + }); + + it('should provide a mapping object that allows consumer to ignore metric definition order', () => { + const field1First = metricsToApiOptions( + testMetricsMapField1First, + 'test.node.type.groupingField' + ); + + assertListContentIsEqual(Object.keys(field1First.metricByField), fields); + expect(field1First.metricByField).toEqual({ + 'test.node.type.field1': 'metric_0', + 'test.node.type.field2': 'metric_1', + }); + + const field1Second = metricsToApiOptions( + testMetricsMapField1Second, + 'test.node.type.groupingField' + ); + + assertListContentIsEqual(Object.keys(field1Second.metricByField), fields); + expect(field1Second.metricByField).toEqual({ + 'test.node.type.field1': 'metric_1', + 'test.node.type.field2': 'metric_0', + }); + }); + + function assertListContentIsEqual(firstList: string[], secondList: string[]) { + const firstListAsSet = new Set(firstList); + const secondListAsSet = new Set(secondList); + + expect(firstListAsSet).toEqual(secondListAsSet); + expect(firstList.length).toBe(secondList.length); + } +}); diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts new file mode 100644 index 0000000000000..23d6383a303da --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + MetricsExplorerOptions, + MetricsExplorerOptionsMetric, +} from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +/* +They key part of this file is the function 'createFieldLookup'. + +The metrics_explorer endpoint expects a list of the metrics to use, like this: +[ + { field: 'some.metric.field', aggregation: 'avg' }, + { field: 'some.other.metric.field', aggregation: 'min' }, +] + +The API then responds with a series, which is a list of rows (buckets from a date_histogram +aggregation), where each bucket has this format: +{ metric_0: 99, metric_1: 88 } + +For each metric in the request, a key like metric_X is defined, and the number used is the order in +which the metric appeared in the request. So if the metric for 'some.metric.field' is first, it'll +be mapped to metric_0, but if the code changes and it is now second, it will be mapped to metric_1. + +This makes the code that consumes the API response fragile to such re-ordering, the types and +functions in this file are used to reduce this fragility and allowing consuming code to reference +the metrics by their field names instead. +The returned metricByField object, handles the translation from field name to "index name". +For example, in the transform function passed to useInfrastructureNodeMetrics it can be used +to find a field metric like this: +row[metricByField['kubernetes.container.start_time']] + +If the endpoint where to change its return format to: +{ 'some.metric.field': 99, 'some.other.metric.field': 88 } +Then this code would no longer be needed. +*/ + +// The input to this generic type is a (union) string type that defines all the fields we want to +// request metrics for. This input type serves as something like a "source of truth" for which +// fields are being used. The resulting MetricsMap and metricByField helper ensures a type safe +// usage of the metrics data returned from the API. +export type MetricsMap = { + [field in T]: NodeMetricsExplorerOptionsMetric; +}; + +// MetricsMap uses an object type to ensure each field gets defined. +// This type only ensures that the MetricsMap is defined in a way that the key matches the field +// it uses +// { 'some-field: { field: 'some-field', aggregation: 'whatever' } } +export interface NodeMetricsExplorerOptionsMetric + extends Omit { + field: Field; +} + +export function metricsToApiOptions(metricsMap: MetricsMap, groupBy: string) { + const metrics = Object.values(metricsMap) as Array>; + + const options: MetricsExplorerOptions = { + aggregation: 'avg', + groupBy, + metrics, + }; + + const metricByField = createFieldLookup(Object.keys(metricsMap) as T[], metrics); + + return { + options, + metricByField, + }; +} + +function createFieldLookup( + fields: T[], + metrics: Array> +) { + const setMetricIndexToField = (acc: Record, field: T) => { + return { + ...acc, + [field]: fieldToMetricIndex(field, metrics), + }; + }; + return fields.reduce(setMetricIndexToField, {} as Record); +} + +function fieldToMetricIndex( + field: T, + metrics: Array> +) { + const index = metrics.findIndex((metric) => metric.field === field); + + if (index === -1) { + throw new Error('Failed to find index for field ' + field); + } + + return `metric_${index}`; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts new file mode 100644 index 0000000000000..47e4fd86f04e2 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.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 { parse } from '@elastic/datemath'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useEffect, useMemo, useState } from 'react'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import type { + MetricsExplorerResponse, + MetricsExplorerSeries, +} from '../../../../../common/http_api/metrics_explorer'; +import { useSourceContext } from '../../../../containers/metrics_source'; +import type { + MetricsExplorerOptions, + MetricsExplorerTimeOptions, +} from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { useTrackedPromise } from '../../../../utils/use_tracked_promise'; + +export interface SortState { + field: keyof T; + direction: 'asc' | 'desc'; +} + +interface UseInfrastructureNodeMetricsOptions { + metricsExplorerOptions: MetricsExplorerOptions; + timerange: Pick; + filterClauseDsl?: QueryDslQueryContainer; + transform: (series: MetricsExplorerSeries) => T; + sortState: SortState; + currentPageIndex: number; +} + +const NODE_COUNT_LIMIT = 10000; +const TOTAL_NODES_LIMIT = 100; +const TABLE_PAGE_SIZE = 10; +const nullData: MetricsExplorerResponse = { + series: [], + pageInfo: { + afterKey: null, + total: -1, + }, +}; + +export const useInfrastructureNodeMetrics = ( + options: UseInfrastructureNodeMetricsOptions +) => { + const { + metricsExplorerOptions, + timerange, + filterClauseDsl, + transform, + sortState, + currentPageIndex, + } = options; + + const [transformedNodes, setTransformedNodes] = useState([]); + const fetch = useKibanaHttpFetch(); + const { source, isLoadingSource } = useSourceContext(); + const timerangeWithInterval = useTimerangeWithInterval(timerange); + + const [{ state: promiseState }, fetchNodes] = useTrackedPromise( + { + createPromise: (): Promise => { + if (!source) { + return Promise.resolve(nullData); + } + + const request = { + metrics: metricsExplorerOptions.metrics, + groupBy: metricsExplorerOptions.groupBy, + limit: NODE_COUNT_LIMIT, + indexPattern: source.configuration.metricAlias, + filterQuery: JSON.stringify(filterClauseDsl), + timerange: timerangeWithInterval, + }; + + return fetch('/api/infra/metrics_explorer', { + method: 'POST', + body: JSON.stringify(request), + }); + }, + onResolve: (response: MetricsExplorerResponse) => { + setTransformedNodes(response.series.map(transform)); + }, + onReject: (error) => { + // What to do about this? + // eslint-disable-next-line no-console + console.log(error); + }, + cancelPreviousOn: 'creation', + }, + [source, metricsExplorerOptions, timerangeWithInterval, filterClauseDsl] + ); + const isLoadingNodes = promiseState === 'pending' || promiseState === 'uninitialized'; + + useEffect(() => { + fetchNodes(); + }, [fetchNodes]); + + const sortedNodes = useMemo(() => { + return [...transformedNodes].sort(makeSortNodes(sortState)); + }, [transformedNodes, sortState]); + + const top100Nodes = useMemo(() => { + return sortedNodes.slice(0, TOTAL_NODES_LIMIT); + }, [sortedNodes]); + + const nodes = useMemo(() => { + const pageStartIndex = currentPageIndex * TABLE_PAGE_SIZE; + const pageEndIndex = pageStartIndex + TABLE_PAGE_SIZE; + return top100Nodes.slice(pageStartIndex, pageEndIndex); + }, [top100Nodes, currentPageIndex]); + + const pageCount = useMemo(() => Math.ceil(top100Nodes.length / TABLE_PAGE_SIZE), [top100Nodes]); + + return { + isLoading: isLoadingSource || isLoadingNodes, + nodes, + pageCount, + }; +}; + +function useKibanaHttpFetch() { + const kibana = useKibana(); + const fetch = kibana.services.http?.fetch; + + if (!fetch) { + throw new Error('Could not find Kibana HTTP fetch'); + } + + return fetch; +} + +function useTimerangeWithInterval(timerange: Pick) { + return useMemo(() => { + const from = parse(timerange.from); + const to = parse(timerange.to); + + if (!from || !to) { + throw new Error('Could not parse timerange'); + } + + return { from: from.valueOf(), to: to.valueOf(), interval: 'modules' }; + }, [timerange]); +} + +function makeSortNodes(sortState: SortState) { + return (nodeA: T, nodeB: T) => { + const nodeAValue = nodeA[sortState.field]; + const nodeBValue = nodeB[sortState.field]; + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + if (sortState.direction === 'asc') { + return nodeAValue.localeCompare(nodeBValue); + } else { + return nodeBValue.localeCompare(nodeAValue); + } + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + if (sortState.direction === 'asc') { + return nodeAValue - nodeBValue; + } else { + return nodeBValue - nodeAValue; + } + } + + return 0; + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts new file mode 100644 index 0000000000000..8c74b28764d35 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from './components'; +export { metricsToApiOptions, useInfrastructureNodeMetrics } from './hooks'; +export type { MetricsMap, SortState } from './hooks'; +export type { + IntegratedNodeMetricsTableProps, + SourceProviderProps, + UseNodeMetricsTableOptions, +} from './types'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts new file mode 100644 index 0000000000000..5ab363dc7fafd --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { MetricsExplorerTimeOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +export interface UseNodeMetricsTableOptions { + timerange: Pick; + filterClauseDsl?: QueryDslQueryContainer; +} + +export interface SourceProviderProps { + sourceId: string; +} + +export type IntegratedNodeMetricsTableProps = UseNodeMetricsTableOptions & + SourceProviderProps & + CoreProvidersProps; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts new file mode 100644 index 0000000000000..3a53e00647999 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DeepPartial } from 'utility-types'; +import type { HttpFetchOptions } from '../../../../../../src/core/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import type { MetricsExplorerResponse } from '../../../common/http_api/metrics_explorer'; +import type { MetricsSourceConfigurationResponse } from '../../../common/metrics_sources'; +import type { CoreProvidersProps } from '../../apps/common_providers'; +import type { InfraClientStartDeps } from '../../types'; + +export type SourceResponseMock = DeepPartial; +export type DataResponseMock = DeepPartial; +export type NodeMetricsTableFetchMock = ( + path: string, + options: HttpFetchOptions +) => Promise; + +export function createCoreProvidersPropsMock(fetchMock: NodeMetricsTableFetchMock) { + const core = coreMock.createStart(); + // @ts-expect-error core.http.fetch has overloads, Jest/TypeScript only picks the first definition when mocking + core.http.fetch.mockImplementation(fetchMock); + + const coreProvidersPropsMock: CoreProvidersProps = { + core, + plugins: {} as InfraClientStartDeps, + theme$: core.theme.theme$, + }; + + return { + coreProvidersPropsMock, + fetch: core.http.fetch, + }; +} diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 40c6d61183228..806947b1e5c3f 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -19,7 +19,7 @@ function setup() { return Promise.resolve([ core as CoreStart, deps as InfraClientStartDeps, - void 0 as InfraClientStartExports, + {} as InfraClientStartExports, ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; }); return { core, mockedGetStartServices }; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 1eb016f582939..6a125c75ab396 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -13,6 +13,10 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { createInventoryMetricRuleType } from './alerting/inventory'; import { createLogThresholdRuleType } from './alerting/log_threshold'; import { createMetricThresholdRuleType } from './alerting/metric_threshold'; +import type { CoreProvidersProps } from './apps/common_providers'; +import { createLazyContainerMetricsTable } from './components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table'; +import { createLazyHostMetricsTable } from './components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table'; +import { createLazyPodMetricsTable } from './components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table'; import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; @@ -204,7 +208,19 @@ export class Plugin implements InfraClientPluginClass { }); } - start(_core: InfraClientCoreStart, _plugins: InfraClientStartDeps) {} + start(core: InfraClientCoreStart, plugins: InfraClientStartDeps) { + const coreProvidersProps: CoreProvidersProps = { + core, + plugins, + theme$: core.theme.theme$, + }; + + return { + ContainerMetricsTable: createLazyContainerMetricsTable(coreProvidersProps), + HostMetricsTable: createLazyHostMetricsTable(coreProvidersProps), + PodMetricsTable: createLazyPodMetricsTable(coreProvidersProps), + }; + } stop() {} } diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index d304309f27802..8c0033c1b79e5 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -8,8 +8,8 @@ import type { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; import { IHttpFetchError } from 'src/core/public'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import type { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; import type { UsageCollectionSetup, UsageCollectionStart, @@ -19,18 +19,32 @@ import type { TriggersAndActionsUIPublicPluginStart, } from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; +import { MlPluginSetup, MlPluginStart } from '../../ml/public'; import type { ObservabilityPublicSetup, ObservabilityPublicStart, } from '../../observability/public'; // import type { OsqueryPluginStart } from '../../osquery/public'; import type { SpacesPluginStart } from '../../spaces/public'; -import { MlPluginStart, MlPluginSetup } from '../../ml/public'; -import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { + SourceProviderProps, + UseNodeMetricsTableOptions, +} from './components/infrastructure_node_metrics_tables/shared'; // Our own setup and start contract values export type InfraClientSetupExports = void; -export type InfraClientStartExports = void; + +export interface InfraClientStartExports { + ContainerMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; + HostMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; + PodMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; +} export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts index 8a1920f534cd6..d57dc5690e9c2 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts @@ -87,7 +87,7 @@ function setup() { return Promise.resolve([ core as CoreStart, deps as InfraClientStartDeps, - void 0 as InfraClientStartExports, + {} as InfraClientStartExports, ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; }); return { core, mockedGetStartServices, dataResponder }; From b80aa878cfed6123048b211c45a7c67458655391 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 21 Mar 2022 15:00:27 +0000 Subject: [PATCH 29/38] skip flaky suite (#127905) --- test/functional/apps/discover/_field_data_with_fields_api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index f0dedb155fc9b..402783694cbd5 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -34,7 +34,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FLAKY: https://github.com/elastic/kibana/issues/127905 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { From 2c6c9352b6e16fc6c638a005110024d536342dd1 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 21 Mar 2022 11:02:31 -0400 Subject: [PATCH 30/38] [Fleet] Do not allow to use logstash output with APM integration (#127809) --- .../hooks.test.tsx | 197 +++++++++++++++++- .../agent_policy_advanced_fields/hooks.tsx | 110 ++++++++-- .../agent_policy_advanced_fields/index.tsx | 2 +- .../step_select_agent_policy.test.tsx | 18 +- .../step_select_agent_policy.tsx | 168 ++++++++++----- .../step_select_hosts.test.tsx | 15 +- x-pack/plugins/fleet/server/errors/index.ts | 2 + .../server/services/agent_policies/index.ts | 2 +- ..._policy.test.ts => output_helpers.test.ts} | 79 ++++++- .../agent_policies/outputs_helpers.ts | 84 ++++++++ .../validate_outputs_for_policy.ts | 48 ----- .../fleet/server/services/agent_policy.ts | 34 ++- .../fleet/server/services/output.test.ts | 42 +++- .../plugins/fleet/server/services/output.ts | 56 ++++- .../fleet/server/services/package_policy.ts | 13 +- 15 files changed, 710 insertions(+), 160 deletions(-) rename x-pack/plugins/fleet/server/services/agent_policies/{validate_outputs_for_policy.test.ts => output_helpers.test.ts} (65%) create mode 100644 x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts delete mode 100644 x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx index 88072b327d9f2..874f60a604bfe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx @@ -9,6 +9,7 @@ import { createFleetTestRendererMock } from '../../../../../../mock'; import type { MockedFleetStartServices } from '../../../../../../mock'; import { useLicense } from '../../../../../../hooks/use_license'; import type { LicenseService } from '../../../../services'; +import type { AgentPolicy } from '../../../../types'; import { useOutputOptions } from './hooks'; @@ -44,12 +45,44 @@ const mockApiCallsWithOutputs = (http: MockedFleetStartServices['http']) => { { id: 'output2', name: 'Output 2', - is_default: true, - is_default_monitoring: true, + is_default: false, + is_default_monitoring: false, }, { id: 'output3', name: 'Output 3', + is_default: false, + is_default_monitoring: false, + }, + ], + }, + }; + } + + return defaultHttpClientGetImplementation(path); + }); +}; + +const mockApiCallsWithLogstashOutputs = (http: MockedFleetStartServices['http']) => { + http.get.mockImplementation(async (path) => { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + if (path === '/api/fleet/outputs') { + return { + data: { + items: [ + { + id: 'elasticsearch1', + name: 'Elasticsearch1', + is_default: false, + type: 'elasticsearch', + is_default_monitoring: false, + }, + { + id: 'logstash1', + name: 'Logstash 1', + type: 'logstash', is_default: true, is_default_monitoring: true, }, @@ -69,13 +102,16 @@ describe('useOutputOptions', () => { hasAtLeast: () => true, } as unknown as LicenseService); mockApiCallsWithOutputs(testRenderer.startServices.http); - const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); expect(result.current.isLoading).toBeTruthy(); await waitForNextUpdate(); expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": false, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -99,6 +135,7 @@ describe('useOutputOptions', () => { expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": undefined, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -127,13 +164,16 @@ describe('useOutputOptions', () => { hasAtLeast: () => false, } as unknown as LicenseService); mockApiCallsWithOutputs(testRenderer.startServices.http); - const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); expect(result.current.isLoading).toBeTruthy(); await waitForNextUpdate(); expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": false, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -157,6 +197,7 @@ describe('useOutputOptions', () => { expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": undefined, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -178,4 +219,152 @@ describe('useOutputOptions', () => { ] `); }); + + it('should enable logstash output if there is no APM integration in the policy', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithLogstashOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": false, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": undefined, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + }); + + it('should not enable logstash output if there is an APM integration in the policy', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithLogstashOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({ + package_policies: [ + { + package: { + name: 'apm', + }, + }, + ], + } as AgentPolicy) + ); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": true, + "inputDisplay": + + Default (currently Logstash 1) + + + + + + , + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": true, + "inputDisplay": + + Logstash 1 + + + + + + , + "value": "logstash1", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": undefined, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx index b092223879994..47c2db3db05a9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx @@ -5,52 +5,107 @@ * 2.0. */ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import type { EuiSuperSelectOption } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; import { useGetOutputs, useLicense } from '../../../../hooks'; -import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../../../../../common'; +import { + LICENCE_FOR_PER_POLICY_OUTPUT, + FLEET_APM_PACKAGE, + outputType, +} from '../../../../../../../common'; +import type { NewAgentPolicy, AgentPolicy } from '../../../../types'; // The super select component do not support null or '' as a value export const DEFAULT_OUTPUT_VALUE = '@@##DEFAULT_OUTPUT_VALUE##@@'; -function getDefaultOutput(defaultOutputName?: string) { +function getOutputLabel(name: string, disabledMessage?: React.ReactNode) { + if (!disabledMessage) { + return name; + } + + return ( + <> + {name} + + {disabledMessage} + + ); +} + +function getDefaultOutput( + defaultOutputName?: string, + defaultOutputDisabled?: boolean, + defaultOutputDisabledMessage?: React.ReactNode +) { return { - inputDisplay: i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { - defaultMessage: 'Default (currently {defaultOutputName})', - values: { defaultOutputName }, - }), + inputDisplay: getOutputLabel( + i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { + defaultMessage: 'Default (currently {defaultOutputName})', + values: { defaultOutputName }, + }), + defaultOutputDisabledMessage + ), value: DEFAULT_OUTPUT_VALUE, + disabled: defaultOutputDisabled, }; } -export function useOutputOptions() { +export function useOutputOptions(agentPolicy: Partial) { const outputsRequest = useGetOutputs(); const licenseService = useLicense(); const isLicenceAllowingPolicyPerOutput = licenseService.hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + const isAgentPolicyUsingAPM = + 'package_policies' in agentPolicy && + agentPolicy.package_policies?.some((packagePolicy) => { + return typeof packagePolicy !== 'string' && packagePolicy.package?.name === FLEET_APM_PACKAGE; + }); - const outputOptions: Array> = useMemo(() => { + const dataOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { return []; } - return outputsRequest.data.items.map((item) => ({ - value: item.id, - inputDisplay: item.name, - disabled: !isLicenceAllowingPolicyPerOutput, - })); - }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); - - const dataOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { return []; } - const defaultOutputName = outputsRequest.data.items.find((item) => item.is_default)?.name; - return [getDefaultOutput(defaultOutputName), ...outputOptions]; - }, [outputsRequest, outputOptions]); + const defaultOutput = outputsRequest.data.items.find((item) => item.is_default); + const defaultOutputName = defaultOutput?.name; + const defaultOutputDisabled = + isAgentPolicyUsingAPM && defaultOutput?.type === outputType.Logstash; + + const defaultOutputDisabledMessage = defaultOutputDisabled ? ( + + ) : undefined; + + return [ + getDefaultOutput(defaultOutputName, defaultOutputDisabled, defaultOutputDisabledMessage), + ...outputsRequest.data.items.map((item) => { + const isLogstashOutputWithAPM = isAgentPolicyUsingAPM && item.type === outputType.Logstash; + + return { + value: item.id, + inputDisplay: getOutputLabel( + item.name, + isLogstashOutputWithAPM ? ( + + ) : undefined + ), + disabled: !isLicenceAllowingPolicyPerOutput || isLogstashOutputWithAPM, + }; + }), + ]; + }, [outputsRequest, isLicenceAllowingPolicyPerOutput, isAgentPolicyUsingAPM]); const monitoringOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { @@ -60,8 +115,17 @@ export function useOutputOptions() { const defaultOutputName = outputsRequest.data.items.find( (item) => item.is_default_monitoring )?.name; - return [getDefaultOutput(defaultOutputName), ...outputOptions]; - }, [outputsRequest, outputOptions]); + return [ + getDefaultOutput(defaultOutputName), + ...outputsRequest.data.items.map((item) => { + return { + value: item.id, + inputDisplay: item.name, + disabled: !isLicenceAllowingPolicyPerOutput, + }; + }), + ]; + }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); return useMemo( () => ({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 305008513d019..1ba7f09d0333d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -56,7 +56,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = dataOutputOptions, monitoringOutputOptions, isLoading: isLoadingOptions, - } = useOutputOptions(); + } = useOutputOptions(agentPolicy); // agent monitoring checkbox group can appear multiple times in the DOM, ids have to be unique to work correctly const monitoringCheckboxIdSuffix = Date.now(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx index 9ef31b596e652..a4c66802e20cb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx @@ -19,6 +19,13 @@ jest.mock('../../../hooks', () => { return { ...jest.requireActual('../../../hooks'), useGetAgentPolicies: jest.fn(), + useGetOutputs: jest.fn().mockResolvedValue({ + data: [], + isLoading: false, + }), + sendGetOneAgentPolicy: jest.fn().mockResolvedValue({ + data: { item: { id: 'policy-1' } }, + }), useFleetStatus: jest.fn().mockReturnValue({ isReady: true } as any), sendGetFleetStatus: jest .fn() @@ -34,12 +41,13 @@ describe('step select agent policy', () => { let testRenderer: TestRenderer; let renderResult: ReturnType; const mockSetHasAgentPolicyError = jest.fn(); + const updateAgentPolicyMock = jest.fn(); const render = () => (renderResult = testRenderer.render( @@ -47,6 +55,7 @@ describe('step select agent policy', () => { beforeEach(() => { testRenderer = createFleetTestRendererMock(); + updateAgentPolicyMock.mockReset(); }); test('should not select agent policy by default if multiple exists', async () => { @@ -68,7 +77,6 @@ describe('step select agent policy', () => { const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); expect((select as any)?.value).toEqual(''); - expect(renderResult.getAllByRole('option').length).toBe(2); expect(renderResult.getByText('An agent policy is required.')).toBeVisible(); }); }); @@ -82,10 +90,10 @@ describe('step select agent policy', () => { } as any); render(); - + await act(async () => {}); // Needed as updateAgentPolicy is called after multiple useEffect await act(async () => { - const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); - expect((select as any)?.value).toEqual('policy-1'); + expect(updateAgentPolicyMock).toBeCalled(); + expect(updateAgentPolicyMock).toBeCalledWith({ id: 'policy-1' }); }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx index 8558fbecbde0f..317c327fa675e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx @@ -9,8 +9,8 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { EuiSelectOption } from '@elastic/eui'; -import { EuiSelect } from '@elastic/eui'; +import type { EuiSuperSelectOption } from '@elastic/eui'; +import { EuiSuperSelect } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, @@ -18,12 +18,24 @@ import { EuiDescribedFormGroup, EuiTitle, EuiText, + EuiSpacer, } from '@elastic/eui'; import { Error } from '../../../components'; -import type { AgentPolicy, PackageInfo, GetAgentPoliciesResponseItem } from '../../../types'; +import type { + AgentPolicy, + Output, + PackageInfo, + GetAgentPoliciesResponseItem, +} from '../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../services'; -import { useGetAgentPolicies, sendGetOneAgentPolicy, useFleetStatus } from '../../../hooks'; +import { + useGetAgentPolicies, + useGetOutputs, + sendGetOneAgentPolicy, + useFleetStatus, +} from '../../../hooks'; +import { FLEET_APM_PACKAGE, outputType } from '../../../../../../common'; const AgentPolicyFormRow = styled(EuiFormRow)` .euiFormRow__label { @@ -31,23 +43,7 @@ const AgentPolicyFormRow = styled(EuiFormRow)` } `; -export const StepSelectAgentPolicy: React.FunctionComponent<{ - packageInfo?: PackageInfo; - agentPolicy: AgentPolicy | undefined; - updateAgentPolicy: (agentPolicy: AgentPolicy | undefined) => void; - setHasAgentPolicyError: (hasError: boolean) => void; - selectedAgentPolicyId?: string; -}> = ({ - packageInfo, - agentPolicy, - updateAgentPolicy, - setHasAgentPolicyError, - selectedAgentPolicyId, -}) => { - const { isReady: isFleetReady } = useFleetStatus(); - - const [selectedAgentPolicyError, setSelectedAgentPolicyError] = useState(); - +function useAgentPoliciesOptions(packageInfo?: PackageInfo) { // Fetch agent policies info const { data: agentPoliciesData, @@ -72,21 +68,104 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ }, {}); }, [agentPolicies]); + const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); + + const { getDataOutputForPolicy } = useMemo(() => { + const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); + const outputsById = (outputsData?.items ?? []).reduce( + (acc: { [key: string]: Output }, output) => { + acc[output.id] = output; + return acc; + }, + {} + ); + + return { + getDataOutputForPolicy: (policy: AgentPolicy) => { + return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; + }, + }; + }, [outputsData]); + + const agentPolicyOptions: Array> = useMemo( + () => + packageInfo + ? agentPolicies.map((agentConf) => { + const isLimitedPackageAlreadyInPolicy = doesAgentPolicyHaveLimitedPackage( + agentConf, + packageInfo + ); + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(agentConf)?.type === outputType.Logstash; + + return { + inputDisplay: ( + <> + {agentConf.name} + {isAPMPackageAndDataOutputIsLogstash && ( + <> + + + + + + )} + + ), + value: agentConf.id, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyItem', + }; + }) + : [], + [agentPolicies, packageInfo, getDataOutputForPolicy] + ); + + return { + agentPoliciesError, + isLoading: isOutputLoading || isAgentPoliciesLoading, + agentPolicies, + agentPoliciesById, + agentPolicyOptions, + }; +} + +function doesAgentPolicyHaveLimitedPackage(policy: AgentPolicy, pkgInfo: PackageInfo) { + return policy + ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) + : false; +} + +export const StepSelectAgentPolicy: React.FunctionComponent<{ + packageInfo?: PackageInfo; + agentPolicy: AgentPolicy | undefined; + updateAgentPolicy: (agentPolicy: AgentPolicy | undefined) => void; + setHasAgentPolicyError: (hasError: boolean) => void; + selectedAgentPolicyId?: string; +}> = ({ + packageInfo, + agentPolicy, + updateAgentPolicy, + setHasAgentPolicyError, + selectedAgentPolicyId, +}) => { + const { isReady: isFleetReady } = useFleetStatus(); + + const [selectedAgentPolicyError, setSelectedAgentPolicyError] = useState(); + + const { agentPolicies, agentPoliciesById, isLoading, agentPoliciesError, agentPolicyOptions } = + useAgentPoliciesOptions(packageInfo); // Selected agent policy state const [selectedPolicyId, setSelectedPolicyId] = useState( agentPolicy?.id ?? (selectedAgentPolicyId || (agentPolicies.length === 1 ? agentPolicies[0].id : undefined)) ); - const doesAgentPolicyHaveLimitedPackage = useCallback( - (policy: AgentPolicy, pkgInfo: PackageInfo) => { - return policy - ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) - : false; - }, - [] - ); - // Update parent selected agent policy state useEffect(() => { const fetchAgentPolicyInfo = async () => { @@ -109,21 +188,6 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ } }, [selectedPolicyId, agentPolicy, updateAgentPolicy]); - const agentPolicyOptions: EuiSelectOption[] = useMemo( - () => - packageInfo - ? agentPolicies.map((agentConf) => { - return { - text: agentConf.name, - value: agentConf.id, - disabled: doesAgentPolicyHaveLimitedPackage(agentConf, packageInfo), - 'data-test-subj': 'agentPolicyItem', - }; - }) - : [], - [agentPolicies, doesAgentPolicyHaveLimitedPackage, packageInfo] - ); - // Try to select default agent policy useEffect(() => { if (!selectedPolicyId && agentPolicies.length && agentPolicyOptions.length) { @@ -141,6 +205,11 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ } else setHasAgentPolicyError(true); }, [selectedAgentPolicyError, selectedPolicyId, setHasAgentPolicyError]); + const onChange = useCallback( + (newValue: string) => setSelectedPolicyId(newValue === '' ? undefined : newValue), + [] + ); + // Display agent policies list error if there is one if (agentPoliciesError) { return ( @@ -227,19 +296,18 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ ) } > - 1} fullWidth - isLoading={isAgentPoliciesLoading || !packageInfo} + isLoading={isLoading || !packageInfo} options={agentPolicyOptions} - value={selectedPolicyId || undefined} - onChange={(e) => setSelectedPolicyId(e.target.value)} + valueOfSelected={selectedPolicyId} + onChange={onChange} data-test-subj="agentPolicySelect" aria-label="Select Agent Policy" /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx index 0c9f450e83dae..5b127c4e83971 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx @@ -20,6 +20,13 @@ jest.mock('../../../hooks', () => { return { ...jest.requireActual('../../../hooks'), useGetAgentPolicies: jest.fn(), + useGetOutputs: jest.fn().mockResolvedValue({ + data: [], + isLoading: false, + }), + sendGetOneAgentPolicy: jest.fn().mockResolvedValue({ + data: { item: { id: 'policy-1', name: 'Agent policy 1' } }, + }), }; }); @@ -110,7 +117,7 @@ describe('StepSelectHosts', () => { ); }); - it('should display dropdown with agent policy selected when Existing hosts selected', () => { + it('should display dropdown with agent policy selected when Existing hosts selected', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' }], @@ -126,8 +133,9 @@ describe('StepSelectHosts', () => { fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); }); - expect(renderResult.getAllByRole('option').length).toEqual(1); - expect(renderResult.getByText('Agent policy 1').closest('select')).toBeInTheDocument(); + expect( + renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent + ).toEqual('Agent policy 1'); }); it('should display dropdown without preselected value when Existing hosts selected with mulitple agent policies', () => { @@ -149,7 +157,6 @@ describe('StepSelectHosts', () => { fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); }); - expect(renderResult.getAllByRole('option').length).toEqual(2); waitFor(() => { expect(renderResult.getByText('An agent policy is required.')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 0d8627c13b3dc..41ebe9ef713f8 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -57,6 +57,8 @@ export class GenerateServiceTokenError extends IngestManagerError {} export class FleetUnauthorizedError extends IngestManagerError {} export class OutputUnauthorizedError extends IngestManagerError {} +export class OutputInvalidError extends IngestManagerError {} +export class OutputLicenceError extends IngestManagerError {} export class ArtifactsClientError extends IngestManagerError {} export class ArtifactsClientAccessDeniedError extends IngestManagerError { diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts index 92cf5b90ca3f6..43d2561001d9f 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/index.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/index.ts @@ -10,4 +10,4 @@ export { storedPackagePolicyToAgentInputs, storedPackagePoliciesToAgentInputs, } from './package_policies_to_agent_inputs'; -export { validateOutputForPolicy } from './validate_outputs_for_policy'; +export { getDataOutputForAgentPolicy, validateOutputForPolicy } from './outputs_helpers'; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts similarity index 65% rename from x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts rename to x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts index ba5bc4a3aeeb2..4bbcdaabe9b50 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts @@ -5,13 +5,18 @@ * 2.0. */ +import { savedObjectsClientMock } from 'src/core/server/mocks'; + import { appContextService } from '..'; +import { outputService } from '../output'; import { validateOutputForPolicy } from '.'; jest.mock('../app_context'); +jest.mock('../output'); const mockedAppContextService = appContextService as jest.Mocked; +const mockedOutputService = outputService as jest.Mocked; function mockHasLicence(res: boolean) { mockedAppContextService.getSecurityLicense.mockReturnValue({ @@ -23,7 +28,7 @@ describe('validateOutputForPolicy', () => { describe('Without oldData (create)', () => { it('should allow default outputs without platinum licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, }); @@ -31,7 +36,7 @@ describe('validateOutputForPolicy', () => { it('should allow default outputs with platinum licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, }); @@ -39,7 +44,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom data outputs without platinum licence', async () => { mockHasLicence(false); - const res = validateOutputForPolicy({ + const res = validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, }); @@ -50,7 +55,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom monitoring outputs without platinum licence', async () => { mockHasLicence(false); - const res = validateOutputForPolicy({ + const res = validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -61,7 +66,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom data output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, }); @@ -69,7 +74,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom monitoring output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -77,7 +82,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs for managed preconfigured policy without licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { is_managed: true, is_preconfigured: true, data_output_id: 'test1', @@ -90,6 +95,7 @@ describe('validateOutputForPolicy', () => { it('should allow default outputs without platinum licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, @@ -104,6 +110,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom data outputs without platinum licence', async () => { mockHasLicence(false); const res = validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, @@ -121,6 +128,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom monitoring outputs without platinum licence', async () => { mockHasLicence(false); const res = validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', @@ -138,6 +146,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom data output with platinum licence', async () => { mockHasLicence(true); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, @@ -151,7 +160,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom monitoring output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -160,6 +169,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs for managed preconfigured policy without licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: 'test1', @@ -171,6 +181,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs if they did not change without licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: 'test1', @@ -178,5 +189,57 @@ describe('validateOutputForPolicy', () => { { data_output_id: 'test1', monitoring_output_id: 'test1' } ); }); + + it('should not allow APM for a logstash output', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'logstash', + } as any); + await expect( + validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + true // hasAPM + ) + ).rejects.toThrow(/Logstash output is not usable with policy using the APM integration./); + }); + + it('should allow APM for an elasticsearch output', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'elasticsearch', + } as any); + + await validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + true // hasAPM + ); + }); + + it('should allow logstash output for a policy not using APM', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'logstash', + } as any); + + await validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + false // do not have APM + ); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts b/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts new file mode 100644 index 0000000000000..42a48f66de919 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsClientContract } from 'kibana/server'; + +import type { AgentPolicySOAttributes } from '../../types'; +import { LICENCE_FOR_PER_POLICY_OUTPUT, outputType } from '../../../common'; +import { appContextService } from '..'; +import { outputService } from '../output'; +import { OutputInvalidError, OutputLicenceError } from '../../errors'; + +/** + * Get the data output for a given agent policy + * @param soClient + * @param agentPolicy + * @returns + */ +export async function getDataOutputForAgentPolicy( + soClient: SavedObjectsClientContract, + agentPolicy: Partial +) { + const dataOutputId = + agentPolicy.data_output_id || (await outputService.getDefaultDataOutputId(soClient)); + + if (!dataOutputId) { + throw new Error('No default data output found.'); + } + + return outputService.get(soClient, dataOutputId); +} + +/** + * Validate outputs are valid for a policy using the current kibana licence or throw. + * @param data + * @returns + */ +export async function validateOutputForPolicy( + soClient: SavedObjectsClientContract, + newData: Partial, + existingData: Partial = {}, + isPolicyUsingAPM = false +) { + if ( + newData.data_output_id === existingData.data_output_id && + newData.monitoring_output_id === existingData.monitoring_output_id + ) { + return; + } + + const data = { ...existingData, ...newData }; + + if (isPolicyUsingAPM) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, data); + + if (dataOutput.type === outputType.Logstash) { + throw new OutputInvalidError( + 'Logstash output is not usable with policy using the APM integration.' + ); + } + } + + if (!data.data_output_id && !data.monitoring_output_id) { + return; + } + + // Do not validate licence output for managed and preconfigured policy + if (data.is_managed && data.is_preconfigured) { + return; + } + + const hasLicence = appContextService + .getSecurityLicense() + .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + if (!hasLicence) { + throw new OutputLicenceError( + `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` + ); + } +} diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts deleted file mode 100644 index 272e1cd6c5b52..0000000000000 --- a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { AgentPolicySOAttributes } from '../../types'; -import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../common'; -import { appContextService } from '..'; - -/** - * Validate outputs are valid for a policy using the current kibana licence or throw. - * @param data - * @returns - */ -export async function validateOutputForPolicy( - newData: Partial, - oldData: Partial = {} -) { - if ( - newData.data_output_id === oldData.data_output_id && - newData.monitoring_output_id === oldData.monitoring_output_id - ) { - return; - } - - const data = { ...oldData, ...newData }; - - if (!data.data_output_id && !data.monitoring_output_id) { - return; - } - - // Do not validate licence output for managed and preconfigured policy - if (data.is_managed && data.is_preconfigured) { - return; - } - - const hasLicence = appContextService - .getSecurityLicense() - .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); - - if (!hasLicence) { - throw new Error( - `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` - ); - } -} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index dd944f366a326..170942d59061f 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -38,6 +38,7 @@ import { packageToPackagePolicy, AGENT_POLICY_INDEX, UUID_V5_NAMESPACE, + FLEET_APM_PACKAGE, } from '../../common'; import type { DeleteAgentPolicyResponse, @@ -85,26 +86,31 @@ class AgentPolicyService { user?: AuthenticatedUser, options: { bumpRevision: boolean } = { bumpRevision: true } ): Promise { - const oldAgentPolicy = await this.get(soClient, id, false); + const existingAgentPolicy = await this.get(soClient, id, true); - if (!oldAgentPolicy) { + if (!existingAgentPolicy) { throw new Error('Agent policy not found'); } if ( - oldAgentPolicy.status === agentPolicyStatuses.Inactive && + existingAgentPolicy.status === agentPolicyStatuses.Inactive && agentPolicy.status !== agentPolicyStatuses.Active ) { throw new Error( - `Agent policy ${id} cannot be updated because it is ${oldAgentPolicy.status}` + `Agent policy ${id} cannot be updated because it is ${existingAgentPolicy.status}` ); } - await validateOutputForPolicy(agentPolicy); + await validateOutputForPolicy( + soClient, + agentPolicy, + existingAgentPolicy, + this.hasAPMIntegration(existingAgentPolicy) + ); await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentPolicy, - ...(options.bumpRevision ? { revision: oldAgentPolicy.revision + 1 } : {}), + ...(options.bumpRevision ? { revision: existingAgentPolicy.revision + 1 } : {}), updated_at: new Date().toISOString(), updated_by: user ? user.username : 'system', }); @@ -164,6 +170,12 @@ class AgentPolicyService { }; } + public hasAPMIntegration(agentPolicy: AgentPolicy) { + return agentPolicy.package_policies.some( + (p) => typeof p !== 'string' && p.package?.name === FLEET_APM_PACKAGE + ); + } + public async create( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -172,7 +184,7 @@ class AgentPolicyService { ): Promise { await this.requireUniqueName(soClient, agentPolicy); - await validateOutputForPolicy(agentPolicy); + await validateOutputForPolicy(soClient, agentPolicy); const newSo = await soClient.create( SAVED_OBJECT_TYPE, @@ -297,8 +309,9 @@ class AgentPolicyService { } } - const agentPolicies = await Promise.all( - agentPoliciesSO.saved_objects.map(async (agentPolicySO) => { + const agentPolicies = await pMap( + agentPoliciesSO.saved_objects, + async (agentPolicySO) => { const agentPolicy = { id: agentPolicySO.id, ...agentPolicySO.attributes, @@ -314,7 +327,8 @@ class AgentPolicyService { } } return agentPolicy; - }) + }, + { concurrency: 50 } ); return { diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 589fb10f99588..a08fd7a81759d 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -65,7 +65,10 @@ function getMockedSoClient( return mockOutputSO('existing-default-output'); } case outputIdToUuid('existing-default-monitoring-output'): { - return mockOutputSO('existing-default-monitoring-output', { is_default: true }); + return mockOutputSO('existing-default-monitoring-output', { + is_default: true, + type: 'elasticsearch', + }); } case outputIdToUuid('existing-preconfigured-default-output'): { return mockOutputSO('existing-preconfigured-default-output', { @@ -74,6 +77,13 @@ function getMockedSoClient( }); } + case outputIdToUuid('existing-logstash-output'): { + return mockOutputSO('existing-logstash-output', { + type: 'logstash', + is_default: false, + }); + } + default: throw new Error('not found: ' + id); } @@ -149,6 +159,8 @@ function getMockedSoClient( describe('Output Service', () => { beforeEach(() => { + mockedAgentPolicyService.list.mockClear(); + mockedAgentPolicyService.hasAPMIntegration.mockClear(); mockedAgentPolicyService.removeOutputFromAll.mockReset(); }); describe('create', () => { @@ -432,6 +444,34 @@ describe('Output Service', () => { { is_default: false } ); }); + + // With logstash output + it('Should work if you try to make that output the default output and no policies using default output has APM integration', async () => { + const soClient = getMockedSoClient({}); + mockedAgentPolicyService.list.mockResolvedValue({ + items: [{}], + } as unknown as ReturnType); + mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(false); + + await outputService.update(soClient, 'existing-logstash-output', { + is_default: true, + }); + + expect(soClient.update).toBeCalled(); + }); + it('Should throw if you try to make that output the default output and somne policies using default output has APM integration', async () => { + const soClient = getMockedSoClient({}); + mockedAgentPolicyService.list.mockResolvedValue({ + items: [{}], + } as unknown as ReturnType); + mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(true); + + await expect( + outputService.update(soClient, 'existing-logstash-output', { + is_default: true, + }) + ).rejects.toThrow(`Logstash output cannot be used with APM integration.`); + }); }); describe('delete', () => { diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 088220a1b4710..9302c87af85c1 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -9,9 +9,14 @@ import type { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import uuid from 'uuid/v5'; import type { NewOutput, Output, OutputSOAttributes } from '../types'; -import { DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; +import { + DEFAULT_OUTPUT, + DEFAULT_OUTPUT_ID, + OUTPUT_SAVED_OBJECT_TYPE, + AGENT_POLICY_SAVED_OBJECT_TYPE, +} from '../constants'; import { decodeCloudId, normalizeHostsForAgents, SO_SEARCH_LIMIT, outputType } from '../../common'; -import { OutputUnauthorizedError } from '../errors'; +import { OutputUnauthorizedError, OutputInvalidError } from '../errors'; import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; @@ -45,6 +50,39 @@ function outputSavedObjectToOutput(so: SavedObject) { }; } +async function validateLogstashOutputNotUsedInAPMPolicy( + soClient: SavedObjectsClientContract, + outputId?: string, + isDefault?: boolean +) { + // Validate no policy with APM use that policy + let kuery: string; + if (outputId) { + if (isDefault) { + kuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:"${outputId}" or not ${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:*`; + } else { + kuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:"${outputId}"`; + } + } else { + if (isDefault) { + kuery = `not ${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:*`; + } else { + return; + } + } + + const agentPolicySO = await agentPolicyService.list(soClient, { + kuery, + perPage: SO_SEARCH_LIMIT, + withPackagePolicies: true, + }); + for (const agentPolicy of agentPolicySO.items) { + if (agentPolicyService.hasAPMIntegration(agentPolicy)) { + throw new OutputInvalidError('Logstash output cannot be used with APM integration.'); + } + } +} + class OutputService { private async _getDefaultDataOutputsSO(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -126,6 +164,10 @@ class OutputService { ): Promise { const data: OutputSOAttributes = { ...output }; + if (output.type === outputType.Logstash) { + await validateLogstashOutputNotUsedInAPMPolicy(soClient, undefined, data.is_default); + } + // ensure only default output exists if (data.is_default) { const defaultDataOuputId = await this.getDefaultDataOutputId(soClient); @@ -267,7 +309,13 @@ class OutputService { ); } - const updateData = { type: originalOutput.type, ...data }; + const updateData = { ...data }; + const mergedType = data.type ?? originalOutput.type; + const mergedIsDefault = data.is_default ?? originalOutput.is_default; + + if (mergedType === outputType.Logstash) { + await validateLogstashOutputNotUsedInAPMPolicy(soClient, id, mergedIsDefault); + } // ensure only default output exists if (data.is_default) { @@ -294,7 +342,7 @@ class OutputService { } } - if (updateData.type === outputType.Elasticsearch && updateData.hosts) { + if (mergedType === outputType.Elasticsearch && updateData.hosts) { updateData.hosts = updateData.hosts.map(normalizeHostsForAgents); } const outputSO = await soClient.update( diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 81ecfda9425ab..a6c67ff529a85 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -29,6 +29,8 @@ import { validatePackagePolicy, validationHasErrors, SO_SEARCH_LIMIT, + FLEET_APM_PACKAGE, + outputType, } from '../../common'; import type { DeletePackagePoliciesResponse, @@ -63,6 +65,7 @@ import type { ExternalCallback } from '..'; import { storedPackagePolicyToAgentInputs } from './agent_policies'; import { agentPolicyService } from './agent_policy'; +import { getDataOutputForAgentPolicy } from './agent_policies'; import { outputService } from './output'; import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/packages'; import { getAssetsData } from './epm/packages/assets'; @@ -104,6 +107,15 @@ class PackagePolicyService implements PackagePolicyServiceInterface { overwrite?: boolean; } ): Promise { + const agentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id, true); + + if (agentPolicy && packagePolicy.package?.name === FLEET_APM_PACKAGE) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, agentPolicy); + if (dataOutput.type === outputType.Logstash) { + throw new IngestManagerError('You cannot add APM to a policy using a logstash output'); + } + } + // trailing whitespace causes issues creating API keys packagePolicy.name = packagePolicy.name.trim(); if (!options?.skipUniqueNameVerification) { @@ -155,7 +167,6 @@ class PackagePolicyService implements PackagePolicyServiceInterface { // Check if it is a limited package, and if so, check that the corresponding agent policy does not // already contain a package policy for this package if (isPackageLimited(pkgInfo)) { - const agentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id, true); if (agentPolicy && doesAgentPolicyAlreadyIncludePackage(agentPolicy, pkgInfo.name)) { throw new IngestManagerError( `Unable to create package policy. Package '${pkgInfo.name}' already exists on this agent policy.` From 40c010349253e8117596af82116a3266a47222f5 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Mon, 21 Mar 2022 15:06:55 +0000 Subject: [PATCH 31/38] [ML] JobType rename (#128059) * [ML] JobType rename * prop fix * fixing tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/saved_objects.ts | 5 +- .../delete_space_aware_item_check_modal.tsx | 30 ++++++----- .../index.ts | 2 +- .../ml_saved_objects_spaces_list.tsx} | 31 ++++++----- .../saved_objects_warning.tsx | 12 +++-- .../components/analytics_list/use_actions.tsx | 2 +- .../components/analytics_list/use_columns.tsx | 6 +-- .../pages/analytics_management/page.tsx | 2 +- .../pages/job_map/components/controls.tsx | 2 +- .../pages/job_map/page.tsx | 2 +- .../delete_job_modal/delete_job_modal.tsx | 2 +- .../components/jobs_list/jobs_list.js | 6 +-- .../jobs_list_page/jobs_list_page.tsx | 4 +- .../services/ml_api_service/saved_objects.ts | 14 ++--- .../models_management/delete_models_modal.tsx | 2 +- .../models_management/models_list.tsx | 8 +-- .../ml/server/lib/ml_client/ml_client.ts | 42 +++++++-------- .../plugins/ml/server/lib/ml_client/search.ts | 6 +-- x-pack/plugins/ml/server/lib/route_guard.ts | 10 ++-- .../data_recognizer/data_recognizer.test.ts | 4 +- .../models/data_recognizer/data_recognizer.ts | 16 +++--- .../job_audit_messages/job_audit_messages.ts | 6 +-- .../ml/server/routes/job_audit_messages.ts | 10 ++-- x-pack/plugins/ml/server/routes/modules.ts | 34 ++++++------ .../plugins/ml/server/routes/saved_objects.ts | 54 +++++++++---------- .../ml/server/routes/schemas/saved_objects.ts | 4 +- .../plugins/ml/server/saved_objects/checks.ts | 45 +++++++++------- .../plugins/ml/server/saved_objects/index.ts | 4 +- .../initialization/initialization.ts | 6 +-- .../ml/server/saved_objects/service.ts | 11 ++-- .../plugins/ml/server/saved_objects/sync.ts | 46 ++++++++-------- .../ml/server/saved_objects/sync_task.ts | 6 +-- .../shared_services/providers/modules.ts | 16 +++--- .../server/shared_services/shared_services.ts | 22 ++++---- .../apis/ml/saved_objects/can_delete_job.ts | 6 +-- .../apis/ml/saved_objects/sync.ts | 6 +-- 36 files changed, 255 insertions(+), 229 deletions(-) rename x-pack/plugins/ml/public/application/components/{job_spaces_list => ml_saved_objects_spaces_list}/index.ts (77%) rename x-pack/plugins/ml/public/application/components/{job_spaces_list/job_spaces_list.tsx => ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx} (82%) diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 79954aa1b2145..ab3b97d1e614d 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -9,17 +9,18 @@ import type { ErrorType } from '../util/errors'; export type JobType = 'anomaly-detector' | 'data-frame-analytics'; export type TrainedModelType = 'trained-model'; +export type MlSavedObjectType = JobType | TrainedModelType; export const ML_JOB_SAVED_OBJECT_TYPE = 'ml-job'; export const ML_TRAINED_MODEL_SAVED_OBJECT_TYPE = 'ml-trained-model'; export const ML_MODULE_SAVED_OBJECT_TYPE = 'ml-module'; export interface SavedObjectResult { - [id: string]: { success: boolean; type: JobType | TrainedModelType; error?: ErrorType }; + [id: string]: { success: boolean; type: MlSavedObjectType; error?: ErrorType }; } export type SyncResult = { - [jobType in JobType | TrainedModelType]?: { + [jobType in MlSavedObjectType]?: { [id: string]: { success: boolean; error?: ErrorType }; }; }; diff --git a/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx b/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx index 42ee6e386c12d..882a351877071 100644 --- a/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx +++ b/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx @@ -23,9 +23,8 @@ import { EuiSpacer, } from '@elastic/eui'; import type { - JobType, CanDeleteMLSpaceAwareItemsResponse, - TrainedModelType, + MlSavedObjectType, } from '../../../../common/types/saved_objects'; import { useMlApiContext } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; @@ -75,7 +74,7 @@ function getRespSummary( function getModalContent( ids: string[], - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, respSummary: CanDeleteMLSpaceAwareItemsSummary, hasManagedJob?: boolean ): ModalContentReturnType { @@ -96,7 +95,7 @@ function getModalContent( defaultMessage="{ids} have different space permissions. " values={{ ids: ids.join(', ') }} /> - {jobType === 'trained-model' ? ( + {mlSavedObjectType === 'trained-model' ? ( - {jobType === 'trained-model' ? ( + {mlSavedObjectType === 'trained-model' ? ( void; onCloseCallback: () => void; refreshJobsCallback?: () => void; - jobType: JobType | TrainedModelType; + mlSavedObjectType: MlSavedObjectType; ids: string[]; setDidUntag?: React.Dispatch>; hasManagedJob?: boolean; @@ -235,7 +234,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ canDeleteCallback, onCloseCallback, refreshJobsCallback, - jobType, + mlSavedObjectType, ids, setDidUntag, hasManagedJob, @@ -257,7 +256,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ useEffect(() => { setIsLoading(true); // Do the spaces check and set the content for the modal and buttons depending on results - canDeleteMLSpaceAwareItems(jobType, ids).then((resp) => { + canDeleteMLSpaceAwareItems(mlSavedObjectType, ids).then((resp) => { const respSummary = getRespSummary(resp); const { canDelete, canRemoveFromSpace, canTakeAnyAction } = respSummary; if (canTakeAnyAction && canDelete && !canRemoveFromSpace) { @@ -266,7 +265,12 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ return; } setItemCheckRespSummary(respSummary); - const { buttonText, modalText } = getModalContent(ids, jobType, respSummary, hasManagedJob); + const { buttonText, modalText } = getModalContent( + ids, + mlSavedObjectType, + respSummary, + hasManagedJob + ); setButtonContent(buttonText); setModalContent(modalText); }); @@ -278,7 +282,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ const onUntagClick = async () => { setIsUntagging(true); - const resp = await removeItemFromCurrentSpace(jobType, ids); + const resp = await removeItemFromCurrentSpace(mlSavedObjectType, ids); setIsUntagging(false); if (typeof setDidUntag === 'function') { setDidUntag(true); @@ -356,7 +360,9 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ size="s" onClick={onUntagClick} > - {jobType === 'trained-model' ? shouldUnTagModelLabel : shouldUnTagJobLabel} + {mlSavedObjectType === 'trained-model' + ? shouldUnTagModelLabel + : shouldUnTagJobLabel} )} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts similarity index 77% rename from x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts rename to x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts index 8acec6a45a0c8..5adc5716896cb 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList } from './job_spaces_list'; +export { MLSavedObjectsSpacesList } from './ml_saved_objects_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx similarity index 82% rename from x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx rename to x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx index e8e06dfce784b..20f487da0002e 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx @@ -10,20 +10,19 @@ import React, { FC, useCallback, useState } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { - JobType, - TrainedModelType, ML_JOB_SAVED_OBJECT_TYPE, SavedObjectResult, + MlSavedObjectType, } from '../../../../common/types/saved_objects'; import type { SpacesPluginStart, ShareToSpaceFlyoutProps } from '../../../../../spaces/public'; -import { ml } from '../../services/ml_api_service'; +import { useMlApiContext } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { spacesApi: SpacesPluginStart; spaceIds: string[]; id: string; - jobType: JobType | TrainedModelType; + mlSavedObjectType: MlSavedObjectType; refresh(): void; } @@ -36,7 +35,16 @@ const modelObjectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.model defaultMessage: 'trained model', }); -export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, refresh }) => { +export const MLSavedObjectsSpacesList: FC = ({ + spacesApi, + spaceIds, + id, + mlSavedObjectType, + refresh, +}) => { + const { + savedObjects: { updateJobsSpaces, updateModelsSpaces }, + } = useMlApiContext(); const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); @@ -50,16 +58,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, ref const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove; if (spacesToAdd.length || spacesToRemove.length) { - if (jobType === 'trained-model') { - const resp = await ml.savedObjects.updateModelsSpaces([id], spacesToAdd, spacesToRemove); + if (mlSavedObjectType === 'trained-model') { + const resp = await updateModelsSpaces([id], spacesToAdd, spacesToRemove); handleApplySpaces(resp); } else { - const resp = await ml.savedObjects.updateJobsSpaces( - jobType, - [id], - spacesToAdd, - spacesToRemove - ); + const resp = await updateJobsSpaces(mlSavedObjectType, [id], spacesToAdd, spacesToRemove); handleApplySpaces(resp); } } @@ -94,7 +97,7 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, ref id, namespaces: spaceIds, title: id, - noun: jobType === 'trained-model' ? modelObjectNoun : jobObjectNoun, + noun: mlSavedObjectType === 'trained-model' ? modelObjectNoun : jobObjectNoun, }, behaviorContext: 'outside-space', changeSpacesHandler, diff --git a/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx b/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx index 028b9db615147..ccbf0c1082d0f 100644 --- a/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx +++ b/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx @@ -8,18 +8,22 @@ import React, { FC, useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { JobType, TrainedModelType } from '../../../../common/types/saved_objects'; +import type { MlSavedObjectType } from '../../../../common/types/saved_objects'; import { useMlApiContext } from '../../contexts/kibana'; import { JobSpacesSyncFlyout } from '../../components/job_spaces_sync'; import { checkPermission } from '../../capabilities/check_capabilities'; interface Props { - jobType?: JobType | TrainedModelType; + mlSavedObjectType?: MlSavedObjectType; onCloseFlyout?: () => void; forceRefresh?: boolean; } -export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRefresh }) => { +export const SavedObjectsWarning: FC = ({ + mlSavedObjectType, + onCloseFlyout, + forceRefresh, +}) => { const { savedObjects: { syncCheck }, } = useMlApiContext(); @@ -35,7 +39,7 @@ export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRe return; } - const { result } = await syncCheck(jobType); + const { result } = await syncCheck(mlSavedObjectType); if (mounted.current === true) { setShowWarning(showSyncFlyout || result); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index 90661eb596c84..b6088e9db0252 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -66,7 +66,7 @@ export const useActions = ( deleteAction.closeDeleteJobCheckModal(); }} refreshJobsCallback={refresh} - jobType={deleteAction.jobType} + mlSavedObjectType={deleteAction.jobType} ids={[deleteAction.item.config.id]} /> )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 4213b59386122..329120e26be2d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -34,7 +34,7 @@ import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/locator'; import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; -import { JobSpacesList } from '../../../../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../../../../components/ml_saved_objects_spaces_list'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -290,11 +290,11 @@ export const useColumns = ( }), render: (item: DataFrameAnalyticsListRow) => Array.isArray(item.spaceIds) ? ( - ) : null, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index b0f51874daed5..57904a206d281 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -65,7 +65,7 @@ export const Page: FC = () => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx index 92335b65eb906..8bac15adb2afb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -310,7 +310,7 @@ export const Controls: FC = React.memo( {isDeleteJobCheckModalVisible && item && ( { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx index ed31fa9fd10f4..efdd386ca47b3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx @@ -112,7 +112,7 @@ export const Page: FC = () => { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx index 6df08e637249b..701ee35d9ca92 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx @@ -167,7 +167,7 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, <> { setCanDelete(true); }} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 18826667298e5..19cbb99bbf7dd 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -15,7 +15,7 @@ import { toLocaleString } from '../../../../util/string_utils'; import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; -import { JobSpacesList } from '../../../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../../../components/ml_saved_objects_spaces_list'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; import { @@ -315,11 +315,11 @@ export class JobsList extends Component { defaultMessage: 'Spaces', }), render: (item) => ( - ), diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index fe43aa9d3b123..5620902ee768b 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -52,7 +52,7 @@ import { getMlGlobalServices } from '../../../../app'; import { ListingPageUrlState } from '../../../../../../common/types/common'; import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; import { ExportJobsFlyout, ImportJobsFlyout } from '../../../../components/import_export_jobs'; -import type { JobType, TrainedModelType } from '../../../../../../common/types/saved_objects'; +import type { JobType, MlSavedObjectType } from '../../../../../../common/types/saved_objects'; import type { FieldFormatsStart } from '../../../../../../../../../src/plugins/field_formats/public'; interface Tab extends EuiTabbedContentTab { @@ -170,7 +170,7 @@ export const JobsListPage: FC<{ const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = useTabs(isMlEnabledInSpace, spacesApi); - const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); + const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); const I18nContext = coreStart.i18n.Context; const theme$ = coreStart.theme.theme$; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index 88eb318ccde27..131244e7122cc 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -15,7 +15,7 @@ import { HttpService } from '../http_service'; import { basePath } from './index'; import type { JobType, - TrainedModelType, + MlSavedObjectType, CanDeleteMLSpaceAwareItemsResponse, SyncSavedObjectResponse, InitializeSavedObjectResponse, @@ -45,8 +45,8 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ body, }); }, - removeItemFromCurrentSpace(jobType: JobType | TrainedModelType, ids: string[]) { - const body = JSON.stringify({ jobType, ids }); + removeItemFromCurrentSpace(mlSavedObjectType: MlSavedObjectType, ids: string[]) { + const body = JSON.stringify({ mlSavedObjectType, ids }); return httpService.http({ path: `${basePath()}/saved_objects/remove_item_from_current_space`, method: 'POST', @@ -67,18 +67,18 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ query: { simulate }, }); }, - syncCheck(jobType?: JobType | TrainedModelType) { - const body = JSON.stringify({ jobType }); + syncCheck(mlSavedObjectType?: MlSavedObjectType) { + const body = JSON.stringify({ mlSavedObjectType }); return httpService.http({ path: `${basePath()}/saved_objects/sync_check`, method: 'POST', body, }); }, - canDeleteMLSpaceAwareItems(jobType: JobType | TrainedModelType, ids: string[]) { + canDeleteMLSpaceAwareItems(mlSavedObjectType: MlSavedObjectType, ids: string[]) { const body = JSON.stringify({ ids }); return httpService.http({ - path: `${basePath()}/saved_objects/can_delete_ml_space_aware_item/${jobType}`, + path: `${basePath()}/saved_objects/can_delete_ml_space_aware_item/${mlSavedObjectType}`, method: 'POST', body, }); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx index 51ed0e5e05893..401f18ab3d3a0 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx @@ -103,7 +103,7 @@ export const DeleteModelsModal: FC = ({ modelIds, onClos ) : ( {}} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index 96c2a1cd556e5..bd3e3638e8310 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -51,7 +51,7 @@ import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats import { useRefresh } from '../../routing/use_refresh'; import { DEPLOYMENT_STATE, TRAINED_MODEL_TYPE } from '../../../../common/constants/trained_models'; import { getUserConfirmationProvider } from './force_stop_dialog'; -import { JobSpacesList } from '../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../components/ml_saved_objects_spaces_list'; import { SavedObjectsWarning } from '../../components/saved_objects_warning'; type Stats = Omit; @@ -588,11 +588,11 @@ export const ModelsList: FC = ({ render: (id: string) => { const spaces = modelSpaces[id]; return ( - ); @@ -715,7 +715,7 @@ export const ModelsList: FC = ({ {isManagementTable ? null : ( <> diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index 4ff555445f2c8..342a3913a6cba 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -7,7 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IScopedClusterClient } from 'kibana/server'; -import { JobSavedObjectService } from '../../saved_objects'; +import { MLSavedObjectService } from '../../saved_objects'; import { getJobDetailsFromTrainedModel } from '../../saved_objects/util'; import { JobType } from '../../../common/types/saved_objects'; @@ -27,7 +27,7 @@ import { export function getMlClient( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ): MlClient { const mlClient = client.asInternalUser.ml; @@ -40,7 +40,7 @@ export function getMlClient( } async function checkJobIds(jobType: JobType, jobIds: string[], allowWildcards: boolean = false) { - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace(jobType, jobIds); + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace(jobType, jobIds); let missingIds = jobIds.filter((j) => filteredJobIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -113,9 +113,7 @@ export function getMlClient( async function datafeedIdsCheck(p: MlClientParams, allowWildcards: boolean = false) { const datafeedIds = getDatafeedIdsFromRequest(p); if (datafeedIds.length) { - const filteredDatafeedIds = await jobSavedObjectService.filterDatafeedIdsForSpace( - datafeedIds - ); + const filteredDatafeedIds = await mlSavedObjectService.filterDatafeedIdsForSpace(datafeedIds); let missingIds = datafeedIds.filter((j) => filteredDatafeedIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -135,7 +133,7 @@ export function getMlClient( } async function checkModelIds(modelIds: string[], allowWildcards: boolean = false) { - const filteredModelIds = await jobSavedObjectService.filterTrainedModelIdsForSpace(modelIds); + const filteredModelIds = await mlSavedObjectService.filterTrainedModelIdsForSpace(modelIds); let missingIds = modelIds.filter((j) => filteredModelIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -173,7 +171,7 @@ export function getMlClient( const resp = await mlClient.deleteDatafeed(...p); const [datafeedId] = getDatafeedIdsFromRequest(p); if (datafeedId !== undefined) { - await jobSavedObjectService.deleteDatafeed(datafeedId); + await mlSavedObjectService.deleteDatafeed(datafeedId); } return resp; }, @@ -242,7 +240,7 @@ export function getMlClient( const groups = calJobIds.filter((j) => allJobIds.includes(j) === false); // get list of calendar jobs which are allowed in this space - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace( + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace( 'anomaly-detector', calJobIds ); @@ -271,7 +269,7 @@ export function getMlClient( const meta = options.meta ?? false; const response = await mlClient.getDataFrameAnalytics(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'data-frame-analytics', // @ts-expect-error @elastic-elasticsearch Data frame types incomplete response.body.data_frame_analytics, @@ -303,7 +301,7 @@ export function getMlClient( })) as unknown as { body: { data_frame_analytics: DataFrameAnalyticsConfig[] }; }; - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'data-frame-analytics', response.body.data_frame_analytics, 'id' @@ -328,7 +326,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getDatafeedStats(params, { ...options, meta: true }); - const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( + const datafeeds = await mlSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', response.body.datafeeds, 'datafeed_id' @@ -353,7 +351,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getDatafeeds(params, { ...options, meta: true }); - const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( + const datafeeds = await mlSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', response.body.datafeeds, 'datafeed_id' @@ -384,7 +382,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getJobStats(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', response.body.jobs, 'job_id' @@ -415,7 +413,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getJobs(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', response.body.jobs, 'job_id' @@ -459,7 +457,7 @@ export function getMlClient( try { const body = await mlClient.getTrainedModels(...p); const models = - await jobSavedObjectService.filterTrainedModelsForSpace( + await mlSavedObjectService.filterTrainedModelsForSpace( body.trained_model_configs, 'model_id' ); @@ -476,7 +474,7 @@ export function getMlClient( try { const body = await mlClient.getTrainedModelsStats(...p); const models = - await jobSavedObjectService.filterTrainedModelsForSpace( + await mlSavedObjectService.filterTrainedModelsForSpace( body.trained_model_stats, 'model_id' ); @@ -524,7 +522,7 @@ export function getMlClient( const resp = await mlClient.putDataFrameAnalytics(...p); const [analyticsId] = getDFAJobIdsFromRequest(p); if (analyticsId !== undefined) { - await jobSavedObjectService.createDataFrameAnalyticsJob(analyticsId); + await mlSavedObjectService.createDataFrameAnalyticsJob(analyticsId); } return resp; }, @@ -533,7 +531,7 @@ export function getMlClient( const [datafeedId] = getDatafeedIdsFromRequest(p); const jobId = getJobIdFromBody(p); if (datafeedId !== undefined && jobId !== undefined) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); } return resp; @@ -545,7 +543,7 @@ export function getMlClient( const resp = await mlClient.putJob(...p); const [jobId] = getADJobIdsFromRequest(p); if (jobId !== undefined) { - await jobSavedObjectService.createAnomalyDetectionJob(jobId); + await mlSavedObjectService.createAnomalyDetectionJob(jobId); } return resp; }, @@ -555,7 +553,7 @@ export function getMlClient( if (modelId !== undefined) { const model = (p[0] as estypes.MlPutTrainedModelRequest).body; const job = getJobDetailsFromTrainedModel(model); - await jobSavedObjectService.createTrainedModel(modelId, job); + await mlSavedObjectService.createTrainedModel(modelId, job); } return resp; }, @@ -640,7 +638,7 @@ export function getMlClient( return mlClient.getMemoryStats(...p); }, - ...searchProvider(client, jobSavedObjectService), + ...searchProvider(client, mlSavedObjectService), } as MlClient; } diff --git a/x-pack/plugins/ml/server/lib/ml_client/search.ts b/x-pack/plugins/ml/server/lib/ml_client/search.ts index 6ba75478d5988..1ead8d0fd4ea1 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/search.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/search.ts @@ -15,17 +15,17 @@ import type { TransportRequestOptionsWithOutMeta, } from '@elastic/elasticsearch'; -import { JobSavedObjectService } from '../../saved_objects'; +import { MLSavedObjectService } from '../../saved_objects'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import type { JobType } from '../../../common/types/saved_objects'; export function searchProvider( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { async function jobIdsCheck(jobType: JobType, jobIds: string[]) { if (jobIds.length) { - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace(jobType, jobIds); + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace(jobType, jobIds); const missingIds = jobIds.filter((j) => filteredJobIds.indexOf(j) === -1); if (missingIds.length) { throw Boom.notFound(`${missingIds.join(',')} missing`); diff --git a/x-pack/plugins/ml/server/lib/route_guard.ts b/x-pack/plugins/ml/server/lib/route_guard.ts index 0b445eeeae396..90de46340d873 100644 --- a/x-pack/plugins/ml/server/lib/route_guard.ts +++ b/x-pack/plugins/ml/server/lib/route_guard.ts @@ -16,7 +16,7 @@ import type { import type { SpacesPluginSetup } from '../../../spaces/server'; import type { SecurityPluginSetup } from '../../../security/server'; -import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; +import { mlSavedObjectServiceFactory, MLSavedObjectService } from '../saved_objects'; import type { MlLicense } from '../../common/license'; import { MlClient, getMlClient } from '../lib/ml_client'; @@ -34,7 +34,7 @@ type Handler

= (handlerParams: { request: KibanaRequest; response: KibanaResponseFactory; context: MLRequestHandlerContext; - jobSavedObjectService: JobSavedObjectService; + mlSavedObjectService: MLSavedObjectService; mlClient: MlClient; getDataViewsService(): Promise; }) => ReturnType>; @@ -96,7 +96,7 @@ export class RouteGuard { }); } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( mlSavedObjectClient, internalSavedObjectsClient, this._spacesPlugin !== undefined, @@ -110,8 +110,8 @@ export class RouteGuard { request, response, context, - jobSavedObjectService, - mlClient: getMlClient(client, jobSavedObjectService), + mlSavedObjectService, + mlClient: getMlClient(client, mlSavedObjectService), getDataViewsService: getDataViewsServiceFactory( this._getDataViews, context.core.savedObjects.client, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts index d4c3648d05325..7d2f37cefb50b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts @@ -14,7 +14,7 @@ import type { DataViewsService } from '../../../../../../src/plugins/data_views/ import type { Module } from '../../../common/types/modules'; import { DataRecognizer } from '../data_recognizer'; import type { MlClient } from '../../lib/ml_client'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; const callAs = () => Promise.resolve({ body: {} }); @@ -34,7 +34,7 @@ describe('ML - data recognizer', () => { bulkCreate: jest.fn(), } as unknown as SavedObjectsClientContract, { find: jest.fn() } as unknown as DataViewsService, - {} as JobSavedObjectService, + {} as MLSavedObjectService, { headers: { authorization: '' } } as unknown as KibanaRequest ); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 7498d2372f7d7..3ef36f4ad3b6b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -52,7 +52,7 @@ import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; import type { JobExistResult, JobStat } from '../../../common/types/data_recognizer'; import type { Datafeed } from '../../../common/types/anomaly_detection_jobs'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; import { isDefined } from '../../../common/types/guards'; import { isPopulatedObject } from '../../../common/util/object_utils'; @@ -111,7 +111,7 @@ export class DataRecognizer { private _client: IScopedClusterClient; private _mlClient: MlClient; private _savedObjectsClient: SavedObjectsClientContract; - private _jobSavedObjectService: JobSavedObjectService; + private _mlSavedObjectService: MLSavedObjectService; private _dataViewsService: DataViewsService; private _request: KibanaRequest; @@ -145,14 +145,14 @@ export class DataRecognizer { mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest ) { this._client = mlClusterClient; this._mlClient = mlClient; this._savedObjectsClient = savedObjectsClient; this._dataViewsService = dataViewsService; - this._jobSavedObjectService = jobSavedObjectService; + this._mlSavedObjectService = mlSavedObjectService; this._request = request; this._authorizationHeader = getAuthorizationHeader(request); this._jobsService = jobServiceProvider(mlClusterClient, mlClient); @@ -782,12 +782,12 @@ export class DataRecognizer { }) ); if (applyToAllSpaces === true) { - const canCreateGlobalJobs = await this._jobSavedObjectService.canCreateGlobalMlSavedObjects( + const canCreateGlobalJobs = await this._mlSavedObjectService.canCreateGlobalMlSavedObjects( 'anomaly-detector', this._request ); if (canCreateGlobalJobs === true) { - await this._jobSavedObjectService.updateJobsSpaces( + await this._mlSavedObjectService.updateJobsSpaces( 'anomaly-detector', jobs.map((j) => j.id), ['*'], // spacesToAdd @@ -1399,7 +1399,7 @@ export function dataRecognizerFactory( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest ) { return new DataRecognizer( @@ -1407,7 +1407,7 @@ export function dataRecognizerFactory( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); } diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index 79b54b9a635a8..cab1d42743017 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -11,7 +11,7 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/type import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ML_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { MESSAGE_LEVEL } from '../../../common/constants/message_levels'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; import type { MlClient } from '../../lib/ml_client'; import type { JobMessage } from '../../../common/types/audit_message'; import { AuditMessage } from '../../../common/types/anomaly_detection_jobs'; @@ -66,7 +66,7 @@ export function jobAuditMessagesProvider( // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d async function getJobAuditMessages( - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, { jobId, from, @@ -174,7 +174,7 @@ export function jobAuditMessagesProvider( messages.push(hit._source!); }); } - messages = await jobSavedObjectService.filterJobsForSpace( + messages = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', messages, 'job_id' diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index d28effae5ca2b..360e2bef4c99e 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -40,12 +40,12 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { getJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { jobId } = request.params; const { from, start, end } = request.query; - const resp = await getJobAuditMessages(jobSavedObjectService, { + const resp = await getJobAuditMessages(mlSavedObjectService, { jobId, from, start, @@ -82,11 +82,11 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { getJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { from } = request.query; - const resp = await getJobAuditMessages(jobSavedObjectService, { from }); + const resp = await getJobAuditMessages(mlSavedObjectService, { from }); return response.ok({ body: resp, @@ -118,7 +118,7 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { clearJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { jobId, notificationIndices } = request.body; diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index d814e91f70ca0..2b366cef9a9f4 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -24,14 +24,14 @@ import { } from './schemas/modules'; import type { RouteInitialization } from '../types'; import type { MlClient } from '../lib/ml_client'; -import type { JobSavedObjectService } from '../saved_objects'; +import type { MLSavedObjectService } from '../saved_objects'; function recognize( client: IScopedClusterClient, mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, indexPatternTitle: string ) { @@ -40,7 +40,7 @@ function recognize( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.findMatches(indexPatternTitle); @@ -51,7 +51,7 @@ function getModule( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId?: string ) { @@ -60,7 +60,7 @@ function getModule( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); if (moduleId === undefined) { @@ -75,7 +75,7 @@ function setup( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId: string, prefix?: string, @@ -96,7 +96,7 @@ function setup( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.setup( @@ -121,7 +121,7 @@ function dataRecognizerJobsExist( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId: string ) { @@ -130,7 +130,7 @@ function dataRecognizerJobsExist( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.dataRecognizerJobsExist(moduleId); @@ -185,7 +185,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -196,7 +196,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, indexPatternTitle ); @@ -334,7 +334,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -350,7 +350,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId ); @@ -521,7 +521,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -549,7 +549,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId, prefix, @@ -643,7 +643,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -654,7 +654,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId ); diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index faddb55d2a46b..eecd6f7f9ba5f 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -18,7 +18,7 @@ import { itemTypeSchema, } from './schemas/saved_objects'; import { spacesUtilsProvider } from '../lib/spaces_utils'; -import type { JobType, TrainedModelType } from '../../common/types/saved_objects'; +import type { MlSavedObjectType } from '../../common/types/saved_objects'; /** * Routes for job saved object management @@ -43,9 +43,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetJobs', 'access:ml:canGetTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, response, mlSavedObjectService }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const status = await checkStatus(); return response.ok({ @@ -82,10 +82,10 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { const { simulate } = request.query; - const { syncSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { syncSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const savedObjects = await syncSavedObjects(simulate); return response.ok({ @@ -119,10 +119,10 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { const { simulate } = request.query; - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const savedObjects = await initSavedObjects(simulate); return response.ok({ @@ -156,11 +156,11 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { - const { jobType } = request.body; - const { isSyncNeeded } = syncSavedObjectsFactory(client, jobSavedObjectService); - const result = await isSyncNeeded(jobType as JobType | TrainedModelType); + const { mlSavedObjectType } = request.body; + const { isSyncNeeded } = syncSavedObjectsFactory(client, mlSavedObjectService); + const result = await isSyncNeeded(mlSavedObjectType as MlSavedObjectType); return response.ok({ body: { result }, @@ -190,11 +190,11 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { const { jobType, jobIds, spacesToAdd, spacesToRemove } = request.body; - const body = await jobSavedObjectService.updateJobsSpaces( + const body = await mlSavedObjectService.updateJobsSpaces( jobType, jobIds, spacesToAdd, @@ -229,11 +229,11 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { const { modelIds, spacesToAdd, spacesToRemove } = request.body; - const body = await jobSavedObjectService.updateTrainedModelsSpaces( + const body = await mlSavedObjectService.updateTrainedModelsSpaces( modelIds, spacesToAdd, spacesToRemove @@ -267,9 +267,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { - const { jobType, ids } = request.body; + const { mlSavedObjectType, ids } = request.body; const { getCurrentSpaceId } = spacesUtilsProvider(getSpaces, request); const currentSpaceId = await getCurrentSpaceId(); @@ -284,8 +284,8 @@ export function savedObjectsRoutes( }); } - if (jobType === 'trained-model') { - const body = await jobSavedObjectService.updateTrainedModelsSpaces( + if (mlSavedObjectType === 'trained-model') { + const body = await mlSavedObjectService.updateTrainedModelsSpaces( ids, [], // spacesToAdd [currentSpaceId] // spacesToRemove @@ -296,8 +296,8 @@ export function savedObjectsRoutes( }); } - const body = await jobSavedObjectService.updateJobsSpaces( - jobType, + const body = await mlSavedObjectService.updateJobsSpaces( + mlSavedObjectType, ids, [], // spacesToAdd [currentSpaceId] // spacesToRemove @@ -328,9 +328,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ response, mlSavedObjectService, client }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const savedObjects = (await checkStatus()).savedObjects; const jobStatus = ( Object.entries(savedObjects) @@ -373,9 +373,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ response, mlSavedObjectService, client }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const savedObjects = (await checkStatus()).savedObjects; const modelStatus = savedObjects['trained-model'] .filter((s) => s.checks.trainedModelExists) @@ -433,12 +433,12 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService, client }) => { try { const { jobType } = request.params; const { ids } = request.body; - const { canDeleteMLSpaceAwareItems } = checksFactory(client, jobSavedObjectService); + const { canDeleteMLSpaceAwareItems } = checksFactory(client, mlSavedObjectService); const body = await canDeleteMLSpaceAwareItems( request, jobType, diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index 4a90088ab8ae0..ef5c81a08a516 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -34,13 +34,13 @@ export const updateTrainedModelsSpaces = schema.object({ }); export const itemsAndCurrentSpace = schema.object({ - jobType: itemTypeLiterals, + mlSavedObjectType: itemTypeLiterals, ids: schema.arrayOf(schema.string()), }); export const syncJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); -export const syncCheckSchema = schema.object({ jobType: schema.maybe(schema.string()) }); +export const syncCheckSchema = schema.object({ mlSavedObjectType: schema.maybe(schema.string()) }); export const canDeleteMLSpaceAwareItemsSchema = schema.object({ /** List of job or trained model IDs. */ diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index e23dd7bee7426..c6abcb79030de 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { IScopedClusterClient, KibanaRequest, SavedObjectsFindResult } from 'kibana/server'; import type { - JobSavedObjectService, + MLSavedObjectService, TrainedModelJob, JobObject, TrainedModelObject, @@ -17,7 +17,7 @@ import type { import type { JobType, DeleteMLSpaceAwareItemsCheckResponse, - TrainedModelType, + MlSavedObjectType, } from '../../common/types/saved_objects'; import type { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; @@ -76,7 +76,7 @@ export interface StatusResponse { export function checksFactory( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { async function checkStatus(): Promise { const [ @@ -89,10 +89,10 @@ export function checksFactory( dfaJobs, models, ] = await Promise.all([ - jobSavedObjectService.getAllJobObjects(undefined, false), - jobSavedObjectService.getAllJobObjectsForAllSpaces(), - jobSavedObjectService.getAllTrainedModelObjects(false), - jobSavedObjectService.getAllTrainedModelObjectsForAllSpaces(), + mlSavedObjectService.getAllJobObjects(undefined, false), + mlSavedObjectService.getAllJobObjectsForAllSpaces(), + mlSavedObjectService.getAllTrainedModelObjects(false), + mlSavedObjectService.getAllTrainedModelObjectsForAllSpaces(), client.asInternalUser.ml.getJobs(), client.asInternalUser.ml.getDatafeeds(), client.asInternalUser.ml.getDataFrameAnalytics() as unknown as { @@ -274,12 +274,15 @@ export function checksFactory( async function canDeleteMLSpaceAwareItems( request: KibanaRequest, - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, ids: string[], spacesEnabled: boolean, resolveMlCapabilities: ResolveMlCapabilities ): Promise { - if (['anomaly-detector', 'data-frame-analytics', 'trained-model'].includes(jobType) === false) { + if ( + ['anomaly-detector', 'data-frame-analytics', 'trained-model'].includes(mlSavedObjectType) === + false + ) { throw Boom.badRequest( 'Saved object type must be "anomaly-detector", "data-frame-analytics" or "trained-model' ); @@ -291,8 +294,9 @@ export function checksFactory( } if ( - (jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || - (jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false) + (mlSavedObjectType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || + (mlSavedObjectType === 'data-frame-analytics' && + mlCapabilities.canDeleteDataFrameAnalytics === false) ) { // user does not have access to delete jobs. return ids.reduce((results, id) => { @@ -302,7 +306,10 @@ export function checksFactory( }; return results; }, {} as DeleteMLSpaceAwareItemsCheckResponse); - } else if (jobType === 'trained-model' && mlCapabilities.canDeleteTrainedModels === false) { + } else if ( + mlSavedObjectType === 'trained-model' && + mlCapabilities.canDeleteTrainedModels === false + ) { // user does not have access to delete trained models. return ids.reduce((results, id) => { results[id] = { @@ -323,19 +330,21 @@ export function checksFactory( return results; }, {} as DeleteMLSpaceAwareItemsCheckResponse); } - const canCreateGlobalMlSavedObjects = await jobSavedObjectService.canCreateGlobalMlSavedObjects( - jobType, + const canCreateGlobalMlSavedObjects = await mlSavedObjectService.canCreateGlobalMlSavedObjects( + mlSavedObjectType, request ); const savedObjects = - jobType === 'trained-model' - ? await Promise.all(ids.map((id) => jobSavedObjectService.getTrainedModelObject(id))) - : await Promise.all(ids.map((id) => jobSavedObjectService.getJobObject(jobType, id))); + mlSavedObjectType === 'trained-model' + ? await Promise.all(ids.map((id) => mlSavedObjectService.getTrainedModelObject(id))) + : await Promise.all( + ids.map((id) => mlSavedObjectService.getJobObject(mlSavedObjectType, id)) + ); return ids.reduce((results, id) => { const savedObject = - jobType === 'trained-model' + mlSavedObjectType === 'trained-model' ? (savedObjects as Array | undefined>).find( (j) => j?.attributes.model_id === id ) diff --git a/x-pack/plugins/ml/server/saved_objects/index.ts b/x-pack/plugins/ml/server/saved_objects/index.ts index 7537c7ed01dcc..1a21a23c63b0b 100644 --- a/x-pack/plugins/ml/server/saved_objects/index.ts +++ b/x-pack/plugins/ml/server/saved_objects/index.ts @@ -6,8 +6,8 @@ */ export { setupSavedObjects } from './saved_objects'; -export type { JobObject, JobSavedObjectService } from './service'; -export { jobSavedObjectServiceFactory } from './service'; +export type { JobObject, MLSavedObjectService } from './service'; +export { mlSavedObjectServiceFactory } from './service'; export { checksFactory } from './checks'; export type { JobSavedObjectStatus } from './checks'; export { syncSavedObjectsFactory } from './sync'; diff --git a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts index 1764c59f7e446..5e453a613b22d 100644 --- a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts +++ b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts @@ -8,7 +8,7 @@ import { IScopedClusterClient, CoreStart, SavedObjectsClientContract } from 'kibana/server'; import { savedObjectClientsFactory } from '../util'; import { syncSavedObjectsFactory } from '../sync'; -import { jobSavedObjectServiceFactory, JobObject } from '../service'; +import { mlSavedObjectServiceFactory, JobObject } from '../service'; import { mlLog } from '../../lib/log'; import { ML_JOB_SAVED_OBJECT_TYPE } from '../../../common/types/saved_objects'; import { createJobSpaceOverrides } from './space_overrides'; @@ -47,7 +47,7 @@ export function jobSavedObjectsInitializationFactory( return; } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( savedObjectsClient, savedObjectsClient, spacesEnabled, @@ -60,7 +60,7 @@ export function jobSavedObjectsInitializationFactory( // create space overrides for specific jobs const jobSpaceOverrides = await createJobSpaceOverrides(client); // initialize jobs - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const { jobs, trainedModels } = await initSavedObjects(false, jobSpaceOverrides); mlLog.info(`${jobs.length + trainedModels.length} ML saved objects initialized`); } catch (error) { diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index af2aef1b0aa14..eb3fbee91308d 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -18,6 +18,7 @@ import type { JobType, TrainedModelType, SavedObjectResult, + MlSavedObjectType, } from '../../common/types/saved_objects'; import { ML_JOB_SAVED_OBJECT_TYPE, @@ -46,9 +47,9 @@ export interface TrainedModelJob { type TrainedModelObjectFilter = { [k in keyof TrainedModelObject]?: string }; -export type JobSavedObjectService = ReturnType; +export type MLSavedObjectService = ReturnType; -export function jobSavedObjectServiceFactory( +export function mlSavedObjectServiceFactory( savedObjectsClient: SavedObjectsClientContract, internalSavedObjectsClient: SavedObjectsClientContract, spacesEnabled: boolean, @@ -397,7 +398,7 @@ export function jobSavedObjectServiceFactory( } async function canCreateGlobalMlSavedObjects( - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, request: KibanaRequest ) { if (authorization === undefined) { @@ -407,7 +408,9 @@ export function jobSavedObjectServiceFactory( const { canCreateJobsGlobally, canCreateTrainedModelsGlobally } = await authorizationCheck( request ); - return jobType === 'trained-model' ? canCreateTrainedModelsGlobally : canCreateJobsGlobally; + return mlSavedObjectType === 'trained-model' + ? canCreateTrainedModelsGlobally + : canCreateJobsGlobally; } async function getTrainedModelObject( diff --git a/x-pack/plugins/ml/server/saved_objects/sync.ts b/x-pack/plugins/ml/server/saved_objects/sync.ts index 63f62d264a63d..b4573e8b7c8aa 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync.ts @@ -7,12 +7,12 @@ import Boom from '@hapi/boom'; import type { IScopedClusterClient } from 'kibana/server'; -import type { JobObject, JobSavedObjectService, TrainedModelObject } from './service'; +import type { JobObject, MLSavedObjectService, TrainedModelObject } from './service'; import type { JobType, - TrainedModelType, SyncSavedObjectResponse, InitializeSavedObjectResponse, + MlSavedObjectType, } from '../../common/types/saved_objects'; import { checksFactory } from './checks'; import type { JobStatus } from './checks'; @@ -26,9 +26,9 @@ export interface JobSpaceOverrides { export function syncSavedObjectsFactory( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); async function syncSavedObjects(simulate: boolean = false) { const results: SyncSavedObjectResponse = { @@ -65,7 +65,7 @@ export function syncSavedObjectsFactory( const datafeedId = job.datafeedId; tasks.push(async () => { try { - await jobSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); + await mlSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); results.savedObjectsCreated[type]![job.jobId] = { success: true }; } catch (error) { results.savedObjectsCreated[type]![job.jobId] = { @@ -91,7 +91,7 @@ export function syncSavedObjectsFactory( const jobId = job.jobId; tasks.push(async () => { try { - await jobSavedObjectService.createDataFrameAnalyticsJob(jobId); + await mlSavedObjectService.createDataFrameAnalyticsJob(jobId); results.savedObjectsCreated[type]![job.jobId] = { success: true, }; @@ -129,7 +129,7 @@ export function syncSavedObjectsFactory( return; } const job = getJobDetailsFromTrainedModel(mod); - await jobSavedObjectService.createTrainedModel(modelId, job); + await mlSavedObjectService.createTrainedModel(modelId, job); results.savedObjectsCreated[type]![modelId] = { success: true, }; @@ -159,9 +159,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteAnomalyDetectionJob(jobId, namespaces[0]); + await mlSavedObjectService.forceDeleteAnomalyDetectionJob(jobId, namespaces[0]); } else { - await jobSavedObjectService.deleteAnomalyDetectionJob(jobId); + await mlSavedObjectService.deleteAnomalyDetectionJob(jobId); } results.savedObjectsDeleted[type]![job.jobId] = { success: true }; } catch (error) { @@ -189,9 +189,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteDataFrameAnalyticsJob(jobId, namespaces[0]); + await mlSavedObjectService.forceDeleteDataFrameAnalyticsJob(jobId, namespaces[0]); } else { - await jobSavedObjectService.deleteDataFrameAnalyticsJob(jobId); + await mlSavedObjectService.deleteDataFrameAnalyticsJob(jobId); } results.savedObjectsDeleted[type]![job.jobId] = { success: true, @@ -223,9 +223,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteTrainedModel(modelId, namespaces[0]); + await mlSavedObjectService.forceDeleteTrainedModel(modelId, namespaces[0]); } else { - await jobSavedObjectService.deleteTrainedModel(modelId); + await mlSavedObjectService.deleteTrainedModel(modelId); } results.savedObjectsDeleted[type]![modelId] = { success: true, @@ -266,7 +266,7 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (datafeedId !== undefined) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); } results.datafeedsAdded[type]![job.jobId] = { success: true }; } catch (error) { @@ -294,7 +294,7 @@ export function syncSavedObjectsFactory( const datafeedId = job.datafeedId; tasks.push(async () => { try { - await jobSavedObjectService.deleteDatafeed(datafeedId); + await mlSavedObjectService.deleteDatafeed(datafeedId); results.datafeedsRemoved[type]![job.jobId] = { success: true }; } catch (error) { results.datafeedsRemoved[type]![job.jobId] = { @@ -408,7 +408,7 @@ export function syncSavedObjectsFactory( try { // create missing job saved objects - const createJobsResults = await jobSavedObjectService.bulkCreateJobs(jobObjects); + const createJobsResults = await mlSavedObjectService.bulkCreateJobs(jobObjects); createJobsResults.saved_objects.forEach(({ attributes }) => { results.jobs.push({ id: attributes.job_id, @@ -418,7 +418,7 @@ export function syncSavedObjectsFactory( // create missing datafeed ids for (const { jobId, datafeedId } of datafeeds) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); results.datafeeds.push({ id: datafeedId, type: 'anomaly-detector', @@ -426,7 +426,7 @@ export function syncSavedObjectsFactory( } // use * space if no spaces for related jobs can be found. - const createModelsResults = await jobSavedObjectService.bulkCreateTrainedModel( + const createModelsResults = await mlSavedObjectService.bulkCreateTrainedModel( modelObjects, '*' ); @@ -443,15 +443,17 @@ export function syncSavedObjectsFactory( return results; } - async function isSyncNeeded(jobType?: JobType | TrainedModelType) { + async function isSyncNeeded(mlSavedObjectType?: MlSavedObjectType) { const { jobs, datafeeds, trainedModels } = await initSavedObjects(true); const missingJobs = - jobs.length > 0 && (jobType === undefined || jobs.some(({ type }) => type === jobType)); + jobs.length > 0 && + (mlSavedObjectType === undefined || jobs.some(({ type }) => type === mlSavedObjectType)); const missingModels = - trainedModels.length > 0 && (jobType === undefined || jobType === 'trained-model'); + trainedModels.length > 0 && + (mlSavedObjectType === undefined || mlSavedObjectType === 'trained-model'); - const missingDatafeeds = datafeeds.length > 0 && jobType !== 'data-frame-analytics'; + const missingDatafeeds = datafeeds.length > 0 && mlSavedObjectType !== 'data-frame-analytics'; return missingJobs || missingModels || missingDatafeeds; } diff --git a/x-pack/plugins/ml/server/saved_objects/sync_task.ts b/x-pack/plugins/ml/server/saved_objects/sync_task.ts index eeb86cd11d0d5..ce54cc85abc68 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync_task.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync_task.ts @@ -14,7 +14,7 @@ import { } from '../../../task_manager/server'; import type { SecurityPluginSetup } from '../../../security/server'; import { savedObjectClientsFactory } from './util'; -import { jobSavedObjectServiceFactory } from './service'; +import { mlSavedObjectServiceFactory } from './service'; import { syncSavedObjectsFactory } from './sync'; const SAVED_OBJECTS_SYNC_TASK_TYPE = 'ML:saved-objects-sync'; @@ -67,7 +67,7 @@ export class SavedObjectsSyncService { throw new Error(error); } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( savedObjectsClient, savedObjectsClient, spacesEnabled, @@ -75,7 +75,7 @@ export class SavedObjectsSyncService { client, isMlReady ); - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const { jobs, trainedModels } = await initSavedObjects(false); const count = jobs.length + trainedModels.length; diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index f6a6c58fadb4e..10b07a90880de 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -38,14 +38,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.findMatches(...args); @@ -55,14 +55,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.getModule(moduleId); @@ -72,14 +72,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.listModules(); @@ -89,14 +89,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canCreateJob']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.setup( diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 16dd3cf7bec97..39ab8a43e3233 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -35,7 +35,7 @@ import { MLUISettingsClientUninitialized, } from './errors'; import { MlClient, getMlClient } from '../lib/ml_client'; -import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; +import { mlSavedObjectServiceFactory, MLSavedObjectService } from '../saved_objects'; import { getAlertingServiceProvider, MlAlertingServiceProvider, @@ -76,7 +76,7 @@ export interface SharedServicesChecks { interface OkParams { scopedClient: IScopedClusterClient; mlClient: MlClient; - jobSavedObjectService: JobSavedObjectService; + mlSavedObjectService: MLSavedObjectService; getFieldsFormatRegistry: FieldFormatsRegistryProvider; getDataViewsService: GetDataViewsService; } @@ -126,7 +126,7 @@ export function createSharedServices( hasMlCapabilities, scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, } = getRequestItems(request); @@ -150,7 +150,7 @@ export function createSharedServices( return callback({ scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, }); @@ -202,7 +202,7 @@ function getRequestItemsProvider( // instead a dummy request object will be supplied const clusterClient = getClusterClient(); const getSobSavedObjectService = (client: IScopedClusterClient) => { - return jobSavedObjectServiceFactory( + return mlSavedObjectServiceFactory( savedObjectsClient, internalSavedObjectsClient, spaceEnabled, @@ -238,12 +238,12 @@ function getRequestItemsProvider( return fieldFormatRegistry; }; - let jobSavedObjectService; + let mlSavedObjectService; if (request instanceof KibanaRequest) { hasMlCapabilities = getHasMlCapabilities(request); scopedClient = clusterClient.asScoped(request); - jobSavedObjectService = getSobSavedObjectService(scopedClient); - mlClient = getMlClient(scopedClient, jobSavedObjectService); + mlSavedObjectService = getSobSavedObjectService(scopedClient); + mlClient = getMlClient(scopedClient, mlSavedObjectService); } else { hasMlCapabilities = () => Promise.resolve(); const { asInternalUser } = clusterClient; @@ -251,8 +251,8 @@ function getRequestItemsProvider( asInternalUser, asCurrentUser: asInternalUser, }; - jobSavedObjectService = getSobSavedObjectService(scopedClient); - mlClient = getMlClient(scopedClient, jobSavedObjectService); + mlSavedObjectService = getSobSavedObjectService(scopedClient); + mlClient = getMlClient(scopedClient, mlSavedObjectService); } const getDataViewsService = getDataViewsServiceFactory( @@ -266,7 +266,7 @@ function getRequestItemsProvider( hasMlCapabilities, scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, }; diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts index fedf27f2cc164..4ee7f6966b64a 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import { JobType } from '../../../../../plugins/ml/common/types/saved_objects'; +import { MlSavedObjectType } from '../../../../../plugins/ml/common/types/saved_objects'; export default ({ getService }: FtrProviderContext) => { const ml = getService('ml'); @@ -22,7 +22,7 @@ export default ({ getService }: FtrProviderContext) => { const idSpace2 = 'space2'; async function runRequest( - jobType: JobType, + mlSavedObjectType: MlSavedObjectType, ids: string[], user: USER, expectedStatusCode: number, @@ -32,7 +32,7 @@ export default ({ getService }: FtrProviderContext) => { .post( `${ space ? `/s/${space}` : '' - }/api/ml/saved_objects/can_delete_ml_space_aware_item/${jobType}` + }/api/ml/saved_objects/can_delete_ml_space_aware_item/${mlSavedObjectType}` ) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts index 88f93496c5ad6..149d75429d977 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts @@ -10,7 +10,7 @@ import { cloneDeep } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import { JobType, TrainedModelType } from '../../../../../plugins/ml/common/types/saved_objects'; +import { MlSavedObjectType } from '../../../../../plugins/ml/common/types/saved_objects'; export default ({ getService }: FtrProviderContext) => { const ml = getService('ml'); @@ -35,14 +35,14 @@ export default ({ getService }: FtrProviderContext) => { async function runSyncCheckRequest( user: USER, - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, expectedStatusCode: number ) { const { body, status } = await supertest .post(`/s/${idSpace1}/api/ml/saved_objects/sync_check`) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) - .send({ jobType }); + .send({ mlSavedObjectType }); ml.api.assertResponseStatusCode(expectedStatusCode, status, body); return body; From 1a215ba08a0dcb5debdcecd760e998588b8fcb99 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 21 Mar 2022 15:25:31 +0000 Subject: [PATCH 32/38] skip flaky suite (#118479) --- x-pack/test/fleet_api_integration/apis/epm/setup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 253e19c2db1b1..8dc12750b8109 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -21,7 +21,8 @@ export default function (providerContext: FtrProviderContext) { describe('setup api', async () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); - describe('setup performs upgrades', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/118479 + describe.skip('setup performs upgrades', async () => { const oldEndpointVersion = '0.13.0'; beforeEach(async () => { const url = '/api/fleet/epm/packages/endpoint'; From 2adb417a9b1c826bbb08bdc25dded8129ba6370e Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Mon, 21 Mar 2022 16:44:09 +0100 Subject: [PATCH 33/38] [Workplace Search] Remove unused kibana_host parameter (#128077) * [Workplace Search] Remove unused kibana_host parameter --- .../workplace_search/constants.ts | 2 -- .../add_source/add_source_logic.test.ts | 26 ++------------ .../components/add_source/add_source_logic.ts | 36 ++++++------------- .../server/routes/workplace_search/sources.ts | 9 ----- 4 files changed, 14 insertions(+), 59 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index e83430504b389..dba459df04380 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -363,8 +363,6 @@ export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_ export const CUSTOM_SERVICE_TYPE = 'custom'; export const EXTERNAL_SERVICE_TYPE = 'external'; -export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search'; - export const DOCUMENTATION_LINK_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.documentation', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index ca87459e1e6fb..21246defbb863 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -362,7 +362,6 @@ describe('AddSourceLogic', () => { expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/sources/create', { query: { ...params, - kibana_host: '', }, }); @@ -401,7 +400,6 @@ describe('AddSourceLogic', () => { expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/sources/create', { query: { ...params, - kibana_host: '', }, }); @@ -484,7 +482,6 @@ describe('AddSourceLogic', () => { const query = { index_permissions: false, - kibana_host: '', }; expect(clearFlashMessages).toHaveBeenCalled(); @@ -508,7 +505,6 @@ describe('AddSourceLogic', () => { const query = { index_permissions: true, - kibana_host: '', subdomain: 'subdomain', }; @@ -536,12 +532,7 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions.getSourceReConnectData('github'); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/reauth_prepare', - { - query: { - kibana_host: '', - }, - } + '/internal/workplace_search/org/sources/github/reauth_prepare' ); await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); @@ -725,17 +716,11 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - const query = { - kibana_host: '', - }; - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); expect(http.get).toHaveBeenCalledWith( '/internal/workplace_search/account/sources/github/prepare', - { - query, - } + { query: { index_permissions: false } } ); }); @@ -743,12 +728,7 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions.getSourceReConnectData('123'); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/123/reauth_prepare', - { - query: { - kibana_host: '', - }, - } + '/internal/workplace_search/account/sources/123/reauth_prepare' ); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 4c4c47b5a48c7..8693cffc17e21 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -10,7 +10,6 @@ import { kea, MakeLogicType } from 'kea'; import { keys, pickBy } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { HttpFetchQuery } from 'src/core/public'; import { flashAPIErrors, @@ -21,7 +20,6 @@ import { import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; -import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; @@ -156,15 +154,6 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -/** - * Workplace Search needs to know the host for the redirect. As of yet, we do not - * have access to this in Kibana. We parse it from the browser and pass it as a param. - */ -const { - location: { href }, -} = window; -const kibanaHost = href.substr(0, href.indexOf(WORKPLACE_SEARCH_URL_PREFIX)); - export const AddSourceLogic = kea>({ path: ['enterprise_search', 'workplace_search', 'add_source_logic'], actions: { @@ -383,16 +372,17 @@ export const AddSourceLogic = kea(route, { query }); + const response = await HttpLogic.values.http.get(route, { + query, + }); actions.setSourceConnectData(response); successCallback(response.oauthUrl); } catch (e) { @@ -407,12 +397,8 @@ export const AddSourceLogic = kea(route, { query }); + const response = await HttpLogic.values.http.get(route); actions.setSourceConnectData(response); } catch (e) { flashAPIErrors(e); @@ -497,7 +483,7 @@ export const AddSourceLogic = kea Date: Mon, 21 Mar 2022 10:56:43 -0500 Subject: [PATCH 34/38] [data view mgmt] Delete data views from list (#127557) * Delete data views from list Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_index_pattern/edit_index_pattern.tsx | 86 +++--- .../components/edit_index_pattern/index.tsx | 2 + .../edit_index_pattern/remove_data_view.tsx | 48 +++ .../delete_modal_msg.test.tsx.snap | 274 ++++++++++++++++++ .../delete_modal_msg.test.tsx | 28 ++ .../index_pattern_table/delete_modal_msg.tsx | 75 +++++ .../index_pattern_table.tsx | 88 +++++- 7 files changed, 545 insertions(+), 56 deletions(-) create mode 100644 src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx create mode 100644 src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap create mode 100644 src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx create mode 100644 src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx index f8a26baceb615..6a7663d9d2f01 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { filter } from 'lodash'; import React, { useEffect, useState, useCallback } from 'react'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { @@ -22,11 +21,12 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { DataView, DataViewField } from '../../../../../plugins/data_views/public'; -import { useKibana, toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { useKibana } from '../../../../../plugins/kibana_react/public'; import { IndexPatternManagmentContext } from '../../types'; import { Tabs } from './tabs'; import { IndexHeader } from './index_header'; import { getTags } from '../utils'; +import { removeDataView, RemoveDataViewProps } from './remove_data_view'; export interface EditIndexPatternProps extends RouteComponentProps { indexPattern: DataView; @@ -46,15 +46,6 @@ const mappingConflictHeader = i18n.translate( } ); -const confirmModalOptionsDelete = { - confirmButtonText: i18n.translate('indexPatternManagement.editIndexPattern.deleteButton', { - defaultMessage: 'Delete', - }), - title: i18n.translate('indexPatternManagement.editDataView.deleteHeader', { - defaultMessage: 'Delete data view', - }), -}; - const securityDataView = i18n.translate( 'indexPatternManagement.editIndexPattern.badge.securityDataViewTitle', { @@ -91,47 +82,14 @@ export const EditIndexPattern = withRouter( setDefaultIndex(indexPattern.id || ''); }, [uiSettings, indexPattern.id]); - const removePattern = () => { - async function doRemove() { - if (indexPattern.id === defaultIndex) { - const indexPatterns = await dataViews.getIdsWithTitle(); - uiSettings.remove('defaultIndex'); - const otherPatterns = filter(indexPatterns, (pattern) => { - return pattern.id !== indexPattern.id; - }); - - if (otherPatterns.length) { - uiSettings.set('defaultIndex', otherPatterns[0].id); - } - } - if (indexPattern.id) { - Promise.resolve(dataViews.delete(indexPattern.id)).then(function () { - history.push(''); - }); - } - } - - const warning = - indexPattern.namespaces.length > 1 || indexPattern.namespaces.includes('*') ? ( - {indexPattern.title}, - }} - /> - ) : ( - '' - ); - - overlays - .openConfirm(toMountPoint(

{warning}
), confirmModalOptionsDelete) - .then((isConfirmed) => { - if (isConfirmed) { - doRemove(); - } - }); - }; + const removeHandler = removeDataView({ + dataViews, + uiSettings, + overlays, + onDelete: () => { + history.push(''); + }, + }); const timeFilterHeader = i18n.translate( 'indexPatternManagement.editIndexPattern.timeFilterHeader', @@ -161,12 +119,34 @@ export const EditIndexPattern = withRouter( const docsUrl = kibana.services.docLinks!.links.elasticsearch.mapping; const userEditPermission = dataViews.getCanSaveSync(); + const warning = + (indexPattern.namespaces && indexPattern.namespaces.length > 1) || + indexPattern.namespaces.includes('*') ? ( + {indexPattern.title}, + }} + /> + ) : ( + {indexPattern.title}, + }} + /> + ); + return (
+ removeHandler([indexPattern as RemoveDataViewProps],
{warning}
) + } defaultIndex={defaultIndex} canSave={userEditPermission} > diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx index 7c8b4e5eef31f..71903ae6d1fb1 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx @@ -10,3 +10,5 @@ export { EditIndexPattern } from './edit_index_pattern'; export { EditIndexPatternContainer } from './edit_index_pattern_container'; export { CreateEditField } from './create_edit_field'; export { CreateEditFieldContainer } from './create_edit_field/create_edit_field_container'; +export type { RemoveDataViewProps } from './remove_data_view'; +export { removeDataView } from './remove_data_view'; diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx new file mode 100644 index 0000000000000..9db99e0428463 --- /dev/null +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx @@ -0,0 +1,48 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { IUiSettingsClient, OverlayStart } from 'src/core/public'; +import { asyncForEach } from '@kbn/std'; +import { EuiConfirmModalProps } from '@elastic/eui'; +import { toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { DataViewsPublicPluginStart } from '../../../../../plugins/data_views/public'; + +const confirmModalOptionsDelete = { + confirmButtonText: i18n.translate('indexPatternManagement.editIndexPattern.deleteButton', { + defaultMessage: 'Delete', + }), + title: i18n.translate('indexPatternManagement.editDataView.deleteHeader', { + defaultMessage: 'Delete data view', + }), + buttonColor: 'danger' as EuiConfirmModalProps['buttonColor'], +}; + +export interface RemoveDataViewProps { + id: string; + title: string; + namespaces?: string[] | undefined; +} + +interface RemoveDataViewDeps { + dataViews: DataViewsPublicPluginStart; + uiSettings: IUiSettingsClient; + overlays: OverlayStart; + onDelete: () => void; +} + +export const removeDataView = + ({ dataViews, overlays, onDelete }: RemoveDataViewDeps) => + (dataViewArray: RemoveDataViewProps[], msg: JSX.Element) => { + overlays.openConfirm(toMountPoint(msg), confirmModalOptionsDelete).then(async (isConfirmed) => { + if (isConfirmed) { + await asyncForEach(dataViewArray, async ({ id }) => dataViews.delete(id)); + onDelete(); + } + }); + }; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap b/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap new file mode 100644 index 0000000000000..233c859f0a15e --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`delete modal content render 1`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 2`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 3`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 4`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx new file mode 100644 index 0000000000000..c6084cb219b0d --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { deleteModalMsg } from './delete_modal_msg'; + +describe('delete modal content', () => { + const toDVProps = (title: string, namespaces: string[]) => { + return { + id: '1', + title, + namespaces, + }; + }; + + test('render', () => { + expect(deleteModalMsg([toDVProps('logstash-*', ['a', 'b', 'c'])], true)).toMatchSnapshot(); + expect(deleteModalMsg([toDVProps('logstash-*', ['a', 'b', 'c'])], false)).toMatchSnapshot(); + expect(deleteModalMsg([toDVProps('logstash-*', ['*'])], true)).toMatchSnapshot(); + expect( + deleteModalMsg([toDVProps('logstash-*', ['*']), toDVProps('log*', ['a', 'b', 'c'])], true) + ).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx new file mode 100644 index 0000000000000..55716c5aecdc4 --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiCallOut, EuiTableFieldDataColumnType, EuiBasicTable, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { RemoveDataViewProps } from '../edit_index_pattern'; + +const all = i18n.translate('indexPatternManagement.dataViewTable.spaceCountAll', { + defaultMessage: 'all', +}); + +const dataViewColumnName = i18n.translate( + 'indexPatternManagement.dataViewTable.dataViewColumnName', + { + defaultMessage: 'Data view', + } +); + +const spacesColumnName = i18n.translate('indexPatternManagement.dataViewTable.spacesColumnName', { + defaultMessage: 'Spaces', +}); + +const tableTitle = i18n.translate('indexPatternManagement.dataViewTable.tableTitle', { + defaultMessage: 'Data views selected for deletion', +}); + +export const deleteModalMsg = (views: RemoveDataViewProps[], hasSpaces: boolean) => { + const columns: Array> = [ + { + field: 'title', + name: dataViewColumnName, + sortable: true, + }, + ]; + if (hasSpaces) { + columns.push({ + field: 'namespaces', + name: spacesColumnName, + sortable: true, + width: '100px', + align: 'right', + render: (namespaces: string[]) => (namespaces.indexOf('*') !== -1 ? all : namespaces.length), + }); + } + + return ( +
+ + +
+ +
+ + +
+ ); +}; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx index d18e4d6f81fa5..a07be274f34ba 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -13,6 +13,7 @@ import { EuiInMemoryTable, EuiPageHeader, EuiSpacer, + EuiBasicTableColumn, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { RouteComponentProps, withRouter, useLocation } from 'react-router-dom'; @@ -25,6 +26,8 @@ import { getIndexPatterns } from '../utils'; import { getListBreadcrumbs } from '../breadcrumbs'; import { SpacesList } from './spaces_list'; import type { SpacesContextProps } from '../../../../../../x-pack/plugins/spaces/public'; +import { removeDataView, RemoveDataViewProps } from '../edit_index_pattern'; +import { deleteModalMsg } from './delete_modal_msg'; const pagination = { initialPageSize: 10, @@ -71,11 +74,13 @@ export const IndexPatternTable = ({ dataViews, IndexPatternEditor, spaces, + overlays, } = useKibana().services; const [query, setQuery] = useState(''); const [indexPatterns, setIndexPatterns] = useState([]); const [isLoadingIndexPatterns, setIsLoadingIndexPatterns] = useState(true); const [showCreateDialog, setShowCreateDialog] = useState(showCreateDialogProp); + const [selectedItems, setSelectedItems] = useState([]); const handleOnChange = ({ queryText, error }: { queryText: string; error: unknown }) => { if (!error) { @@ -83,7 +88,38 @@ export const IndexPatternTable = ({ } }; + const renderDeleteButton = () => { + const clickHandler = removeDataView({ + dataViews, + overlays, + uiSettings, + onDelete: () => loadDataViews(), + }); + if (selectedItems.length === 0) { + return; + } + return ( + clickHandler(selectedItems, deleteModalMsg(selectedItems, !!spaces))} + > + + + ); + }; + + const deleteButton = renderDeleteButton(); + const search = { + toolsLeft: deleteButton && [deleteButton], query, onChange: handleOnChange, box: { @@ -127,12 +163,46 @@ export const IndexPatternTable = ({ [spaces] ); - const columns = [ + const removeHandler = removeDataView({ + dataViews, + uiSettings, + overlays, + onDelete: () => loadDataViews(), + }); + + const alertColumn = { + name: 'Actions', + field: 'id', + width: '10%', + actions: [ + { + name: i18n.translate('indexPatternManagement.dataViewTable.columnDelete', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'indexPatternManagement.dataViewTable.columnDeleteDescription', + { + defaultMessage: 'Delete this data view', + } + ), + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: (dataView: RemoveDataViewProps) => + removeHandler([dataView], deleteModalMsg([dataView], !!spaces)), + isPrimary: true, + 'data-test-subj': 'action-delete', + }, + ], + }; + + const columns: Array> = [ { field: 'title', name: i18n.translate('indexPatternManagement.dataViewTable.nameColumn', { defaultMessage: 'Name', }), + width: '70%', render: (name: string, dataView: IndexPatternTableItem) => (
{name} @@ -153,7 +223,10 @@ export const IndexPatternTable = ({ }, { field: 'namespaces', - name: 'spaces', + name: i18n.translate('indexPatternManagement.dataViewTable.spacesColumn', { + defaultMessage: 'Spaces', + }), + width: '20%', render: (name: string, dataView: IndexPatternTableItem) => { return spaces ? ( ); + const selection = { + onSelectionChange: setSelectedItems, + }; + return (
{displayIndexPatternEditor} From 384626bd0b8048f3a8361bdee6624950a657256a Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Mon, 21 Mar 2022 10:06:12 -0600 Subject: [PATCH 35/38] [dev_docs] Fixes broken REST API template links (#128069) ## Summary The REST API template links on the main [Documentation page](https://docs.elastic.dev/kibana-dev-docs/contributing/documentation#rest-apis) are broken. They're pointing to the docs `main` branch and the `docs` repo is still on `master` at this moment. Old Broken Links: - [API doc template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-ref-ex.asciidoc) - [API object definition template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-definitions-ex.asciidoc) New Fixed Links: - [API doc template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc) - [API object definition template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-definitions-ex.asciidoc) --- dev_docs/contributing/documentation.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev_docs/contributing/documentation.mdx b/dev_docs/contributing/documentation.mdx index ad9286dd07ab8..caf2f439548bc 100644 --- a/dev_docs/contributing/documentation.mdx +++ b/dev_docs/contributing/documentation.mdx @@ -24,8 +24,8 @@ node scripts/docs.js --open ## REST APIs REST APIs should be documented using the following formats: -- [API doc template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-ref-ex.asciidoc) -- [API object definition template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-definitions-ex.asciidoc) +- [API doc template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc) +- [API object definition template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-definitions-ex.asciidoc) ## Developer documentation From 35e3dd00e7b10d22f5ca4ca7531dff5b5cee160a Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Mon, 21 Mar 2022 09:17:23 -0700 Subject: [PATCH 36/38] Hides unknown uiSettings from the advanced settings page (#128030) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../management_app/advanced_settings.test.tsx | 29 +++++++++++++++++++ .../management_app/advanced_settings.tsx | 1 + 2 files changed, 30 insertions(+) diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx index 7b294c609f31e..5ee1bbf49f56c 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx @@ -268,6 +268,35 @@ describe('AdvancedSettings', () => { ).toHaveLength(1); }); + it('should should not render a custom setting', async () => { + // The manual mock for the uiSettings client returns false for isConfig, override that + const uiSettings = mockConfig().core.uiSettings; + uiSettings.isCustom = (key) => true; + + const customSettingQuery = 'test:customstring:setting'; + mockQuery(customSettingQuery); + const component = mountWithI18nProvider( + + ); + + expect( + component + .find('Field') + .filterWhere( + (n: ReactWrapper) => + (n.prop('setting') as Record).name === customSettingQuery + ) + ).toEqual({}); + }); + it('should render read-only when saving is disabled', async () => { mockQuery(); const component = mountWithI18nProvider( diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index ba3bb49790627..e46dfdf50956b 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -198,6 +198,7 @@ export class AdvancedSettings extends Component !c.readOnly) + .filter((c) => !c.isCustom) // hide any settings that aren't explicitly registered by enabled plugins. .sort(fieldSorter); } From ee62fe87be38128297c50cf1e7d108cbba7f1980 Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Mon, 21 Mar 2022 21:23:07 +0500 Subject: [PATCH 37/38] [Discover] Add documentation links for Document Explorer (#127971) * [Discover] add document explorer docs links * [Discover] fix tests * [Discover] update translations Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + ...tsx => document_explorer_callout.test.tsx} | 1 + .../document_explorer_callout.tsx | 50 +++++++++++++------ src/plugins/discover/server/ui_settings.ts | 11 +++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 48 insertions(+), 17 deletions(-) rename src/plugins/discover/public/application/main/components/document_explorer_callout/{document_explorere_callout.test.tsx => document_explorer_callout.test.tsx} (96%) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index de8ac8bd5672b..b2a8fe557bbfa 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -66,6 +66,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { discover: { guide: `${KIBANA_DOCS}discover.html`, fieldStatistics: `${KIBANA_DOCS}show-field-statistics.html`, + documentExplorer: `${KIBANA_DOCS}document-explorer.html`, }, filebeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}`, diff --git a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx similarity index 96% rename from src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx rename to src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx index 3e6b8e3973001..cfe04094fd6f6 100644 --- a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx +++ b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx @@ -15,6 +15,7 @@ import { DiscoverServices } from '../../../../build_services'; const defaultServices = { addBasePath: () => '', + docLinks: { links: { discover: { documentExplorer: '' } } }, capabilities: { advancedSettings: { save: true } }, storage: new LocalStorageMock({ [CALLOUT_STATE_KEY]: false }), } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx index 6a256ef4e24c9..73fc7cdf9a105 100644 --- a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx +++ b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx @@ -10,7 +10,14 @@ import React, { useCallback, useState } from 'react'; import './document_explorer_callout.scss'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiButtonIcon, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { DOC_TABLE_LEGACY } from '../../../../../common'; import { Storage } from '../../../../../../kibana_utils/public'; @@ -26,7 +33,7 @@ const updateStoredCalloutState = (newState: boolean, storage: Storage) => { }; export const DocumentExplorerCallout = () => { - const { storage, capabilities, addBasePath } = useDiscoverServices(); + const { storage, capabilities, docLinks, addBasePath } = useDiscoverServices(); const [calloutClosed, setCalloutClosed] = useState(getStoredCalloutState(storage)); const onCloseCallout = useCallback(() => { @@ -50,18 +57,33 @@ export const DocumentExplorerCallout = () => { defaultMessage="Quickly sort, select, and compare data, resize columns, and view documents in fullscreen with the Document Explorer." />

-

- - - -

+ + + + + + + + + + + + ); }; diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index afd70cc5bbee7..b3e955c592ad2 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -167,8 +167,17 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record` + + i18n.translate('discover.advancedSettings.documentExplorerLinkText', { + defaultMessage: 'Document Explorer', + }) + + '', + }, }), category: ['discover'], schema: schema.boolean(), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 091f13cf31d48..5a710d8f2c0df 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2655,7 +2655,6 @@ "discover.advancedSettings.defaultColumnsText": "デフォルトでDiscoverアプリに表示される列。空の場合、ドキュメントの概要が表示されます。", "discover.advancedSettings.defaultColumnsTitle": "デフォルトの列", "discover.advancedSettings.disableDocumentExplorer": "ドキュメントエクスプローラーまたはクラシックビュー", - "discover.advancedSettings.disableDocumentExplorerDescription": "クラシックビューではなく、ドキュメントエクスプローラーを使用するには、このオプションをオフにします。ドキュメントエクスプローラーでは、データの並べ替え、列のサイズ変更、全画面表示といった優れた機能を使用できます。", "discover.advancedSettings.discover.fieldStatisticsLinkText": "フィールド統計情報ビュー", "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "新しいデータビューで使用できない列を削除します。", "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "データビューを変更するときに列を修正", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f0430e8571857..15ab16a10fe54 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2662,7 +2662,6 @@ "discover.advancedSettings.defaultColumnsText": "Discover 应用中默认显示的列。如果为空,将显示文档摘要。", "discover.advancedSettings.defaultColumnsTitle": "默认列", "discover.advancedSettings.disableDocumentExplorer": "Document Explorer 或经典视图", - "discover.advancedSettings.disableDocumentExplorerDescription": "要使用新的 Document Explorer,而非经典视图,请关闭此选项。Document Explorer 提供了更合理的数据排序、可调整大小的列和全屏视图。", "discover.advancedSettings.discover.fieldStatisticsLinkText": "字段统计信息视图", "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "移除新数据视图中不存在的列。", "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "在更改数据视图时修改列", From 2f5fcf7b6db976d1a6aa3701c7ad4c88108b4fd3 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 21 Mar 2022 12:33:24 -0400 Subject: [PATCH 38/38] [Fleet] Make host|url required when creating an output (#128166) --- .../output_form_validators.test.tsx | 10 ++++++-- .../output_form_validators.tsx | 23 +++++++++++++++---- .../fleet/server/types/models/output.ts | 8 +++++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 7f414a8f12deb..57b8681e834bb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -14,10 +14,10 @@ import { describe('Output form validation', () => { describe('validateESHosts', () => { - it('should work without any urls', () => { + it('should not work without any urls', () => { const res = validateESHosts([]); - expect(res).toBeUndefined(); + expect(res).toEqual([{ message: 'URL is required' }]); }); it('should work with valid url', () => { @@ -57,6 +57,12 @@ describe('Output form validation', () => { }); describe('validateLogstashHosts', () => { + it('should not work without any urls', () => { + const res = validateLogstashHosts([]); + + expect(res).toEqual([{ message: 'Host is required' }]); + }); + it('should work for valid hosts', () => { const res = validateLogstashHosts(['test.fr:5044']); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index 33ee3f9678cc8..13b90fe661f61 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { safeLoad } from 'js-yaml'; export function validateESHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -46,13 +46,21 @@ export function validateESHosts(value: string[]) { ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.elasticUrlRequiredError', { + defaultMessage: 'URL is required', + }), + }); + } + if (res.length) { return res; } } export function validateLogstashHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -89,13 +97,20 @@ export function validateLogstashHosts(value: string[]) { .forEach((indexes) => { indexes.forEach((index) => res.push({ - message: i18n.translate('xpack.fleet.settings.outputForm.elasticHostDuplicateError', { - defaultMessage: 'Duplicate URL', + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostDuplicateError', { + defaultMessage: 'Duplicate Host', }), index, }) ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostRequiredError', { + defaultMessage: 'Host is required', + }), + }); + } if (res.length) { return res; diff --git a/x-pack/plugins/fleet/server/types/models/output.ts b/x-pack/plugins/fleet/server/types/models/output.ts index ee7854ade30a8..86b2a70a318fc 100644 --- a/x-pack/plugins/fleet/server/types/models/output.ts +++ b/x-pack/plugins/fleet/server/types/models/output.ts @@ -35,8 +35,12 @@ const OutputBaseSchema = { hosts: schema.conditional( schema.siblingRef('type'), schema.literal(outputType.Elasticsearch), - schema.arrayOf(schema.uri({ scheme: ['http', 'https'] })), - schema.arrayOf(schema.string({ validate: validateLogstashHost })) + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + minSize: 1, + }), + schema.arrayOf(schema.string({ validate: validateLogstashHost }), { + minSize: 1, + }) ), is_default: schema.boolean({ defaultValue: false }), is_default_monitoring: schema.boolean({ defaultValue: false }),