diff --git a/.backportrc.json b/.backportrc.json index db7ad3b0eb887..94c2549418f17 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -3,6 +3,7 @@ "repoName": "kibana", "targetBranchChoices": [ "main", + "8.5", "8.4", "8.3", "8.2", @@ -41,7 +42,7 @@ "backport" ], "branchLabelMapping": { - "^v8.5.0$": "main", + "^v8.6.0$": "main", "^v(\\d+).(\\d+).\\d+$": "$1.$2" }, "autoMerge": true, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c78de33572f14..93cf5cc23dde2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -937,6 +937,7 @@ packages/kbn-utility-types-jest @elastic/kibana-operations packages/kbn-utils @elastic/kibana-operations packages/kbn-yarn-lock-validator @elastic/kibana-operations packages/shared-ux/avatar/solution @elastic/shared-ux +packages/shared-ux/avatar/user_profile/impl @elastic/shared-ux packages/shared-ux/button_toolbar @elastic/shared-ux packages/shared-ux/button/exit_full_screen/impl @elastic/shared-ux packages/shared-ux/button/exit_full_screen/mocks @elastic/shared-ux @@ -966,6 +967,9 @@ packages/shared-ux/page/solution_nav @elastic/shared-ux packages/shared-ux/prompt/no_data_views/impl @elastic/shared-ux packages/shared-ux/prompt/no_data_views/mocks @elastic/shared-ux packages/shared-ux/prompt/no_data_views/types @elastic/shared-ux +packages/shared-ux/router/impl @elastic/shared-ux +packages/shared-ux/router/mocks @elastic/shared-ux +packages/shared-ux/router/types @elastic/shared-ux packages/shared-ux/storybook/config @elastic/shared-ux packages/shared-ux/storybook/mock @elastic/shared-ux x-pack/packages/ml/agg_utils @elastic/ml-ui diff --git a/docs/api/actions-and-connectors/execute.asciidoc b/docs/api/actions-and-connectors/execute.asciidoc index e477d7237c273..b5c59bb86bc70 100644 --- a/docs/api/actions-and-connectors/execute.asciidoc +++ b/docs/api/actions-and-connectors/execute.asciidoc @@ -437,6 +437,60 @@ the security incident. The IPs are added as observables to the security incident `message`:: (Required, string) The message to log. ===== + +.{swimlane} connectors +[%collapsible%open] +===== +`subAction`:: +(Required, string) The action to test. It must be `pushToService`. + +`subActionParams`:: +(Required, object) The set of configuration properties. ++ +.Properties of `subActionParams` +[%collapsible%open] +====== +`comments`::: +(Optional, array of objects) Additional information that is sent to {swimlane}. ++ +.Properties of `comments` objects +[%collapsible%open] +======= +comment:::: +(string) A comment related to the incident. For example, describe how to +troubleshoot the issue. + +commentId:::: +(integer) A unique identifier for the comment. + +======= + +`incident`::: +(Required, object) Information necessary to create or update a {swimlane} incident. ++ +.Properties of `incident` +[%collapsible%open] +======= +`alertId`:::: +(Optional, string) The alert identifier. + +`caseId`:::: +(Optional, string) The case identifier for the incident. + +`caseName`:::: +(Optional, string) The case name for the incident. + +`description`:::: +(Optional, string) The description of the incident. + +`ruleName`:::: +(Optional, string) The rule name. + +`severity`:::: +(Optional, string) The severity of the incident. +======= +====== +===== ==== -- @@ -549,6 +603,41 @@ The API returns the following: } -------------------------------------------------- +Create then update a {swimlane} incident: +[source,sh] +-------------------------------------------------- +POST api/actions/connector/a4746470-2f94-11ed-b0e0-87533c532698/_execute +{ + "params":{ + "subAction":"pushToService", + "subActionParams":{ + "incident":{ + "description":"Description of the incident", + "caseName":"Case name", + "caseId":"1000" + }, + "comments":[ + {"commentId":"1","comment":"A comment about the incident"} + ] + } + } +} + +POST api/actions/connector/a4746470-2f94-11ed-b0e0-87533c532698/_execute +{ + "params":{ + "subAction":"pushToService", + "subActionParams":{ + "incident":{ + "caseId":"1000", + "caseName":"A new case name" + } + } + } +} +-------------------------------------------------- +// KIBANA + Retrieve the list of choices for a {sn-itom} connector: [source,sh] @@ -583,4 +672,5 @@ The API returns the severity and urgency choices, for example: {"dependent_value":"","label":"3 - Low","value":"3","element":"urgency"}], "connector_id":"9d9be270-2fd2-11ed-b0e0-87533c532698" } --------------------------------------------------- \ No newline at end of file +-------------------------------------------------- + diff --git a/docs/discover/document-explorer.asciidoc b/docs/discover/document-explorer.asciidoc index 32811cfbe7728..9547dbaf4477f 100644 --- a/docs/discover/document-explorer.asciidoc +++ b/docs/discover/document-explorer.asciidoc @@ -3,23 +3,11 @@ *Discover* displays your documents in table format, so you can -best explore your data. -Use the document table to resize columns, set row height, +best explore your data. Resize columns, set row height, perform multi-column sorting, compare data, and more. -++++ - - -
-++++ +[role="screenshot"] +image:images/customer.png[Customer last name, first initial in the document table] [float] [[document-explorer-columns]] diff --git a/docs/management/managing-saved-objects.asciidoc b/docs/management/managing-saved-objects.asciidoc index ee1247501e8da..7fccd6c6c93f7 100644 --- a/docs/management/managing-saved-objects.asciidoc +++ b/docs/management/managing-saved-objects.asciidoc @@ -1,11 +1,10 @@ [[managing-saved-objects]] -== Saved Objects +== Manage saved objects -The *Saved Objects* UI helps you keep track of and manage your saved objects. These objects -store data for later use, including dashboards, visualizations, maps, data views, -Canvas workpads, and more. +Edit, import, export, and copy your saved objects. These objects include +dashboards, visualizations, maps, {data-sources}, *Canvas* workpads, and other saved objects. -To get started, open the main menu, then click *Stack Management > Saved Objects*. +To get started, open the main menu, and then click *Stack Management > Saved Objects*. [role="screenshot"] image::images/management-saved-objects.png[Saved Objects] @@ -13,23 +12,24 @@ image::images/management-saved-objects.png[Saved Objects] [float] === Required permissions -The `Saved Objects Management` {kib} privilege is required to access the *Saved Objects* UI. +To access *Saved Objects*, you must have the required `Saved Objects Management` {kib} privilege. -To add the privilege, open the menu, then click *Stack Management > Roles*. +To add the privilege, open the main menu, and then click *Stack Management > Roles*. -NOTE: -Granting access to Saved Objects Management will authorize users to manage all saved objects in {kib}, including objects that are managed by applications they may not otherwise be authorized to access. +NOTE: Granting access to `Saved Objects Management` authorizes users to +manage all saved objects in {kib}, including objects that are managed by +applications they may not otherwise be authorized to access. [float] [[managing-saved-objects-view]] === View and delete -* To view and edit an object in its associated application, click the object title. +* To view and edit a saved object in its associated application, click the object title. * To show objects that use this object, so you know the impact of deleting it, click the actions icon image:images/actions_icon.png[Actions icon] -and select *Relationships*. +and then select *Relationships*. * To delete one or more objects, select their checkboxes, and then click *Delete*. @@ -37,58 +37,67 @@ and select *Relationships*. [[managing-saved-objects-export-objects]] === Import and export -Using the import and export actions, you can move objects between different -{kib} instances. This action is useful when you -have multiple environments for development and production. -Import and export also work well when you have a large number -of objects to update and want to batch the process. +Use import and export to move objects between different {kib} instances. +These actions are useful when you have multiple environments for development and production. +Import and export also work well when you have a large number of objects to update and want to batch the process. -In addition to the user interface, {kib} provides beta <> and <> APIs if -you want to automate this process. +{kib} also provides <> and +<> APIs to automate this process. -[float] -==== Compatibility across versions - -With each release, {kib} introduces changes to the way saved objects are stored. When importing a saved object, {kib} will run the necessary migrations to ensure that the imported saved objects are compatible with the current version. - -However, saved objects can only be imported into the same version, a newer minor on the same major, or the next major. Exported saved objects are not backwards compatible and cannot be imported into an older version of {kib}. See the table below for compatibility examples: - -|======= -| Exporting version | Importing version | Compatible? -| 6.7.0 | 6.8.1 | Yes -| 6.8.1 | 7.3.0 | Yes -| 7.3.0 | 7.11.1 | Yes -| 7.11.1 | 7.6.0 | No -| 6.8.1 | 8.0.0 | No -|======= [float] ==== Import -You can import multiple objects in a single operation. Click *Import* and -navigate to the NDJSON file that -represents the objects to import. By default, +Import multiple objects in a single operation. + +. In the toolbar, click *Import*. +. Select the NDJSON file that +includes the objects you want to import. +. Select the import options. By default, saved objects already in {kib} are overwritten. +. Click *Import*. NOTE: The <> configuration setting -limits the number of saved objects which may be included in this file. Similarly, the +limits the number of saved objects to include in the file. The <> setting limits the overall -size of the file that can be imported. +size of the file that you can import. [float] ==== Export -You have two options for exporting saved objects. +Export objects by selection or type. -* Select the checkboxes of objects that you want to export, and then click *Export*. -* Click *Export x objects*, and export objects by type. +* To export specific objects, select them in the table, and then click *Export*. +* To export objects by type, click *Export objects* in the toolbar. -This action creates an NDJSON with all your saved objects. By default, the NDJSON includes child objects that are related to the saved -objects. Exported dashboards include their associated data views. +{kib} creates an NDJSON with all your saved objects. By default, the NDJSON includes child objects related to the saved +objects. Exported dashboards include their associated {data-sources}. NOTE: The <> configuration setting -limits the number of saved objects which may be exported. +limits the number of saved objects that you can export. + +[float] +==== Compatibility across versions + +With each release, {kib} introduces changes to the way saved objects are stored. +When importing a saved object, {kib} runs the necessary migrations to ensure +that the imported saved objects are compatible with the current version. + +However, saved objects can only be imported into the same version, +a newer minor on the same major, or the next major. +Exported saved objects are not backward compatible and cannot be imported +into an older version of {kib}. For example: + +|======= +| Exporting version | Importing version | Compatible? +| 6.7.0 | 6.8.1 | Yes +| 6.8.1 | 7.3.0 | Yes +| 7.3.0 | 7.11.1 | Yes +| 7.11.1 | 7.6.0 | No +| 6.8.1 | 8.0.0 | No +|======= + [float] @@ -96,12 +105,16 @@ limits the number of saved objects which may be exported. [[managing-saved-objects-copy-to-space]] === Copy to other {kib} spaces -To copy a saved object to another space, click the actions icon image:images/actions_icon.png[Actions icon] -and select *Copy to spaces*. From here, you can select the spaces in which to copy the object. -You can also select whether to automatically overwrite any conflicts in the target spaces, or -resolve them manually. +Copy saved objects and their related objects between spaces. -WARNING: The copy operation automatically includes child objects that are related to the saved objects. If you don't want this behavior, use +. Click the actions icon image:images/actions_icon.png[Actions icon]. +. Click *Copy to spaces*. +. Select the spaces in which to copy the object. +. Specify whether to automatically overwrite any objects that already exist +in the target spaces, or resolve them on a per-object basis. ++ +The copy operation automatically includes child objects that are related to +the saved object. If you don't want this behavior, use the <> instead. [float] @@ -109,13 +122,18 @@ the <> instead. [[managing-saved-objects-share-to-space]] === Share to other {kib} spaces -To share a saved object to another space -- which makes a single saved object available in multiple spaces -- click the actions icon -image:images/actions_icon.png[Actions icon] and select *Share to spaces*. From here, you can select the spaces in which to share the object, -or indicate that you want the object to be shared to _all spaces_, which includes those that exist now and any created in the future. +Make a single saved object available in multiple spaces. -Not all saved object types are shareable. If an object is shareable, the Spaces column shows which spaces it exists in. You can also click +. Click the actions icon +image:images/actions_icon.png[Actions icon]. +. Select *Share to spaces*. +. Select the spaces in which to share the object. +Or, indicate that you want the object to be shared to _all spaces_, +which includes those that exist now and any created in the future. ++ +Not all saved object types are shareable. If an object is shareable, the *Spaces* column shows where the object exists. You can click those space icons to open the Share UI. - -WARNING: The share operation automatically includes child objects that are related to the saved objects. ++ +The share operation automatically includes child objects that are related to the saved objects. include::saved-objects/saved-object-ids.asciidoc[] diff --git a/docs/management/managing-tags.asciidoc b/docs/management/managing-tags.asciidoc index a0b3dce7f4b27..b9fbe85760786 100644 --- a/docs/management/managing-tags.asciidoc +++ b/docs/management/managing-tags.asciidoc @@ -2,8 +2,10 @@ [[managing-tags]] == Tags -Tags enable you to categorize your saved objects. -You can then filter for related objects based on shared tags. +Use tags to categorize your saved objects, +then filter for related objects based on shared tags. + +To get started, open the main menu, and then click *Stack Management > Tags*. [role="screenshot"] image::images/tags/tag-management-section.png[Tags management] @@ -29,7 +31,6 @@ from the global search. Create a tag to assign to your saved objects. -. Open the main menu, and then click *Stack Management > Tags*. . Click *Create tag*. . Enter a name and select a color for the new tag. @@ -41,23 +42,21 @@ The name cannot be longer than 50 characters. [[settings-assign-tag]] === Assign a tag to an object -To assign and remove tags from saved objects, you must have `write` permission +To assign and remove tags, you must have `write` permission on the objects to which you assign the tags. -. In the *Tags* view, find the tag you want to assign. -. Click the action menu (...) in the tag row, -and then select the *Manage assignments* action. +. Find the tag you want to assign. +. Click the actions icon +image:images/actions_icon.png[Actions icon], +and then select *Manage assignments*. . Select the objects to which you want to assign or remove tags. + [role="screenshot"] -image::images/tags/manage-assignments-flyout.png[Assign flyout] +image::images/tags/manage-assignments-flyout.png[Assign flyout, width=75%] . Click *Save tag assignments*. -TIP: To assign, delete, or clear multiple tags at once, -select their checkboxes in the *Tags* view, and then select -the desired action from the *selected tags* menu. [float] [[settings-delete-tag]] @@ -65,6 +64,11 @@ the desired action from the *selected tags* menu. When you delete a tag, you remove it from all saved objects that use it. -. Click the action menu (...) in the tag row, and then select the *Delete* action. +. Click the actions icon +image:images/actions_icon.png[Actions icon], and then select *Delete*. . Click *Delete tag*. + +TIP: To assign, delete, or clear multiple tags, +select them in the *Tags* view, and then select +the action from the *selected tags* menu. diff --git a/docs/user/graph/getting-started.asciidoc b/docs/user/graph/getting-started.asciidoc index 5e87efc5e8aca..03274bec76714 100644 --- a/docs/user/graph/getting-started.asciidoc +++ b/docs/user/graph/getting-started.asciidoc @@ -95,7 +95,7 @@ a field, select *Edit Settings*, and change *Terms per hop*. Documents that match a blocked term are not allowed in the graph. To block a term, select its vertex and click the block icon -image:user/graph/images/graph-block-button.png[Block selection] +image:user/graph/images/graph-block-button.png[Block list] in the control panel. For a list of blocked terms, go to *Settings > Blocked terms*. diff --git a/docs/user/whats-new.asciidoc b/docs/user/whats-new.asciidoc index 640a824180480..399de14d5f18c 100644 --- a/docs/user/whats-new.asciidoc +++ b/docs/user/whats-new.asciidoc @@ -1,8 +1,12 @@ [[whats-new]] -== What's new in 8.0 +== What's new in {minor-version} -This section summarizes the most important changes in each release. For the -full list, see <> and <>. +Here are the highlights of what's new and improved in {minor-version}. +For detailed information about this release, +check the <>. + +Previous versions: {kibana-ref-all}/8.4/whats-new.html[8.4] | {kibana-ref-all}/8.3/whats-new.html[8.3] | {kibana-ref-all}/8.2/whats-new.html[8.2] +| {kibana-ref-all}/8.1/whats-new.html[8.1] | {kibana-ref-all}/8.0/whats-new.html[8.0] //NOTE: The notable-highlights tagged regions are re-used in the //Installation and Upgrade Guide diff --git a/examples/guided_onboarding_example/public/components/step_two.tsx b/examples/guided_onboarding_example/public/components/step_two.tsx index a9f9388d567fc..9f96532450bfc 100644 --- a/examples/guided_onboarding_example/public/components/step_two.tsx +++ b/examples/guided_onboarding_example/public/components/step_two.tsx @@ -80,7 +80,7 @@ export const StepTwo = (props: StepTwoProps) => { > { - await guidedOnboardingApi?.completeGuideStep('search', 'search_experience'); + await guidedOnboardingApi?.completeGuideStep('search', 'browse_docs'); }} > Complete step 2 diff --git a/package.json b/package.json index 2982193768995..99de39602aff4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dashboarding" ], "private": true, - "version": "8.5.0", + "version": "8.6.0", "branch": "main", "types": "./kibana.d.ts", "tsdocMetadata": "./build/tsdoc-metadata.json", @@ -109,7 +109,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.3.0-canary.1", "@elastic/ems-client": "8.3.3", - "@elastic/eui": "64.0.4", + "@elastic/eui": "64.0.5", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 051d0ac9bf27f..56ef73801d5a9 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -281,6 +281,7 @@ filegroup( "//packages/kbn-utils:build", "//packages/kbn-yarn-lock-validator:build", "//packages/shared-ux/avatar/solution:build", + "//packages/shared-ux/avatar/user_profile/impl:build", "//packages/shared-ux/button_toolbar:build", "//packages/shared-ux/button/exit_full_screen/impl:build", "//packages/shared-ux/button/exit_full_screen/mocks:build", @@ -310,6 +311,9 @@ filegroup( "//packages/shared-ux/prompt/no_data_views/impl:build", "//packages/shared-ux/prompt/no_data_views/mocks:build", "//packages/shared-ux/prompt/no_data_views/types:build", + "//packages/shared-ux/router/impl:build", + "//packages/shared-ux/router/mocks:build", + "//packages/shared-ux/router/types:build", "//packages/shared-ux/storybook/config:build", "//packages/shared-ux/storybook/mock:build", "//x-pack/packages/ml/agg_utils:build", @@ -581,6 +585,7 @@ filegroup( "//packages/kbn-utils:build_types", "//packages/kbn-yarn-lock-validator:build_types", "//packages/shared-ux/avatar/solution:build_types", + "//packages/shared-ux/avatar/user_profile/impl:build_types", "//packages/shared-ux/button_toolbar:build_types", "//packages/shared-ux/button/exit_full_screen/impl:build_types", "//packages/shared-ux/button/exit_full_screen/mocks:build_types", @@ -601,6 +606,8 @@ filegroup( "//packages/shared-ux/page/solution_nav:build_types", "//packages/shared-ux/prompt/no_data_views/impl:build_types", "//packages/shared-ux/prompt/no_data_views/mocks:build_types", + "//packages/shared-ux/router/impl:build_types", + "//packages/shared-ux/router/mocks:build_types", "//packages/shared-ux/storybook/config:build_types", "//packages/shared-ux/storybook/mock:build_types", "//x-pack/packages/ml/agg_utils:build_types", diff --git a/packages/core/analytics/core-analytics-server-internal/src/analytics_service.test.ts b/packages/core/analytics/core-analytics-server-internal/src/analytics_service.test.ts index 2609859ae9d8c..6e21336bbb6fe 100644 --- a/packages/core/analytics/core-analytics-server-internal/src/analytics_service.test.ts +++ b/packages/core/analytics/core-analytics-server-internal/src/analytics_service.test.ts @@ -22,16 +22,26 @@ describe('AnalyticsService', () => { expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(1); await expect( await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$) - ).toMatchInlineSnapshot(` + ).toMatchInlineSnapshot( + { + branch: expect.any(String), + buildNum: 9007199254740991, + buildSha: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + isDev: true, + isDistributable: false, + version: expect.any(String), + }, + ` Object { - "branch": "main", + "branch": Any, "buildNum": 9007199254740991, "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "isDev": true, "isDistributable": false, - "version": "8.5.0", + "version": Any, } - `); + ` + ); }); test('should register the `performance_metric` event type on creation', () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts index 7c14da7e4b421..7a6d94c31f291 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts @@ -65,9 +65,14 @@ const existsSchema = s.object({ // For more details see how the types are defined in the elasticsearch javascript client: // https://github.com/elastic/elasticsearch-js/blob/4ad5daeaf401ce8ebb28b940075e0a67e56ff9ce/src/api/typesWithBodyKey.ts#L5295 const boolSchema = s.object({ - bool: s.object({ - must_not: s.oneOf([termSchema]), - }), + bool: s.oneOf([ + s.object({ + must_not: s.oneOf([termSchema, existsSchema]), + }), + s.object({ + filter: s.oneOf([termSchema, existsSchema]), + }), + ]), }); const orderSchema = s.oneOf([ diff --git a/packages/kbn-es-query/src/filters/helpers/meta_filter.ts b/packages/kbn-es-query/src/filters/helpers/meta_filter.ts index 484b85d608cff..3406ad5a5a1ce 100644 --- a/packages/kbn-es-query/src/filters/helpers/meta_filter.ts +++ b/packages/kbn-es-query/src/filters/helpers/meta_filter.ts @@ -113,11 +113,7 @@ export const unpinFilter = (filter: Filter) => * @public */ export const isFilter = (x: unknown): x is Filter => - !!x && - typeof x === 'object' && - !!(x as Filter).meta && - typeof (x as Filter).meta === 'object' && - typeof (x as Filter).meta.disabled === 'boolean'; + !!x && typeof x === 'object' && !!(x as Filter).meta && typeof (x as Filter).meta === 'object'; /** * @param {unknown} filters diff --git a/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc b/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc new file mode 100644 index 0000000000000..1fab1b9cb7d84 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-avatar-user-profile-components", + "owner": "@elastic/shared-ux", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/shared-ux/router/impl/kibana.jsonc b/packages/shared-ux/router/impl/kibana.jsonc new file mode 100644 index 0000000000000..77f3eca900702 --- /dev/null +++ b/packages/shared-ux/router/impl/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-router", + "owner": "@elastic/shared-ux", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/shared-ux/router/mocks/kibana.jsonc b/packages/shared-ux/router/mocks/kibana.jsonc new file mode 100644 index 0000000000000..8f3aef23a2081 --- /dev/null +++ b/packages/shared-ux/router/mocks/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-router-mocks", + "owner": "@elastic/shared-ux", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/shared-ux/router/types/kibana.jsonc b/packages/shared-ux/router/types/kibana.jsonc new file mode 100644 index 0000000000000..4e328b93d6081 --- /dev/null +++ b/packages/shared-ux/router/types/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-router-types", + "owner": "@elastic/shared-ux", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 840d7564681a8..de299c1298f9f 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -84,6 +84,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.3.3': ['Elastic License 2.0'], - '@elastic/eui@64.0.4': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@64.0.5': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts index 298b93c3a2fdb..c9e954a081ca2 100644 --- a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts @@ -10,15 +10,15 @@ import _ from 'lodash'; import type { KibanaExecutionContext } from '@kbn/core/public'; import type { ControlGroupInput } from '@kbn/controls-plugin/public'; +import { type EmbeddablePackageState, ViewMode } from '@kbn/embeddable-plugin/public'; import { compareFilters, - isFilterPinned, - migrateFilter, COMPARE_ALL_OPTIONS, - type Filter, + Filter, + isFilterPinned, + TimeRange, } from '@kbn/es-query'; -import { type EmbeddablePackageState, ViewMode } from '@kbn/embeddable-plugin/public'; -import type { TimeRange } from '@kbn/es-query'; +import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; import type { DashboardSavedObject } from '../../saved_dashboards'; import { getTagsFromSavedDashboard, migrateAppState } from '.'; @@ -72,6 +72,7 @@ export const savedObjectToDashboardState = ({ if (rawState.timeRestore) { rawState.timeRange = { from: savedDashboard.timeFrom, to: savedDashboard.timeTo } as TimeRange; } + rawState.controlGroupInput = deserializeControlGroupFromDashboardSavedObject( savedDashboard ) as ControlGroupInput; @@ -89,9 +90,10 @@ export const stateToDashboardContainerInput = ({ executionContext, }: StateToDashboardContainerInputProps): DashboardContainerInput => { const { - data: { query: queryService }, + data: { + query: { filterManager, timefilter: timefilterService }, + }, } = pluginServices.getServices(); - const { filterManager, timefilter: timefilterService } = queryService; const { timefilter } = timefilterService; const { @@ -109,6 +111,7 @@ export const stateToDashboardContainerInput = ({ filters: dashboardFilters, } = dashboardState; + const migratedDashboardFilters = mapAndFlattenFilters(_.cloneDeep(dashboardFilters)); return { refreshConfig: timefilter.getRefreshInterval(), filters: filterManager @@ -116,8 +119,8 @@ export const stateToDashboardContainerInput = ({ .filter( (filter) => isFilterPinned(filter) || - dashboardFilters.some((dashboardFilter) => - filtersAreEqual(migrateFilter(_.cloneDeep(dashboardFilter)), filter) + migratedDashboardFilters.some((dashboardFilter) => + filtersAreEqual(dashboardFilter, filter) ) ), isFullScreenMode: fullScreenMode, diff --git a/src/plugins/guided_onboarding/public/constants/observability.ts b/src/plugins/guided_onboarding/public/constants/observability.ts index fb60c71f8ee17..3f96ad1268173 100644 --- a/src/plugins/guided_onboarding/public/constants/observability.ts +++ b/src/plugins/guided_onboarding/public/constants/observability.ts @@ -27,8 +27,8 @@ export const observabilityConfig: GuideConfig = { ], }, { - id: 'rules', - title: 'Customize your alerting rules', + id: 'view_dashboard', + title: 'View Kubernetes metrics', descriptionList: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', @@ -36,35 +36,8 @@ export const observabilityConfig: GuideConfig = { ], }, { - id: 'infrastructure', - title: 'View infrastructure details', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - }, - { - id: 'explore', - title: 'Explore Discover and Dashboards', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - }, - { - id: 'tour', - title: 'Tour Observability', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - }, - { - id: 'do_more', - title: 'Do more with Observability', + id: 'tour_observability', + title: 'Tour Elastic Observability', descriptionList: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', diff --git a/src/plugins/guided_onboarding/public/constants/search.ts b/src/plugins/guided_onboarding/public/constants/search.ts index f93beff593fb8..b4c3c151aca0c 100644 --- a/src/plugins/guided_onboarding/public/constants/search.ts +++ b/src/plugins/guided_onboarding/public/constants/search.ts @@ -30,8 +30,8 @@ export const searchConfig: GuideConfig = { }, }, { - id: 'search_experience', - title: 'Build a search experience', + id: 'browse_docs', + title: 'Browse your documents', descriptionList: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', @@ -43,17 +43,8 @@ export const searchConfig: GuideConfig = { }, }, { - id: 'optimize', - title: 'Optimize your search relevance', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - }, - { - id: 'review', - title: 'Review your search analytics', + id: 'search_experience', + title: 'Build a search experience', descriptionList: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', diff --git a/src/plugins/guided_onboarding/public/constants/security.ts b/src/plugins/guided_onboarding/public/constants/security.ts index 84b4e107674ad..2c19e7acc2bed 100644 --- a/src/plugins/guided_onboarding/public/constants/security.ts +++ b/src/plugins/guided_onboarding/public/constants/security.ts @@ -49,14 +49,5 @@ export const securityConfig: GuideConfig = { 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', ], }, - { - id: 'do_more', - title: 'Do more with Elastic Security', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - }, ], }; diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index ff614fc8da78c..9f5e20cb9f89d 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -13,11 +13,12 @@ import { firstValueFrom, Subscription } from 'rxjs'; import { API_BASE_PATH } from '../../common'; import { ApiService } from './api'; import { GuidedOnboardingState } from '..'; +import { guidesConfig } from '../constants/guides_config'; const searchGuide = 'search'; -const firstStep = 'add_data'; -const secondStep = 'search_experience'; -const lastStep = 'review'; +const firstStep = guidesConfig[searchGuide].steps[0].id; +const secondStep = guidesConfig[searchGuide].steps[1].id; +const lastStep = guidesConfig[searchGuide].steps[2].id; describe('GuidedOnboarding ApiService', () => { let httpClient: jest.Mocked; diff --git a/src/plugins/guided_onboarding/public/services/helpers.test.ts b/src/plugins/guided_onboarding/public/services/helpers.test.ts index 3f2ea164bd25d..6e1a3cc3e0049 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.test.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.test.ts @@ -6,26 +6,32 @@ * Side Public License, v 1. */ +import { guidesConfig } from '../constants/guides_config'; import { getNextStep, isLastStep } from './helpers'; +const searchGuide = 'search'; +const firstStep = guidesConfig[searchGuide].steps[0].id; +const secondStep = guidesConfig[searchGuide].steps[1].id; +const lastStep = guidesConfig[searchGuide].steps[2].id; + describe('GuidedOnboarding ApiService helpers', () => { // this test suite depends on the guides config describe('isLastStepActive', () => { it('returns true if the passed params are for the last step', () => { - const result = isLastStep('search', 'review'); + const result = isLastStep(searchGuide, lastStep); expect(result).toBe(true); }); it('returns false if the passed params are not for the last step', () => { - const result = isLastStep('search', 'add_data'); + const result = isLastStep(searchGuide, firstStep); expect(result).toBe(false); }); }); describe('getNextStep', () => { it('returns id of the next step', () => { - const result = getNextStep('search', 'add_data'); - expect(result).toEqual('search_experience'); + const result = getNextStep(searchGuide, firstStep); + expect(result).toEqual(secondStep); }); it('returns undefined if the params are not part of the config', () => { @@ -34,7 +40,7 @@ describe('GuidedOnboarding ApiService helpers', () => { }); it(`returns undefined if it's the last step`, () => { - const result = getNextStep('search', 'review'); + const result = getNextStep(searchGuide, lastStep); expect(result).toBeUndefined(); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index d8511298f6ac9..41df488839358 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -562,10 +562,6 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, - 'enterpriseSearch:enableIndexTransformsTab': { - type: 'boolean', - _meta: { description: 'Non-default value of setting.' }, - }, 'enterpriseSearch:enableBehavioralAnalyticsSection': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 7ca9ddfeef5b9..2bd59dc69084f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -150,6 +150,5 @@ export interface UsageStats { 'securitySolution:enableGroupedNav': boolean; 'securitySolution:showRelatedIntegrations': boolean; 'visualization:visualize:legacyGaugeChartsLibrary': boolean; - 'enterpriseSearch:enableIndexTransformsTab': boolean; 'enterpriseSearch:enableBehavioralAnalyticsSection': boolean; } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0a4d6c347674d..1a97586dffa62 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8888,12 +8888,6 @@ "description": "Non-default value of setting." } }, - "enterpriseSearch:enableIndexTransformsTab": { - "type": "boolean", - "_meta": { - "description": "Non-default value of setting." - } - }, "enterpriseSearch:enableBehavioralAnalyticsSection": { "type": "boolean", "_meta": { diff --git a/versions.json b/versions.json index 5b70f97320e23..a39a1412c46f6 100644 --- a/versions.json +++ b/versions.json @@ -2,11 +2,17 @@ "notice": "This file is not maintained outside of the main branch and should only be used for tooling.", "versions": [ { - "version": "8.5.0", + "version": "8.6.0", "branch": "main", "currentMajor": true, "currentMinor": true }, + { + "version": "8.5.0", + "branch": "8.5", + "currentMajor": true, + "previousMinor": true + }, { "version": "8.4.3", "branch": "8.4", diff --git a/x-pack/package.json b/x-pack/package.json index 2433a70c26123..fe6050dd8f95d 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -1,6 +1,6 @@ { "name": "x-pack", - "version": "8.5.0", + "version": "8.6.0", "author": "Elastic", "private": true, "license": "Elastic-License", diff --git a/x-pack/plugins/cases/server/telemetry/constants.ts b/x-pack/plugins/cases/server/telemetry/constants.ts new file mode 100644 index 0000000000000..705321e3f1fa0 --- /dev/null +++ b/x-pack/plugins/cases/server/telemetry/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GENERAL_CASES_OWNER, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../common'; + +/** + * This should only be used within telemetry + */ +export const OWNERS = [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, GENERAL_CASES_OWNER] as const; diff --git a/x-pack/plugins/cases/server/telemetry/queries/cases.test.ts b/x-pack/plugins/cases/server/telemetry/queries/cases.test.ts index 71c484071017c..2a6aaddf8f5db 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/cases.test.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/cases.test.ts @@ -7,6 +7,7 @@ import { SavedObjectsFindResponse } from '@kbn/core/server'; import { savedObjectsRepositoryMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { CaseAggregationResult } from '../types'; import { getCasesTelemetryData } from './cases'; describe('getCasesTelemetryData', () => { @@ -50,13 +51,33 @@ describe('getCasesTelemetryData', () => { ], }; - mockFind({ + const assignees = { + assigneeFilters: { + buckets: { + atLeastOne: { + doc_count: 0, + }, + zero: { + doc_count: 100, + }, + }, + }, + totalAssignees: { value: 5 }, + }; + + const solutionValues = { + counts, + ...assignees, + }; + + const caseAggsResult: CaseAggregationResult = { users: { value: 1 }, tags: { value: 2 }, + ...assignees, counts, - securitySolution: { counts }, - observability: { counts }, - cases: { counts }, + securitySolution: { ...solutionValues }, + observability: { ...solutionValues }, + cases: { ...solutionValues }, syncAlerts: { buckets: [ { @@ -93,7 +114,9 @@ describe('getCasesTelemetryData', () => { }, ], }, - }); + }; + + mockFind(caseAggsResult); mockFind({ participants: { value: 2 } }); mockFind({ references: { referenceType: { referenceAgg: { value: 3 } } } }); mockFind({ references: { referenceType: { referenceAgg: { value: 4 } } } }); @@ -139,20 +162,40 @@ describe('getCasesTelemetryData', () => { totalUsers: 1, totalWithAlerts: 3, totalWithConnectors: 4, + assignees: { + total: 5, + totalWithZero: 100, + totalWithAtLeastOne: 0, + }, }, main: { + assignees: { + total: 5, + totalWithZero: 100, + totalWithAtLeastOne: 0, + }, total: 1, daily: 3, weekly: 2, monthly: 1, }, obs: { + assignees: { + total: 5, + totalWithZero: 100, + totalWithAtLeastOne: 0, + }, total: 1, daily: 3, weekly: 2, monthly: 1, }, sec: { + assignees: { + total: 5, + totalWithZero: 100, + totalWithAtLeastOne: 0, + }, total: 1, daily: 3, weekly: 2, @@ -166,145 +209,263 @@ describe('getCasesTelemetryData', () => { await getCasesTelemetryData({ savedObjectsClient, logger }); - expect(savedObjectsClient.find.mock.calls[0][0]).toEqual({ - aggs: { - cases: { - aggs: { - counts: { - date_range: { - field: 'cases.attributes.created_at', - format: 'dd/MM/YYYY', - ranges: [ - { - from: 'now-1d', - to: 'now', - }, - { - from: 'now-1w', - to: 'now', + expect(savedObjectsClient.find.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "aggs": Object { + "assigneeFilters": Object { + "filters": Object { + "filters": Object { + "atLeastOne": Object { + "bool": Object { + "filter": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, }, - { - from: 'now-1M', - to: 'now', + }, + "zero": Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, }, - ], + }, }, }, }, - filter: { - term: { - 'cases.attributes.owner': 'cases', - }, - }, - }, - counts: { - date_range: { - field: 'cases.attributes.created_at', - format: 'dd/MM/YYYY', - ranges: [ - { - from: 'now-1d', - to: 'now', + "cases": Object { + "aggs": Object { + "assigneeFilters": Object { + "filters": Object { + "filters": Object { + "atLeastOne": Object { + "bool": Object { + "filter": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, + "zero": Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, + }, + }, }, - { - from: 'now-1w', - to: 'now', + "counts": Object { + "date_range": Object { + "field": "cases.attributes.created_at", + "format": "dd/MM/YYYY", + "ranges": Array [ + Object { + "from": "now-1d", + "to": "now", + }, + Object { + "from": "now-1w", + "to": "now", + }, + Object { + "from": "now-1M", + "to": "now", + }, + ], + }, }, - { - from: 'now-1M', - to: 'now', + "totalAssignees": Object { + "value_count": Object { + "field": "cases.attributes.assignees.uid", + }, }, - ], - }, - }, - observability: { - aggs: { - counts: { - date_range: { - field: 'cases.attributes.created_at', - format: 'dd/MM/YYYY', - ranges: [ - { - from: 'now-1d', - to: 'now', - }, - { - from: 'now-1w', - to: 'now', - }, - { - from: 'now-1M', - to: 'now', - }, - ], + }, + "filter": Object { + "term": Object { + "cases.attributes.owner": "cases", }, }, }, - filter: { - term: { - 'cases.attributes.owner': 'observability', + "counts": Object { + "date_range": Object { + "field": "cases.attributes.created_at", + "format": "dd/MM/YYYY", + "ranges": Array [ + Object { + "from": "now-1d", + "to": "now", + }, + Object { + "from": "now-1w", + "to": "now", + }, + Object { + "from": "now-1M", + "to": "now", + }, + ], }, }, - }, - securitySolution: { - aggs: { - counts: { - date_range: { - field: 'cases.attributes.created_at', - format: 'dd/MM/YYYY', - ranges: [ - { - from: 'now-1d', - to: 'now', - }, - { - from: 'now-1w', - to: 'now', + "observability": Object { + "aggs": Object { + "assigneeFilters": Object { + "filters": Object { + "filters": Object { + "atLeastOne": Object { + "bool": Object { + "filter": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, + "zero": Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, }, - { - from: 'now-1M', - to: 'now', + }, + }, + "counts": Object { + "date_range": Object { + "field": "cases.attributes.created_at", + "format": "dd/MM/YYYY", + "ranges": Array [ + Object { + "from": "now-1d", + "to": "now", + }, + Object { + "from": "now-1w", + "to": "now", + }, + Object { + "from": "now-1M", + "to": "now", + }, + ], + }, + }, + "totalAssignees": Object { + "value_count": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + "filter": Object { + "term": Object { + "cases.attributes.owner": "observability", + }, + }, + }, + "securitySolution": Object { + "aggs": Object { + "assigneeFilters": Object { + "filters": Object { + "filters": Object { + "atLeastOne": Object { + "bool": Object { + "filter": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, + "zero": Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + }, }, - ], + }, + }, + "counts": Object { + "date_range": Object { + "field": "cases.attributes.created_at", + "format": "dd/MM/YYYY", + "ranges": Array [ + Object { + "from": "now-1d", + "to": "now", + }, + Object { + "from": "now-1w", + "to": "now", + }, + Object { + "from": "now-1M", + "to": "now", + }, + ], + }, + }, + "totalAssignees": Object { + "value_count": Object { + "field": "cases.attributes.assignees.uid", + }, + }, + }, + "filter": Object { + "term": Object { + "cases.attributes.owner": "securitySolution", }, }, }, - filter: { - term: { - 'cases.attributes.owner': 'securitySolution', + "status": Object { + "terms": Object { + "field": "cases.attributes.status", }, }, - }, - status: { - terms: { - field: 'cases.attributes.status', + "syncAlerts": Object { + "terms": Object { + "field": "cases.attributes.settings.syncAlerts", + }, }, - }, - syncAlerts: { - terms: { - field: 'cases.attributes.settings.syncAlerts', + "tags": Object { + "cardinality": Object { + "field": "cases.attributes.tags", + }, }, - }, - tags: { - cardinality: { - field: 'cases.attributes.tags', + "totalAssignees": Object { + "value_count": Object { + "field": "cases.attributes.assignees.uid", + }, }, - }, - totalsByOwner: { - terms: { - field: 'cases.attributes.owner', + "totalsByOwner": Object { + "terms": Object { + "field": "cases.attributes.owner", + }, }, - }, - users: { - cardinality: { - field: 'cases.attributes.created_by.username', + "users": Object { + "cardinality": Object { + "field": "cases.attributes.created_by.username", + }, }, }, - }, - page: 0, - perPage: 0, - type: 'cases', - }); + "page": 0, + "perPage": 0, + "type": "cases", + } + `); expect(savedObjectsClient.find.mock.calls[1][0]).toEqual({ aggs: { diff --git a/x-pack/plugins/cases/server/telemetry/queries/cases.ts b/x-pack/plugins/cases/server/telemetry/queries/cases.ts index 3af4a97c07a2b..3969ed77e5a17 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/cases.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/cases.ts @@ -11,6 +11,7 @@ import { CASE_USER_ACTION_SAVED_OBJECT, } from '../../../common/constants'; import { ESCaseAttributes } from '../../services/cases/types'; +import { OWNERS } from '../constants'; import { CollectTelemetryDataParams, Buckets, @@ -18,6 +19,7 @@ import { Cardinality, ReferencesAggregation, LatestDates, + CaseAggregationResult, } from '../types'; import { findValueInBuckets, @@ -27,6 +29,7 @@ import { getOnlyAlertsCommentsFilter, getOnlyConnectorsFilter, getReferencesAggregationQuery, + getSolutionValues, } from './utils'; export const getLatestCasesDates = async ({ @@ -58,8 +61,7 @@ export const getCasesTelemetryData = async ({ savedObjectsClient, logger, }: CollectTelemetryDataParams): Promise => { - const owners = ['observability', 'securitySolution', 'cases'] as const; - const byOwnerAggregationQuery = owners.reduce( + const byOwnerAggregationQuery = OWNERS.reduce( (aggQuery, owner) => ({ ...aggQuery, [owner]: { @@ -68,28 +70,23 @@ export const getCasesTelemetryData = async ({ [`${CASE_SAVED_OBJECT}.attributes.owner`]: owner, }, }, - aggs: getCountsAggregationQuery(CASE_SAVED_OBJECT), + aggs: { + ...getCountsAggregationQuery(CASE_SAVED_OBJECT), + ...getAssigneesAggregations(), + }, }, }), {} ); - const casesRes = await savedObjectsClient.find< - unknown, - Record & { - counts: Buckets; - syncAlerts: Buckets; - status: Buckets; - users: Cardinality; - tags: Cardinality; - } - >({ + const casesRes = await savedObjectsClient.find({ page: 0, perPage: 0, type: CASE_SAVED_OBJECT, aggs: { ...byOwnerAggregationQuery, ...getCountsAggregationQuery(CASE_SAVED_OBJECT), + ...getAssigneesAggregations(), totalsByOwner: { terms: { field: `${CASE_SAVED_OBJECT}.attributes.owner` }, }, @@ -116,7 +113,7 @@ export const getCasesTelemetryData = async ({ const commentsRes = await savedObjectsClient.find< unknown, - Record & { + Record & { participants: Cardinality; } & ReferencesAggregation >({ @@ -164,16 +161,7 @@ export const getCasesTelemetryData = async ({ const aggregationsBuckets = getAggregationsBuckets({ aggs: casesRes.aggregations, - keys: [ - 'counts', - 'observability.counts', - 'securitySolution.counts', - 'cases.counts', - 'syncAlerts', - 'status', - 'totalsByOwner', - 'users', - ], + keys: ['counts', 'syncAlerts', 'status', 'users', 'totalAssignees'], }); return { @@ -195,18 +183,47 @@ export const getCasesTelemetryData = async ({ totalWithConnectors: totalConnectorsRes.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0, latestDates, + assignees: { + total: casesRes.aggregations?.totalAssignees.value ?? 0, + totalWithZero: casesRes.aggregations?.assigneeFilters.buckets.zero.doc_count ?? 0, + totalWithAtLeastOne: + casesRes.aggregations?.assigneeFilters.buckets.atLeastOne.doc_count ?? 0, + }, }, - sec: { - total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'securitySolution'), - ...getCountsFromBuckets(aggregationsBuckets['securitySolution.counts']), - }, - obs: { - total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'observability'), - ...getCountsFromBuckets(aggregationsBuckets['observability.counts']), - }, - main: { - total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'cases'), - ...getCountsFromBuckets(aggregationsBuckets['cases.counts']), - }, + sec: getSolutionValues(casesRes.aggregations, 'securitySolution'), + obs: getSolutionValues(casesRes.aggregations, 'observability'), + main: getSolutionValues(casesRes.aggregations, 'cases'), }; }; + +const getAssigneesAggregations = () => ({ + totalAssignees: { + value_count: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + assigneeFilters: { + filters: { + filters: { + zero: { + bool: { + must_not: { + exists: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + }, + }, + atLeastOne: { + bool: { + filter: { + exists: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + }, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts b/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts index f10cae4afb611..3b1bd17b28cdf 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts @@ -6,6 +6,7 @@ */ import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { CaseAggregationResult } from '../types'; import { findValueInBuckets, getAggregationsBuckets, @@ -18,9 +19,127 @@ import { getOnlyAlertsCommentsFilter, getOnlyConnectorsFilter, getReferencesAggregationQuery, + getSolutionValues, } from './utils'; describe('utils', () => { + describe('getSolutionValues', () => { + const counts = { + buckets: [ + { doc_count: 1, key: 1 }, + { doc_count: 2, key: 2 }, + { doc_count: 3, key: 3 }, + ], + }; + + const assignees = { + assigneeFilters: { + buckets: { + atLeastOne: { + doc_count: 0, + }, + zero: { + doc_count: 100, + }, + }, + }, + totalAssignees: { value: 5 }, + }; + + const solutionValues = { + counts, + ...assignees, + }; + + const aggsResult: CaseAggregationResult = { + users: { value: 1 }, + tags: { value: 2 }, + ...assignees, + counts, + securitySolution: { ...solutionValues }, + observability: { ...solutionValues }, + cases: { ...solutionValues }, + syncAlerts: { + buckets: [ + { + key: 0, + doc_count: 1, + }, + { + key: 1, + doc_count: 1, + }, + ], + }, + status: { + buckets: [ + { + key: 'open', + doc_count: 2, + }, + ], + }, + totalsByOwner: { + buckets: [ + { + key: 'observability', + doc_count: 1, + }, + { + key: 'securitySolution', + doc_count: 1, + }, + { + key: 'cases', + doc_count: 1, + }, + ], + }, + }; + + it('constructs the solution values correctly', () => { + expect(getSolutionValues(aggsResult, 'securitySolution')).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "total": 5, + "totalWithAtLeastOne": 0, + "totalWithZero": 100, + }, + "daily": 3, + "monthly": 1, + "total": 1, + "weekly": 2, + } + `); + expect(getSolutionValues(aggsResult, 'cases')).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "total": 5, + "totalWithAtLeastOne": 0, + "totalWithZero": 100, + }, + "daily": 3, + "monthly": 1, + "total": 1, + "weekly": 2, + } + `); + expect(getSolutionValues(aggsResult, 'observability')).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "total": 5, + "totalWithAtLeastOne": 0, + "totalWithZero": 100, + }, + "daily": 3, + "monthly": 1, + "total": 1, + "weekly": 2, + } + `); + }); + }); + describe('getCountsAggregationQuery', () => { it('returns the correct query', () => { expect(getCountsAggregationQuery('test')).toEqual({ diff --git a/x-pack/plugins/cases/server/telemetry/queries/utils.ts b/x-pack/plugins/cases/server/telemetry/queries/utils.ts index 70e719ef9fa31..dd542b5f65229 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.ts @@ -13,8 +13,15 @@ import { CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, } from '../../../common/constants'; -import { Buckets, CasesTelemetry, MaxBucketOnCaseAggregation } from '../types'; +import { + CaseAggregationResult, + Buckets, + CasesTelemetry, + MaxBucketOnCaseAggregation, + SolutionTelemetry, +} from '../types'; import { buildFilter } from '../../client/utils'; +import { OWNERS } from '../constants'; export const getCountsAggregationQuery = (savedObjectType: string) => ({ counts: { @@ -147,6 +154,26 @@ export const getBucketFromAggregation = ({ aggs?: Record; }): Buckets['buckets'] => (get(aggs, `${key}.buckets`) ?? []) as Buckets['buckets']; +export const getSolutionValues = ( + aggregations: CaseAggregationResult | undefined, + owner: typeof OWNERS[number] +): SolutionTelemetry => { + const aggregationsBuckets = getAggregationsBuckets({ + aggs: aggregations, + keys: ['totalsByOwner', 'securitySolution.counts', 'observability.counts', 'cases.counts'], + }); + + return { + total: findValueInBuckets(aggregationsBuckets.totalsByOwner, owner), + ...getCountsFromBuckets(aggregationsBuckets[`${owner}.counts`]), + assignees: { + total: aggregations?.[owner].totalAssignees.value ?? 0, + totalWithZero: aggregations?.[owner].assigneeFilters.buckets.zero.doc_count ?? 0, + totalWithAtLeastOne: aggregations?.[owner].assigneeFilters.buckets.atLeastOne.doc_count ?? 0, + }, + }; +}; + export const findValueInBuckets = (buckets: Buckets['buckets'], value: string | number): number => buckets.find(({ key }) => key === value)?.doc_count ?? 0; @@ -184,6 +211,11 @@ export const getOnlyConnectorsFilter = () => export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ cases: { all: { + assignees: { + total: 0, + totalWithZero: 0, + totalWithAtLeastOne: 0, + }, total: 0, monthly: 0, weekly: 0, @@ -206,9 +238,27 @@ export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ closedAt: null, }, }, - sec: { total: 0, monthly: 0, weekly: 0, daily: 0 }, - obs: { total: 0, monthly: 0, weekly: 0, daily: 0 }, - main: { total: 0, monthly: 0, weekly: 0, daily: 0 }, + sec: { + total: 0, + monthly: 0, + weekly: 0, + daily: 0, + assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + }, + obs: { + total: 0, + monthly: 0, + weekly: 0, + daily: 0, + assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + }, + main: { + total: 0, + monthly: 0, + weekly: 0, + daily: 0, + assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + }, }, userActions: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } }, comments: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } }, diff --git a/x-pack/plugins/cases/server/telemetry/schema.ts b/x-pack/plugins/cases/server/telemetry/schema.ts index 5b8b75cc01833..fa04fc7c6651d 100644 --- a/x-pack/plugins/cases/server/telemetry/schema.ts +++ b/x-pack/plugins/cases/server/telemetry/schema.ts @@ -12,6 +12,8 @@ import { StatusSchema, LatestDatesSchema, TypeString, + SolutionTelemetrySchema, + AssigneesSchema, } from './types'; const long: TypeLong = { type: 'long' }; @@ -24,6 +26,17 @@ const countSchema: CountSchema = { daily: long, }; +const assigneesSchema: AssigneesSchema = { + total: long, + totalWithZero: long, + totalWithAtLeastOne: long, +}; + +const solutionTelemetry: SolutionTelemetrySchema = { + ...countSchema, + assignees: assigneesSchema, +}; + const statusSchema: StatusSchema = { open: long, inProgress: long, @@ -40,6 +53,7 @@ export const casesSchema: CasesTelemetrySchema = { cases: { all: { ...countSchema, + assignees: assigneesSchema, status: statusSchema, syncAlertsOn: long, syncAlertsOff: long, @@ -50,9 +64,9 @@ export const casesSchema: CasesTelemetrySchema = { totalWithConnectors: long, latestDates: latestDatesSchema, }, - sec: countSchema, - obs: countSchema, - main: countSchema, + sec: solutionTelemetry, + obs: solutionTelemetry, + main: solutionTelemetry, }, userActions: { all: { ...countSchema, maxOnACase: long } }, comments: { all: { ...countSchema, maxOnACase: long } }, diff --git a/x-pack/plugins/cases/server/telemetry/types.ts b/x-pack/plugins/cases/server/telemetry/types.ts index ec880855f3302..2c8e848b3854f 100644 --- a/x-pack/plugins/cases/server/telemetry/types.ts +++ b/x-pack/plugins/cases/server/telemetry/types.ts @@ -7,6 +7,7 @@ import { ISavedObjectsRepository, Logger } from '@kbn/core/server'; import { MakeSchemaFrom } from '@kbn/usage-collection-plugin/server'; +import { OWNERS } from './constants'; export interface Buckets { buckets: Array<{ @@ -19,6 +20,8 @@ export interface Cardinality { value: number; } +export type ValueCount = Cardinality; + export interface MaxBucketOnCaseAggregation { references: { cases: { max: { value: number } } }; } @@ -47,6 +50,41 @@ export interface Count { daily: number; } +export interface AssigneesFilters { + buckets: { + zero: { doc_count: number }; + atLeastOne: { doc_count: number }; + }; +} + +export type CaseAggregationResult = Record< + typeof OWNERS[number], + { + counts: Buckets; + totalAssignees: ValueCount; + assigneeFilters: AssigneesFilters; + } +> & { + assigneeFilters: AssigneesFilters; + counts: Buckets; + syncAlerts: Buckets; + status: Buckets; + users: Cardinality; + tags: Cardinality; + totalAssignees: ValueCount; + totalsByOwner: Buckets; +}; + +export interface Assignees { + total: number; + totalWithZero: number; + totalWithAtLeastOne: number; +} + +export interface SolutionTelemetry extends Count { + assignees: Assignees; +} + export interface Status { open: number; inProgress: number; @@ -62,6 +100,7 @@ export interface LatestDates { export interface CasesTelemetry { cases: { all: Count & { + assignees: Assignees; status: Status; syncAlertsOn: number; syncAlertsOff: number; @@ -72,9 +111,9 @@ export interface CasesTelemetry { totalWithConnectors: number; latestDates: LatestDates; }; - sec: Count; - obs: Count; - main: Count; + sec: SolutionTelemetry; + obs: SolutionTelemetry; + main: SolutionTelemetry; }; userActions: { all: Count & { maxOnACase: number } }; comments: { all: Count & { maxOnACase: number } }; @@ -107,3 +146,5 @@ export type CountSchema = MakeSchemaFrom; export type StatusSchema = MakeSchemaFrom; export type LatestDatesSchema = MakeSchemaFrom; export type CasesTelemetrySchema = MakeSchemaFrom; +export type AssigneesSchema = MakeSchemaFrom; +export type SolutionTelemetrySchema = MakeSchemaFrom; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx index e18a8a37cf22e..5452e26207450 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { type Filter, isFilters, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { type Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; import type { KibanaLocation } from '@kbn/share-plugin/public'; import { DashboardAppLocatorParams, cleanEmptyKeys } from '@kbn/dashboard-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; @@ -62,12 +62,11 @@ export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown isFilterPinned(f)); + params.filters = config.useCurrentFilters + ? input.filters + : input.filters?.filter((f) => isFilterPinned(f)); } const { restOfFilters: filtersFromEvent, timeRange: timeRangeFromEvent } = extractTimeRange( diff --git a/x-pack/plugins/enterprise_search/common/ui_settings_keys.ts b/x-pack/plugins/enterprise_search/common/ui_settings_keys.ts index dc960fb103ddd..d06902fe04fab 100644 --- a/x-pack/plugins/enterprise_search/common/ui_settings_keys.ts +++ b/x-pack/plugins/enterprise_search/common/ui_settings_keys.ts @@ -6,5 +6,4 @@ */ export const enterpriseSearchFeatureId = 'enterpriseSearch'; -export const enableIndexPipelinesTab = 'enterpriseSearch:enableIndexTransformsTab'; export const enableBehavioralAnalyticsSection = 'enterpriseSearch:enableBehavioralAnalyticsSection'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_button.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_button.tsx index 6a11c17e878aa..2bc65e041cc86 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_button.tsx @@ -22,7 +22,7 @@ export const AddMLInferencePipelineButton: React.FC { const { capabilities } = useValues(KibanaLogic); - const { canUseMlInferencePipeline } = useValues(PipelinesLogic); + const { canUseMlInferencePipeline, hasIndexIngestionPipeline } = useValues(PipelinesLogic); const hasMLPermissions = capabilities?.ml?.canAccessML ?? false; if (!hasMLPermissions) { return ( @@ -36,6 +36,21 @@ export const AddMLInferencePipelineButton: React.FC ); } + if (!hasIndexIngestionPipeline) { + return ( + + + + ); + } if (!canUseMlInferencePipeline) { return ( { const { - addInferencePipelineModal: { configuration, indexName }, + addInferencePipelineModal: { configuration }, formErrors, supportedMLModels, sourceFields, @@ -78,10 +78,7 @@ export const ConfigurePipeline: React.FC = () => { 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.name.helpText', { defaultMessage: - 'Pipeline names can only contain letters, numbers, underscores, and hyphens. The pipeline name will be automatically prefixed with "ml-inference@{indexName}-".', - values: { - indexName, - }, + 'Pipeline names can only contain letters, numbers, underscores, and hyphens. The pipeline name will be automatically prefixed with "ml-inference-".', } ) } diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.test.ts index 3ea6890c41932..4a9c11faa7f73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.test.ts @@ -31,11 +31,6 @@ describe('ml inference utils', () => { ner: {}, }, }), - makeFakeModel({ - inference_config: { - classification: {}, - }, - }), makeFakeModel({ inference_config: { text_classification: {}, @@ -53,6 +48,16 @@ describe('ml inference utils', () => { }, }, }), + makeFakeModel({ + inference_config: { + question_answering: {}, + }, + }), + makeFakeModel({ + inference_config: { + fill_mask: {}, + }, + }), ]; for (const model of models) { @@ -61,7 +66,14 @@ describe('ml inference utils', () => { }); it('returns false for expected models', () => { - const models: TrainedModelConfigResponse[] = [makeFakeModel({})]; + const models: TrainedModelConfigResponse[] = [ + makeFakeModel({}), + makeFakeModel({ + inference_config: { + classification: {}, + }, + }), + ]; for (const model of models) { expect(isSupportedMLModel(model)).toBe(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts index 83cf04585b5ff..b788a522d395f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts @@ -11,10 +11,11 @@ import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_ import { AddInferencePipelineFormErrors, InferencePipelineConfiguration } from './types'; const NLP_CONFIG_KEYS = [ + 'fill_mask', 'ner', - 'classification', 'text_classification', 'text_embedding', + 'question_answering', 'zero_shot_classification', ]; export const isSupportedMLModel = (model: TrainedModelConfigResponse): boolean => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts index 47b60b7c4f4b3..7dc3a221cc57a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts @@ -30,6 +30,7 @@ const DEFAULT_VALUES = { pipelineState: DEFAULT_PIPELINE_VALUES, showModal: false, showAddMlInferencePipelineModal: false, + hasIndexIngestionPipeline: false, }; describe('PipelinesLogic', () => { @@ -86,6 +87,7 @@ describe('PipelinesLogic', () => { expect(PipelinesLogic.values).toEqual({ ...DEFAULT_VALUES, pipelineState: { ...DEFAULT_PIPELINE_VALUES, name: 'new_pipeline_name' }, + hasIndexIngestionPipeline: true, }); }); describe('makeRequest', () => { @@ -155,6 +157,7 @@ describe('PipelinesLogic', () => { connector: { ...connectorIndex.connector, pipeline: newPipeline }, }, pipelineState: newPipeline, + hasIndexIngestionPipeline: true, }); }); it('should not set configState if modal is open', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts index e6cb840be420a..99d241507dd2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts @@ -107,6 +107,7 @@ interface PipelinesValues { canUseMlInferencePipeline: boolean; defaultPipelineValues: IngestPipelineParams; defaultPipelineValuesData: IngestPipelineParams | null; + hasIndexIngestionPipeline: boolean; index: FetchIndexApiResponse; mlInferencePipelineProcessors: InferencePipeline[]; pipelineState: IngestPipelineParams; @@ -289,14 +290,26 @@ export const PipelinesLogic = kea [selectors.index], (index: ElasticsearchIndexWithIngestion) => !isApiIndex(index), ], - canUseMlInferencePipeline: [ - () => [selectors.canSetPipeline, selectors.pipelineState], - (canSetPipeline: boolean, pipelineState: IngestPipelineParams) => - canSetPipeline && pipelineState.run_ml_inference, - ], defaultPipelineValues: [ () => [selectors.defaultPipelineValuesData], (pipeline: IngestPipelineParams | null) => pipeline ?? DEFAULT_PIPELINE_VALUES, ], + hasIndexIngestionPipeline: [ + () => [selectors.pipelineState, selectors.defaultPipelineValues], + (pipelineState: IngestPipelineParams, defaultPipelineValues: IngestPipelineParams) => + pipelineState.name !== defaultPipelineValues.name, + ], + canUseMlInferencePipeline: [ + () => [ + selectors.canSetPipeline, + selectors.hasIndexIngestionPipeline, + selectors.pipelineState, + ], + ( + canSetPipeline: boolean, + hasIndexIngestionPipeline: boolean, + pipelineState: IngestPipelineParams + ) => canSetPipeline && hasIndexIngestionPipeline && pipelineState.run_ml_inference, + ], }), }); diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts index 59e7edf1d21d5..69bc3c4ab7e6e 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts @@ -46,6 +46,14 @@ describe('Setup Indices', () => { last_sync_status: { type: 'keyword' }, last_synced: { type: 'date' }, name: { type: 'keyword' }, + pipeline: { + properties: { + extract_binary_content: { type: 'boolean' }, + name: { type: 'keyword' }, + reduce_whitespace: { type: 'boolean' }, + run_ml_inference: { type: 'boolean' }, + }, + }, scheduling: { properties: { enabled: { type: 'boolean' }, diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts index b564d519e73f9..bf6d2d3f96011 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts @@ -42,6 +42,14 @@ const connectorMappingsProperties: Record = { last_sync_status: { type: 'keyword' }, last_synced: { type: 'date' }, name: { type: 'keyword' }, + pipeline: { + properties: { + extract_binary_content: { type: 'boolean' }, + name: { type: 'keyword' }, + reduce_whitespace: { type: 'boolean' }, + run_ml_inference: { type: 'boolean' }, + }, + }, scheduling: { properties: { enabled: { type: 'boolean' }, @@ -70,7 +78,7 @@ export const defaultConnectorsPipelineMeta: DefaultConnectorsPipelineMeta = { default_extract_binary_content: true, default_name: 'ent-search-generic-ingestion', default_reduce_whitespace: true, - default_run_ml_inference: false, + default_run_ml_inference: true, }; const indices: IndexDefinition[] = [ diff --git a/x-pack/plugins/enterprise_search/server/ui_settings.ts b/x-pack/plugins/enterprise_search/server/ui_settings.ts index 0be413f8d9c6a..3334e625bc08f 100644 --- a/x-pack/plugins/enterprise_search/server/ui_settings.ts +++ b/x-pack/plugins/enterprise_search/server/ui_settings.ts @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; import { enterpriseSearchFeatureId, - enableIndexPipelinesTab, enableBehavioralAnalyticsSection, } from '../common/ui_settings_keys'; @@ -31,16 +30,4 @@ export const uiSettings: Record> = { schema: schema.boolean(), value: false, }, - [enableIndexPipelinesTab]: { - category: [enterpriseSearchFeatureId], - description: i18n.translate('xpack.enterpriseSearch.uiSettings.indexPipelines.description', { - defaultMessage: 'Enable the new index pipelines tab in Enterprise Search.', - }), - name: i18n.translate('xpack.enterpriseSearch.uiSettings.indexPipelines.name', { - defaultMessage: 'Enable index pipelines', - }), - requiresPageReload: false, - schema: schema.boolean(), - value: false, - }, }; diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index 776691a895c17..f12cd9585851b 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -43,6 +43,12 @@ export const autoUpdatePackages = [ FLEET_SYNTHETICS_PACKAGE, ]; +export const HIDDEN_API_REFERENCE_PACKAGES = [ + FLEET_ENDPOINT_PACKAGE, + FLEET_APM_PACKAGE, + FLEET_SYNTHETICS_PACKAGE, +]; + export const autoUpgradePoliciesPackages = [FLEET_APM_PACKAGE, FLEET_SYNTHETICS_PACKAGE]; export const agentAssetTypes = { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index cc49d03f1f1c6..f517d4092942e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -27,7 +27,11 @@ import { useCancelAddPackagePolicy, useOnSaveNavigate } from '../hooks'; import type { CreatePackagePolicyRequest } from '../../../../../../../common/types'; import { splitPkgKey } from '../../../../../../../common/services'; -import { dataTypes, FLEET_SYSTEM_PACKAGE } from '../../../../../../../common/constants'; +import { + dataTypes, + FLEET_SYSTEM_PACKAGE, + HIDDEN_API_REFERENCE_PACKAGES, +} from '../../../../../../../common/constants'; import { useConfirmForceInstall } from '../../../../../integrations/hooks'; import type { AgentPolicy, @@ -557,7 +561,13 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ }, ]; - const { showDevtoolsRequest } = ExperimentalFeaturesService.get(); + const { showDevtoolsRequest: isShowDevtoolRequestExperimentEnabled } = + ExperimentalFeaturesService.get(); + + const showDevtoolsRequest = + !HIDDEN_API_REFERENCE_PACKAGES.includes(packageInfo?.name ?? '') && + isShowDevtoolRequestExperimentEnabled; + const devtoolRequest = useMemo( () => generateCreatePackagePolicyDevToolsRequest({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index c0eeb7044c1cf..e11d1ccda3b91 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -63,6 +63,7 @@ import type { GetOnePackagePolicyResponse, UpgradePackagePolicyDryRunResponse, } from '../../../../../../common/types/rest_spec'; +import { HIDDEN_API_REFERENCE_PACKAGES } from '../../../../../../common/constants'; import type { PackagePolicyEditExtensionComponentProps } from '../../../types'; import { ExperimentalFeaturesService, pkgKeyFromPackageInfo } from '../../../services'; import { generateUpdatePackagePolicyDevToolsRequest } from '../services'; @@ -577,7 +578,12 @@ export const EditPackagePolicyForm = memo<{ ] ); - const { showDevtoolsRequest } = ExperimentalFeaturesService.get(); + const { showDevtoolsRequest: isShowDevtoolRequestExperimentEnabled } = + ExperimentalFeaturesService.get(); + + const showDevtoolsRequest = + !HIDDEN_API_REFERENCE_PACKAGES.includes(packageInfo?.name ?? '') && + isShowDevtoolRequestExperimentEnabled; const devtoolRequest = useMemo( () => diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index c55adfe4768d1..d43afbb28835c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -25,6 +25,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import semverLt from 'semver/functions/lt'; import { splitPkgKey } from '../../../../../../../common/services'; +import { HIDDEN_API_REFERENCE_PACKAGES } from '../../../../../../../common/constants'; + import { useGetPackageInstallStatus, useSetPackageInstallStatus, @@ -490,21 +492,23 @@ export function Detail() { }); } - tabs.push({ - id: 'api-reference', - name: ( - - ), - isSelected: panel === 'api-reference', - 'data-test-subj': `tab-api-reference`, - href: getHref('integration_details_api_reference', { - pkgkey: packageInfoKey, - ...(integration ? { integration } : {}), - }), - }); + if (!HIDDEN_API_REFERENCE_PACKAGES.includes(packageInfo.name)) { + tabs.push({ + id: 'api-reference', + name: ( + + ), + isSelected: panel === 'api-reference', + 'data-test-subj': `tab-api-reference`, + href: getHref('integration_details_api_reference', { + pkgkey: packageInfoKey, + ...(integration ? { integration } : {}), + }), + }); + } return tabs; }, [ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx index 2209e1810a788..2204dd16fd46a 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx @@ -364,7 +364,7 @@ export const aggregationType: { [key: string]: any } = { defaultMessage: 'Average', }), fieldRequired: true, - validNormalizedTypes: ['number'], + validNormalizedTypes: ['number', 'histogram'], value: AGGREGATION_TYPES.AVERAGE, }, max: { @@ -372,7 +372,7 @@ export const aggregationType: { [key: string]: any } = { defaultMessage: 'Max', }), fieldRequired: true, - validNormalizedTypes: ['number', 'date'], + validNormalizedTypes: ['number', 'date', 'histogram'], value: AGGREGATION_TYPES.MAX, }, min: { @@ -380,7 +380,7 @@ export const aggregationType: { [key: string]: any } = { defaultMessage: 'Min', }), fieldRequired: true, - validNormalizedTypes: ['number', 'date'], + validNormalizedTypes: ['number', 'date', 'histogram'], value: AGGREGATION_TYPES.MIN, }, cardinality: { @@ -413,7 +413,7 @@ export const aggregationType: { [key: string]: any } = { }), fieldRequired: false, value: AGGREGATION_TYPES.SUM, - validNormalizedTypes: ['number'], + validNormalizedTypes: ['number', 'histogram'], }, p95: { text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.p95', { @@ -421,7 +421,7 @@ export const aggregationType: { [key: string]: any } = { }), fieldRequired: false, value: AGGREGATION_TYPES.P95, - validNormalizedTypes: ['number'], + validNormalizedTypes: ['number', 'histogram'], }, p99: { text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.p99', { @@ -429,6 +429,6 @@ export const aggregationType: { [key: string]: any } = { }), fieldRequired: false, value: AGGREGATION_TYPES.P99, - validNormalizedTypes: ['number'], + validNormalizedTypes: ['number', 'histogram'], }, }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx index 960564fa5dfbf..542c71fa9fd4f 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx @@ -34,6 +34,7 @@ interface ExplorerAnomaliesContainerProps { onSelectEntity: (fieldName: string, fieldValue: string, operation: EntityFieldOperation) => void; showSelectedInterval?: boolean; chartsService: ChartsPluginStart; + timeRange: { from: string; to: string } | undefined; } const tooManyBucketsCalloutMsg = i18n.translate( @@ -56,6 +57,7 @@ export const ExplorerAnomaliesContainer: FC = ( onSelectEntity, showSelectedInterval, chartsService, + timeRange, }) => { return ( <> @@ -87,6 +89,7 @@ export const ExplorerAnomaliesContainer: FC = ( mlLocator, timeBuckets, timefilter, + timeRange, onSelectEntity, tooManyBucketsCalloutMsg, showSelectedInterval, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index d000b5cd465ef..30a32f1953a16 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -94,6 +94,7 @@ function ExplorerChartContainer({ mlLocator, timeBuckets, timefilter, + timeRange, onSelectEntity, recentlyAccessed, tooManyBucketsCalloutMsg, @@ -105,7 +106,6 @@ function ExplorerChartContainer({ const { services: { - data, share, application: { navigateToApp }, }, @@ -118,20 +118,35 @@ function ExplorerChartContainer({ const locator = share.url.locators.get(MAPS_APP_LOCATOR); const location = await locator.getLocation({ initialLayers: initialLayers, - timeRange: data.query.timefilter.timefilter.getTime(), + timeRange: timeRange ?? timefilter?.getTime(), ...(queryString !== undefined ? { query } : {}), }); return location; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [series?.jobId]); + }, [series?.jobId, timeRange]); useEffect(() => { let isCancelled = false; const generateLink = async () => { + // Prioritize timeRange from embeddable panel or case + // Else use the time range from data plugins's timefilters service + let mergedTimeRange = timeRange; + const bounds = timefilter?.getActiveBounds(); + if (!timeRange && bounds) { + mergedTimeRange = { + from: bounds.min.toISOString(), + to: bounds.max.toISOString(), + }; + } + if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { try { - const singleMetricViewerLink = await getExploreSeriesLink(mlLocator, series, timefilter); + const singleMetricViewerLink = await getExploreSeriesLink( + mlLocator, + series, + mergedTimeRange + ); setExplorerSeriesLink(singleMetricViewerLink); } catch (error) { setExplorerSeriesLink(''); @@ -143,7 +158,7 @@ function ExplorerChartContainer({ isCancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mlLocator, series]); + }, [mlLocator, series, timeRange]); useEffect( function getMapsPluginLink() { @@ -358,6 +373,7 @@ export const ExplorerChartsContainerUI = ({ mlLocator, timeBuckets, timefilter, + timeRange, onSelectEntity, tooManyBucketsCalloutMsg, showSelectedInterval, @@ -420,6 +436,7 @@ export const ExplorerChartsContainerUI = ({ mlLocator={mlLocator} timeBuckets={timeBuckets} timefilter={timefilter} + timeRange={timeRange} onSelectEntity={onSelectEntity} recentlyAccessed={recentlyAccessed} tooManyBucketsCalloutMsg={tooManyBucketsCalloutMsg} diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index b05c8b20b22ca..4936f80f1911c 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -174,12 +174,9 @@ export function getChartType(config) { return chartType; } -export async function getExploreSeriesLink(mlLocator, series, timefilter) { +export async function getExploreSeriesLink(mlLocator, series, timeRange) { // Open the Single Metric dashboard over the same overall bounds and // zoomed in to the same time as the current chart. - const bounds = timefilter.getActiveBounds(); - const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z - const to = bounds.max.toISOString(); const zoomFrom = moment(series.plotEarliest).toISOString(); const zoomTo = moment(series.plotLatest).toISOString(); @@ -206,11 +203,7 @@ export async function getExploreSeriesLink(mlLocator, series, timefilter) { pause: true, value: 0, }, - timeRange: { - from: from, - to: to, - mode: 'absolute', - }, + timeRange, zoom: { from: zoomFrom, to: zoomTo, diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap index cb9a915a105a8..531db022eb4ad 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap @@ -31,6 +31,7 @@ Object { "barTarget": undefined, "maxBars": undefined, }, + "timeRange": undefined, "timefilter": Object { "calculateBounds": [MockFunction], "createFilter": [MockFunction], diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx index 5a22cb7809a8c..95012ed2e890b 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx @@ -163,7 +163,7 @@ describe('EmbeddableAnomalyChartsContainer', () => { }); test('should render an error in case it could not fetch the ML charts data', async () => { - (useAnomalyChartsInputResolver as jest.Mock).mockReturnValueOnce({ + (useAnomalyChartsInputResolver as jest.Mock).mockReturnValue({ chartsData: undefined, isLoading: false, error: 'No anomalies', diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx index a0275176afa24..e9a22f1ad244e 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { FormattedMessage } from '@kbn/i18n-react'; import { throttle } from 'lodash'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import useObservable from 'react-use/lib/useObservable'; import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; import type { IAnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; @@ -90,6 +91,8 @@ export const EmbeddableAnomalyChartsContainer: FC { onInputChange({ severityThreshold: severity.val, @@ -204,6 +207,7 @@ export const EmbeddableAnomalyChartsContainer: FC )} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts index 3aab67794669f..dc339bf565cdc 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts @@ -33,6 +33,8 @@ export const LIST_TRUSTED_APPLICATION = 'trusted_application'; export const INSIGHTS_CHANNEL = 'security-insights-v1'; +export const TASK_METRICS_CHANNEL = 'task-metrics'; + export const DEFAULT_ADVANCED_POLICY_CONFIG_SETTINGS = { linux: { advanced: { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts index a07995029cd76..330add4425192 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts @@ -24,6 +24,7 @@ import { metricsResponseToValueListMetaData, tlog, setIsElasticCloudDeployment, + createTaskMetric, } from './helpers'; import type { ESClusterInfo, ESLicense, ExceptionListItem } from './types'; import type { PolicyConfig, PolicyData } from '../../../common/endpoint/types'; @@ -931,3 +932,42 @@ describe('test tlog', () => { expect(logger.debug).toHaveBeenCalled(); }); }); + +describe('test create task metrics', () => { + test('can succeed when all parameters are given', async () => { + const stubTaskName = 'test'; + const stubPassed = true; + const stubStartTime = Date.now(); + await new Promise((r) => setTimeout(r, 11)); + const response = createTaskMetric(stubTaskName, stubPassed, stubStartTime); + const { + time_executed_in_ms: timeExecutedInMs, + start_time: startTime, + end_time: endTime, + ...rest + } = response; + expect(timeExecutedInMs).toBeGreaterThan(10); + expect(rest).toEqual({ + name: 'test', + passed: true, + }); + }); + test('can succeed when error given', async () => { + const stubTaskName = 'test'; + const stubPassed = false; + const stubStartTime = Date.now(); + const errorMessage = 'failed'; + const response = createTaskMetric(stubTaskName, stubPassed, stubStartTime, errorMessage); + const { + time_executed_in_ms: timeExecutedInMs, + start_time: startTime, + end_time: endTime, + ...rest + } = response; + expect(rest).toEqual({ + name: 'test', + passed: false, + error_message: 'failed', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index 0c42a35a317e7..0fd7a0f6604c9 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -22,6 +22,7 @@ import type { ValueListExceptionListResponseAggregation, ValueListItemsResponseAggregation, ValueListIndicatorMatchResponseAggregation, + TaskMetric, } from './types'; import { LIST_DETECTION_RULE_EXCEPTION, @@ -280,3 +281,20 @@ export const tlog = (logger: Logger, message: string) => { logger.debug(message); } }; + +export const createTaskMetric = ( + name: string, + passed: boolean, + startTime: number, + errorMessage?: string +): TaskMetric => { + const endTime = Date.now(); + return { + name, + passed, + time_executed_in_ms: endTime - startTime, + start_time: startTime, + end_time: endTime, + error_message: errorMessage, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts index 1f22a4c97327d..4562cbb725cb4 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts @@ -6,8 +6,12 @@ */ import type { Logger } from '@kbn/core/server'; -import { LIST_DETECTION_RULE_EXCEPTION, TELEMETRY_CHANNEL_LISTS } from '../constants'; -import { batchTelemetryRecords, templateExceptionList, tlog } from '../helpers'; +import { + LIST_DETECTION_RULE_EXCEPTION, + TELEMETRY_CHANNEL_LISTS, + TASK_METRICS_CHANNEL, +} from '../constants'; +import { batchTelemetryRecords, templateExceptionList, tlog, createTaskMetric } from '../helpers'; import type { ITelemetryEventsSender } from '../sender'; import type { ITelemetryReceiver } from '../receiver'; import type { ExceptionListItem, ESClusterInfo, ESLicense, RuleSearchResult } from '../types'; @@ -27,74 +31,87 @@ export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: n sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { - tlog(logger, 'test'); - const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ - receiver.fetchClusterInfo(), - receiver.fetchLicenseInfo(), - ]); + const startTime = Date.now(); + const taskName = 'Security Solution Detection Rule Lists Telemetry'; + try { + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); - const clusterInfo = - clusterInfoPromise.status === 'fulfilled' - ? clusterInfoPromise.value - : ({} as ESClusterInfo); - const licenseInfo = - licenseInfoPromise.status === 'fulfilled' - ? licenseInfoPromise.value - : ({} as ESLicense | undefined); + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); - // Lists Telemetry: Detection Rules + // Lists Telemetry: Detection Rules - const { body: prebuiltRules } = await receiver.fetchDetectionRules(); + const { body: prebuiltRules } = await receiver.fetchDetectionRules(); - if (!prebuiltRules) { - tlog(logger, 'no prebuilt rules found'); - return 0; - } + if (!prebuiltRules) { + tlog(logger, 'no prebuilt rules found'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return 0; + } - const cacheArray = prebuiltRules.hits.hits.reduce((cache, searchHit) => { - const rule = searchHit._source as RuleSearchResult; - const ruleId = rule.alert.params.ruleId; + const cacheArray = prebuiltRules.hits.hits.reduce((cache, searchHit) => { + const rule = searchHit._source as RuleSearchResult; + const ruleId = rule.alert.params.ruleId; - const shouldNotProcess = - rule === null || - rule === undefined || - ruleId === null || - ruleId === undefined || - searchHit._source?.alert.params.exceptionsList.length === 0; + const shouldNotProcess = + rule === null || + rule === undefined || + ruleId === null || + ruleId === undefined || + searchHit._source?.alert.params.exceptionsList.length === 0; - if (shouldNotProcess) { - return cache; - } + if (shouldNotProcess) { + return cache; + } - cache.push(rule); - return cache; - }, [] as RuleSearchResult[]); + cache.push(rule); + return cache; + }, [] as RuleSearchResult[]); - const detectionRuleExceptions = [] as ExceptionListItem[]; - for (const item of cacheArray) { - const ruleVersion = item.alert.params.version; + const detectionRuleExceptions = [] as ExceptionListItem[]; + for (const item of cacheArray) { + const ruleVersion = item.alert.params.version; - for (const ex of item.alert.params.exceptionsList) { - const listItem = await receiver.fetchDetectionExceptionList(ex.list_id, ruleVersion); - for (const exceptionItem of listItem.data) { - detectionRuleExceptions.push(exceptionItem); + for (const ex of item.alert.params.exceptionsList) { + const listItem = await receiver.fetchDetectionExceptionList(ex.list_id, ruleVersion); + for (const exceptionItem of listItem.data) { + detectionRuleExceptions.push(exceptionItem); + } } } - } - const detectionRuleExceptionsJson = templateExceptionList( - detectionRuleExceptions, - clusterInfo, - licenseInfo, - LIST_DETECTION_RULE_EXCEPTION - ); - tlog(logger, `Detection rule exception json length ${detectionRuleExceptionsJson.length}`); - const batches = batchTelemetryRecords(detectionRuleExceptionsJson, maxTelemetryBatch); - for (const batch of batches) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + const detectionRuleExceptionsJson = templateExceptionList( + detectionRuleExceptions, + clusterInfo, + licenseInfo, + LIST_DETECTION_RULE_EXCEPTION + ); + tlog(logger, `Detection rule exception json length ${detectionRuleExceptionsJson.length}`); + const batches = batchTelemetryRecords(detectionRuleExceptionsJson, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + } + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return detectionRuleExceptions.length; + } catch (err) { + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); + return 0; } - - return detectionRuleExceptions.length; }, }; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts index 45d3eeb40a801..a83326334c9d7 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts @@ -46,5 +46,6 @@ describe('diagnostics telemetry task test', () => { expect(mockTelemetryEventsSender.queueTelemetryEvents).toHaveBeenCalledWith( testDiagnosticsAlerts.hits.hits.flatMap((doc) => [doc._source]) ); + expect(mockTelemetryEventsSender.sendOnDemand).toBeCalledTimes(1); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts index 579e0e6cf9675..5c4604289eb51 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts @@ -6,11 +6,12 @@ */ import type { Logger } from '@kbn/core/server'; -import { tlog, getPreviousDiagTaskTimestamp } from '../helpers'; +import { tlog, getPreviousDiagTaskTimestamp, createTaskMetric } from '../helpers'; import type { ITelemetryEventsSender } from '../sender'; import type { TelemetryEvent } from '../types'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; +import { TASK_METRICS_CHANNEL } from '../constants'; export function createTelemetryDiagnosticsTaskConfig() { return { @@ -27,26 +28,41 @@ export function createTelemetryDiagnosticsTaskConfig() { sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { - if (!taskExecutionPeriod.last) { - throw new Error('last execution timestamp is required'); - } + const startTime = Date.now(); + const taskName = 'Security Solution Telemetry Diagnostics task'; + try { + if (!taskExecutionPeriod.last) { + throw new Error('last execution timestamp is required'); + } - const response = await receiver.fetchDiagnosticAlerts( - taskExecutionPeriod.last, - taskExecutionPeriod.current - ); + const response = await receiver.fetchDiagnosticAlerts( + taskExecutionPeriod.last, + taskExecutionPeriod.current + ); - const hits = response.hits?.hits || []; - if (!Array.isArray(hits) || !hits.length) { - tlog(logger, 'no diagnostic alerts retrieved'); + const hits = response.hits?.hits || []; + if (!Array.isArray(hits) || !hits.length) { + tlog(logger, 'no diagnostic alerts retrieved'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return 0; + } + tlog(logger, `Received ${hits.length} diagnostic alerts`); + const diagAlerts: TelemetryEvent[] = hits.flatMap((h) => + h._source != null ? [h._source] : [] + ); + sender.queueTelemetryEvents(diagAlerts); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return diagAlerts.length; + } catch (err) { + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); return 0; } - tlog(logger, `Received ${hits.length} diagnostic alerts`); - const diagAlerts: TelemetryEvent[] = hits.flatMap((h) => - h._source != null ? [h._source] : [] - ); - sender.queueTelemetryEvents(diagAlerts); - return diagAlerts.length; }, }; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index c3c1cdf54e4d9..d3c40b29e218f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -27,9 +27,10 @@ import { getPreviousDailyTaskTimestamp, isPackagePolicyList, tlog, + createTaskMetric, } from '../helpers'; import type { PolicyData } from '../../../../common/endpoint/types'; -import { TELEMETRY_CHANNEL_ENDPOINT_META } from '../constants'; +import { TELEMETRY_CHANNEL_ENDPOINT_META, TASK_METRICS_CHANNEL } from '../constants'; // Endpoint agent uses this Policy ID while it's installing. const DefaultEndpointPolicyIdToIgnore = '00000000-0000-0000-0000-000000000000'; @@ -58,294 +59,320 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { - tlog(logger, 'test'); - if (!taskExecutionPeriod.last) { - throw new Error('last execution timestamp is required'); - } - const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ - receiver.fetchClusterInfo(), - receiver.fetchLicenseInfo(), - ]); - - const clusterInfo = - clusterInfoPromise.status === 'fulfilled' - ? clusterInfoPromise.value - : ({} as ESClusterInfo); - const licenseInfo = - licenseInfoPromise.status === 'fulfilled' - ? licenseInfoPromise.value - : ({} as ESLicense | undefined); - - const endpointData = await fetchEndpointData( - receiver, - taskExecutionPeriod.last, - taskExecutionPeriod.current - ); - - /** STAGE 1 - Fetch Endpoint Agent Metrics - * - * Reads Endpoint Agent metrics out of the `.ds-metrics-endpoint.metrics` data stream - * and buckets them by Endpoint Agent id and sorts by the top hit. The EP agent will - * report its metrics once per day OR every time a policy change has occured. If - * a metric document(s) exists for an EP agent we map to fleet agent and policy - */ - if (endpointData.endpointMetrics === undefined) { - tlog(logger, `no endpoint metrics to report`); - return 0; - } + const startTime = Date.now(); + const taskName = 'Security Solution Telemetry Endpoint Metrics and Info task'; + try { + if (!taskExecutionPeriod.last) { + throw new Error('last execution timestamp is required'); + } + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); + + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + + const endpointData = await fetchEndpointData( + receiver, + taskExecutionPeriod.last, + taskExecutionPeriod.current + ); - const { body: endpointMetricsResponse } = endpointData.endpointMetrics as unknown as { - body: EndpointMetricsAggregation; - }; + /** STAGE 1 - Fetch Endpoint Agent Metrics + * + * Reads Endpoint Agent metrics out of the `.ds-metrics-endpoint.metrics` data stream + * and buckets them by Endpoint Agent id and sorts by the top hit. The EP agent will + * report its metrics once per day OR every time a policy change has occured. If + * a metric document(s) exists for an EP agent we map to fleet agent and policy + */ + if (endpointData.endpointMetrics === undefined) { + tlog(logger, `no endpoint metrics to report`); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return 0; + } - if (endpointMetricsResponse.aggregations === undefined) { - tlog(logger, `no endpoint metrics to report`); - return 0; - } + const { body: endpointMetricsResponse } = endpointData.endpointMetrics as unknown as { + body: EndpointMetricsAggregation; + }; - const telemetryUsageCounter = sender.getTelemetryUsageCluster(); - telemetryUsageCounter?.incrementCounter({ - counterName: createUsageCounterLabel( - usageLabelPrefix.concat(['payloads', TELEMETRY_CHANNEL_ENDPOINT_META]) - ), - counterType: 'num_endpoint', - incrementBy: endpointMetricsResponse.aggregations.endpoint_count.value, - }); - - const endpointMetrics = endpointMetricsResponse.aggregations.endpoint_agents.buckets.map( - (epMetrics) => { - return { - endpoint_agent: epMetrics.latest_metrics.hits.hits[0]._source.agent.id, - endpoint_version: epMetrics.latest_metrics.hits.hits[0]._source.agent.version, - endpoint_metrics: epMetrics.latest_metrics.hits.hits[0]._source, - }; + if (endpointMetricsResponse.aggregations === undefined) { + tlog(logger, `no endpoint metrics to report`); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return 0; } - ); - - /** STAGE 2 - Fetch Fleet Agent Config - * - * As the policy id + policy version does not exist on the Endpoint Metrics document - * we need to fetch information about the Fleet Agent and sync the metrics document - * with the Agent's policy data. - * - */ - const agentsResponse = endpointData.fleetAgentsResponse; - - if (agentsResponse === undefined) { - tlog(logger, 'no fleet agent information available'); - return 0; - } - const fleetAgents = agentsResponse.agents.reduce((cache, agent) => { - if (agent.id === DefaultEndpointPolicyIdToIgnore) { + const telemetryUsageCounter = sender.getTelemetryUsageCluster(); + telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel( + usageLabelPrefix.concat(['payloads', TELEMETRY_CHANNEL_ENDPOINT_META]) + ), + counterType: 'num_endpoint', + incrementBy: endpointMetricsResponse.aggregations.endpoint_count.value, + }); + + const endpointMetrics = endpointMetricsResponse.aggregations.endpoint_agents.buckets.map( + (epMetrics) => { + return { + endpoint_agent: epMetrics.latest_metrics.hits.hits[0]._source.agent.id, + endpoint_version: epMetrics.latest_metrics.hits.hits[0]._source.agent.version, + endpoint_metrics: epMetrics.latest_metrics.hits.hits[0]._source, + }; + } + ); + + /** STAGE 2 - Fetch Fleet Agent Config + * + * As the policy id + policy version does not exist on the Endpoint Metrics document + * we need to fetch information about the Fleet Agent and sync the metrics document + * with the Agent's policy data. + * + */ + const agentsResponse = endpointData.fleetAgentsResponse; + + if (agentsResponse === undefined) { + tlog(logger, 'no fleet agent information available'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return 0; + } + + const fleetAgents = agentsResponse.agents.reduce((cache, agent) => { + if (agent.id === DefaultEndpointPolicyIdToIgnore) { + return cache; + } + + if (agent.policy_id !== null && agent.policy_id !== undefined) { + cache.set(agent.id, agent.policy_id); + } + return cache; + }, new Map()); + + const endpointPolicyCache = new Map(); + for (const policyInfo of fleetAgents.values()) { + if ( + policyInfo !== null && + policyInfo !== undefined && + !endpointPolicyCache.has(policyInfo) + ) { + tlog(logger, `policy info exists as ${policyInfo}`); + const agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); + const packagePolicies = agentPolicy?.package_policies; + + if (packagePolicies !== undefined && isPackagePolicyList(packagePolicies)) { + tlog(logger, `package policy exists as ${JSON.stringify(packagePolicies)}`); + packagePolicies + .map((pPolicy) => pPolicy as PolicyData) + .forEach((pPolicy) => { + if ( + pPolicy.inputs[0]?.config !== undefined && + pPolicy.inputs[0]?.config !== null + ) { + pPolicy.inputs.forEach((input) => { + if ( + input.type === FLEET_ENDPOINT_PACKAGE && + input?.config !== undefined && + policyInfo !== undefined + ) { + endpointPolicyCache.set(policyInfo, pPolicy); + } + }); + } + }); + } + } } - if (agent.policy_id !== null && agent.policy_id !== undefined) { - cache.set(agent.id, agent.policy_id); + /** STAGE 3 - Fetch Endpoint Policy Responses + * + * Reads Endpoint Agent policy responses out of the `.ds-metrics-endpoint.policy*` data + * stream and creates a local K/V structure that stores the policy response (V) with + * the Endpoint Agent Id (K). A value will only exist if there has been a endpoint + * enrolled in the last 24 hours OR a policy change has occurred. We only send + * non-successful responses. If the field is null, we assume no responses in + * the last 24h or no failures/warnings in the policy applied. + * + */ + const { body: failedPolicyResponses } = endpointData.epPolicyResponse as unknown as { + body: EndpointPolicyResponseAggregation; + }; + + // If there is no policy responses in the 24h > now then we will continue + const policyResponses = failedPolicyResponses.aggregations + ? failedPolicyResponses.aggregations.policy_responses.buckets.reduce( + (cache, endpointAgentId) => { + const doc = endpointAgentId.latest_response.hits.hits[0]; + cache.set(endpointAgentId.key, doc); + return cache; + }, + new Map() + ) + : new Map(); + + tlog( + logger, + `policy responses exists as ${JSON.stringify(Object.fromEntries(policyResponses))}` + ); + + /** STAGE 4 - Fetch Endpoint Agent Metadata + * + * Reads Endpoint Agent metadata out of the `.ds-metrics-endpoint.metadata` data stream + * and buckets them by Endpoint Agent id and sorts by the top hit. The EP agent will + * report its metadata once per day OR every time a policy change has occured. If + * a metadata document(s) exists for an EP agent we map to fleet agent and policy + */ + if (endpointData.endpointMetadata === undefined) { + tlog(logger, `no endpoint metadata to report`); } - return cache; - }, new Map()); - - const endpointPolicyCache = new Map(); - for (const policyInfo of fleetAgents.values()) { - if ( - policyInfo !== null && - policyInfo !== undefined && - !endpointPolicyCache.has(policyInfo) - ) { - tlog(logger, `policy info exists as ${policyInfo}`); - const agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); - const packagePolicies = agentPolicy?.package_policies; - - if (packagePolicies !== undefined && isPackagePolicyList(packagePolicies)) { - tlog(logger, `package policy exists as ${JSON.stringify(packagePolicies)}`); - packagePolicies - .map((pPolicy) => pPolicy as PolicyData) - .forEach((pPolicy) => { - if (pPolicy.inputs[0]?.config !== undefined && pPolicy.inputs[0]?.config !== null) { - pPolicy.inputs.forEach((input) => { - if ( - input.type === FLEET_ENDPOINT_PACKAGE && - input?.config !== undefined && - policyInfo !== undefined - ) { - endpointPolicyCache.set(policyInfo, pPolicy); - } - }); - } - }); - } + const { body: endpointMetadataResponse } = endpointData.endpointMetadata as unknown as { + body: EndpointMetadataAggregation; + }; + + if (endpointMetadataResponse.aggregations === undefined) { + tlog(logger, `no endpoint metadata to report`); } - } - /** STAGE 3 - Fetch Endpoint Policy Responses - * - * Reads Endpoint Agent policy responses out of the `.ds-metrics-endpoint.policy*` data - * stream and creates a local K/V structure that stores the policy response (V) with - * the Endpoint Agent Id (K). A value will only exist if there has been a endpoint - * enrolled in the last 24 hours OR a policy change has occurred. We only send - * non-successful responses. If the field is null, we assume no responses in - * the last 24h or no failures/warnings in the policy applied. - * - */ - const { body: failedPolicyResponses } = endpointData.epPolicyResponse as unknown as { - body: EndpointPolicyResponseAggregation; - }; - - // If there is no policy responses in the 24h > now then we will continue - const policyResponses = failedPolicyResponses.aggregations - ? failedPolicyResponses.aggregations.policy_responses.buckets.reduce( + const endpointMetadata = + endpointMetadataResponse.aggregations.endpoint_metadata.buckets.reduce( (cache, endpointAgentId) => { - const doc = endpointAgentId.latest_response.hits.hits[0]; + const doc = endpointAgentId.latest_metadata.hits.hits[0]; cache.set(endpointAgentId.key, doc); return cache; }, - new Map() - ) - : new Map(); - - tlog( - logger, - `policy responses exists as ${JSON.stringify(Object.fromEntries(policyResponses))}` - ); - - /** STAGE 4 - Fetch Endpoint Agent Metadata - * - * Reads Endpoint Agent metadata out of the `.ds-metrics-endpoint.metadata` data stream - * and buckets them by Endpoint Agent id and sorts by the top hit. The EP agent will - * report its metadata once per day OR every time a policy change has occured. If - * a metadata document(s) exists for an EP agent we map to fleet agent and policy - */ - if (endpointData.endpointMetadata === undefined) { - tlog(logger, `no endpoint metadata to report`); - } - - const { body: endpointMetadataResponse } = endpointData.endpointMetadata as unknown as { - body: EndpointMetadataAggregation; - }; - - if (endpointMetadataResponse.aggregations === undefined) { - tlog(logger, `no endpoint metadata to report`); - } - - const endpointMetadata = - endpointMetadataResponse.aggregations.endpoint_metadata.buckets.reduce( - (cache, endpointAgentId) => { - const doc = endpointAgentId.latest_metadata.hits.hits[0]; - cache.set(endpointAgentId.key, doc); - return cache; - }, - new Map() + new Map() + ); + tlog( + logger, + `endpoint metadata exists as ${JSON.stringify(Object.fromEntries(endpointMetadata))}` ); - tlog( - logger, - `endpoint metadata exists as ${JSON.stringify(Object.fromEntries(endpointMetadata))}` - ); - /** STAGE 5 - Create the telemetry log records - * - * Iterates through the endpoint metrics documents at STAGE 1 and joins them together - * to form the telemetry log that is sent back to Elastic Security developers to - * make improvements to the product. - * - */ - try { - const telemetryPayloads = endpointMetrics.map((endpoint) => { - let policyConfig = null; - let failedPolicy = null; - let endpointMetadataById = null; - - const fleetAgentId = endpoint.endpoint_metrics.elastic.agent.id; - const endpointAgentId = endpoint.endpoint_agent; - - const policyInformation = fleetAgents.get(fleetAgentId); - if (policyInformation) { - policyConfig = endpointPolicyCache.get(policyInformation) || null; - - if (policyConfig) { - failedPolicy = policyResponses.get(endpointAgentId); + /** STAGE 5 - Create the telemetry log records + * + * Iterates through the endpoint metrics documents at STAGE 1 and joins them together + * to form the telemetry log that is sent back to Elastic Security developers to + * make improvements to the product. + * + */ + try { + const telemetryPayloads = endpointMetrics.map((endpoint) => { + let policyConfig = null; + let failedPolicy = null; + let endpointMetadataById = null; + + const fleetAgentId = endpoint.endpoint_metrics.elastic.agent.id; + const endpointAgentId = endpoint.endpoint_agent; + + const policyInformation = fleetAgents.get(fleetAgentId); + if (policyInformation) { + policyConfig = endpointPolicyCache.get(policyInformation) || null; + + if (policyConfig) { + failedPolicy = policyResponses.get(endpointAgentId); + } } - } - if (endpointMetadata) { - endpointMetadataById = endpointMetadata.get(endpointAgentId); - } + if (endpointMetadata) { + endpointMetadataById = endpointMetadata.get(endpointAgentId); + } - const { - cpu, - memory, - uptime, - documents_volume: documentsVolume, - malicious_behavior_rules: maliciousBehaviorRules, - system_impact: systemImpact, - threads, - event_filter: eventFilter, - } = endpoint.endpoint_metrics.Endpoint.metrics; - const endpointPolicyDetail = extractEndpointPolicyConfig(policyConfig); - if (endpointPolicyDetail) { - endpointPolicyDetail.value = addDefaultAdvancedPolicyConfigSettings( - endpointPolicyDetail.value - ); - } - return { - '@timestamp': taskExecutionPeriod.current, - cluster_uuid: clusterInfo.cluster_uuid, - cluster_name: clusterInfo.cluster_name, - license_id: licenseInfo?.uid, - endpoint_id: endpointAgentId, - endpoint_version: endpoint.endpoint_version, - endpoint_package_version: policyConfig?.package?.version || null, - endpoint_metrics: { - cpu: cpu.endpoint, - memory: memory.endpoint.private, + const { + cpu, + memory, uptime, - documentsVolume, - maliciousBehaviorRules, - systemImpact, + documents_volume: documentsVolume, + malicious_behavior_rules: maliciousBehaviorRules, + system_impact: systemImpact, threads, - eventFilter, - }, - endpoint_meta: { - os: endpoint.endpoint_metrics.host.os, - capabilities: - endpointMetadataById !== null && endpointMetadataById !== undefined - ? endpointMetadataById._source.Endpoint.capabilities - : [], - }, - policy_config: endpointPolicyDetail !== null ? endpointPolicyDetail : {}, - policy_response: - failedPolicy !== null && failedPolicy !== undefined - ? { - agent_policy_status: failedPolicy._source.event.agent_id_status, - manifest_version: - failedPolicy._source.Endpoint.policy.applied.artifacts.global.version, - status: failedPolicy._source.Endpoint.policy.applied.status, - actions: failedPolicy._source.Endpoint.policy.applied.actions - .map((action) => (action.status !== 'success' ? action : null)) - .filter((action) => action !== null), - configuration: failedPolicy._source.Endpoint.configuration, - state: failedPolicy._source.Endpoint.state, - } - : {}, - telemetry_meta: { - metrics_timestamp: endpoint.endpoint_metrics['@timestamp'], - }, - }; - }); - - /** - * STAGE 6 - Send the documents - * - * Send the documents in a batches of maxTelemetryBatch - */ - const batches = batchTelemetryRecords(telemetryPayloads, maxTelemetryBatch); - for (const batch of batches) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_ENDPOINT_META, batch); + event_filter: eventFilter, + } = endpoint.endpoint_metrics.Endpoint.metrics; + const endpointPolicyDetail = extractEndpointPolicyConfig(policyConfig); + if (endpointPolicyDetail) { + endpointPolicyDetail.value = addDefaultAdvancedPolicyConfigSettings( + endpointPolicyDetail.value + ); + } + return { + '@timestamp': taskExecutionPeriod.current, + cluster_uuid: clusterInfo.cluster_uuid, + cluster_name: clusterInfo.cluster_name, + license_id: licenseInfo?.uid, + endpoint_id: endpointAgentId, + endpoint_version: endpoint.endpoint_version, + endpoint_package_version: policyConfig?.package?.version || null, + endpoint_metrics: { + cpu: cpu.endpoint, + memory: memory.endpoint.private, + uptime, + documentsVolume, + maliciousBehaviorRules, + systemImpact, + threads, + eventFilter, + }, + endpoint_meta: { + os: endpoint.endpoint_metrics.host.os, + capabilities: + endpointMetadataById !== null && endpointMetadataById !== undefined + ? endpointMetadataById._source.Endpoint.capabilities + : [], + }, + policy_config: endpointPolicyDetail !== null ? endpointPolicyDetail : {}, + policy_response: + failedPolicy !== null && failedPolicy !== undefined + ? { + agent_policy_status: failedPolicy._source.event.agent_id_status, + manifest_version: + failedPolicy._source.Endpoint.policy.applied.artifacts.global.version, + status: failedPolicy._source.Endpoint.policy.applied.status, + actions: failedPolicy._source.Endpoint.policy.applied.actions + .map((action) => (action.status !== 'success' ? action : null)) + .filter((action) => action !== null), + configuration: failedPolicy._source.Endpoint.configuration, + state: failedPolicy._source.Endpoint.state, + } + : {}, + telemetry_meta: { + metrics_timestamp: endpoint.endpoint_metrics['@timestamp'], + }, + }; + }); + + /** + * STAGE 6 - Send the documents + * + * Send the documents in a batches of maxTelemetryBatch + */ + const batches = batchTelemetryRecords(telemetryPayloads, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_ENDPOINT_META, batch); + } + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return telemetryPayloads.length; + } catch (err) { + logger.warn(`could not complete endpoint alert telemetry task due to ${err?.message}`); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); + return 0; } - return telemetryPayloads.length; } catch (err) { - logger.warn(`could not complete endpoint alert telemetry task due to ${err?.message}`); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); return 0; } }, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts index 44a6b3cf644f4..33d33924fcf36 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts @@ -10,8 +10,8 @@ import type { ITelemetryEventsSender } from '../sender'; import type { ITelemetryReceiver } from '../receiver'; import type { ESClusterInfo, ESLicense, TelemetryEvent } from '../types'; import type { TaskExecutionPeriod } from '../task'; -import { TELEMETRY_CHANNEL_DETECTION_ALERTS } from '../constants'; -import { batchTelemetryRecords, tlog } from '../helpers'; +import { TELEMETRY_CHANNEL_DETECTION_ALERTS, TASK_METRICS_CHANNEL } from '../constants'; +import { batchTelemetryRecords, tlog, createTaskMetric } from '../helpers'; import { copyAllowlistedFields, prebuiltRuleAllowlistFields } from '../filterlists'; export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: number) { @@ -28,6 +28,8 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { + const startTime = Date.now(); + const taskName = 'Security Solution - Prebuilt Rule and Elastic ML Alerts Telemetry'; try { const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ receiver.fetchClusterInfo(), @@ -54,6 +56,9 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n if (telemetryEvents.length === 0) { tlog(logger, 'no prebuilt rule alerts retrieved'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); return 0; } @@ -76,10 +81,15 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n for (const batch of batches) { await sender.sendOnDemand(TELEMETRY_CHANNEL_DETECTION_ALERTS, batch); } - + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); return enrichedAlerts.length; } catch (err) { logger.error('could not complete prebuilt alerts telemetry task'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); return 0; } }, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts index a6023d809c6b0..08baef614c1b8 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts @@ -15,9 +15,10 @@ import { LIST_ENDPOINT_EVENT_FILTER, LIST_TRUSTED_APPLICATION, TELEMETRY_CHANNEL_LISTS, + TASK_METRICS_CHANNEL, } from '../constants'; import type { ESClusterInfo, ESLicense } from '../types'; -import { batchTelemetryRecords, templateExceptionList, tlog } from '../helpers'; +import { batchTelemetryRecords, templateExceptionList, tlog, createTaskMetric } from '../helpers'; import type { ITelemetryEventsSender } from '../sender'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; @@ -36,88 +37,100 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number) sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { - let count = 0; + const startTime = Date.now(); + const taskName = 'Security Solution Lists Telemetry'; + try { + let count = 0; - const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ - receiver.fetchClusterInfo(), - receiver.fetchLicenseInfo(), - ]); + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); - const clusterInfo = - clusterInfoPromise.status === 'fulfilled' - ? clusterInfoPromise.value - : ({} as ESClusterInfo); - const licenseInfo = - licenseInfoPromise.status === 'fulfilled' - ? licenseInfoPromise.value - : ({} as ESLicense | undefined); - const FETCH_VALUE_LIST_META_DATA_INTERVAL_IN_HOURS = 24; + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + const FETCH_VALUE_LIST_META_DATA_INTERVAL_IN_HOURS = 24; - // Lists Telemetry: Trusted Applications - const trustedApps = await receiver.fetchTrustedApplications(); - if (trustedApps?.data) { - const trustedAppsJson = templateExceptionList( - trustedApps.data, - clusterInfo, - licenseInfo, - LIST_TRUSTED_APPLICATION - ); - tlog(logger, `Trusted Apps: ${trustedAppsJson}`); - count += trustedAppsJson.length; + // Lists Telemetry: Trusted Applications + const trustedApps = await receiver.fetchTrustedApplications(); + if (trustedApps?.data) { + const trustedAppsJson = templateExceptionList( + trustedApps.data, + clusterInfo, + licenseInfo, + LIST_TRUSTED_APPLICATION + ); + tlog(logger, `Trusted Apps: ${trustedAppsJson}`); + count += trustedAppsJson.length; - const batches = batchTelemetryRecords(trustedAppsJson, maxTelemetryBatch); - for (const batch of batches) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + const batches = batchTelemetryRecords(trustedAppsJson, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + } } - } - // Lists Telemetry: Endpoint Exceptions + // Lists Telemetry: Endpoint Exceptions - const epExceptions = await receiver.fetchEndpointList(ENDPOINT_LIST_ID); - if (epExceptions?.data) { - const epExceptionsJson = templateExceptionList( - epExceptions.data, - clusterInfo, - licenseInfo, - LIST_ENDPOINT_EXCEPTION - ); - tlog(logger, `EP Exceptions: ${epExceptionsJson}`); - count += epExceptionsJson.length; + const epExceptions = await receiver.fetchEndpointList(ENDPOINT_LIST_ID); + if (epExceptions?.data) { + const epExceptionsJson = templateExceptionList( + epExceptions.data, + clusterInfo, + licenseInfo, + LIST_ENDPOINT_EXCEPTION + ); + tlog(logger, `EP Exceptions: ${epExceptionsJson}`); + count += epExceptionsJson.length; - const batches = batchTelemetryRecords(epExceptionsJson, maxTelemetryBatch); - for (const batch of batches) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + const batches = batchTelemetryRecords(epExceptionsJson, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + } } - } - // Lists Telemetry: Endpoint Event Filters + // Lists Telemetry: Endpoint Event Filters - const epFilters = await receiver.fetchEndpointList(ENDPOINT_EVENT_FILTERS_LIST_ID); - if (epFilters?.data) { - const epFiltersJson = templateExceptionList( - epFilters.data, - clusterInfo, - licenseInfo, - LIST_ENDPOINT_EVENT_FILTER - ); - tlog(logger, `EP Event Filters: ${epFiltersJson}`); - count += epFiltersJson.length; + const epFilters = await receiver.fetchEndpointList(ENDPOINT_EVENT_FILTERS_LIST_ID); + if (epFilters?.data) { + const epFiltersJson = templateExceptionList( + epFilters.data, + clusterInfo, + licenseInfo, + LIST_ENDPOINT_EVENT_FILTER + ); + tlog(logger, `EP Event Filters: ${epFiltersJson}`); + count += epFiltersJson.length; - const batches = batchTelemetryRecords(epFiltersJson, maxTelemetryBatch); - for (const batch of batches) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + const batches = batchTelemetryRecords(epFiltersJson, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + } } - } - // Value list meta data - const valueListMetaData = await receiver.fetchValueListMetaData( - FETCH_VALUE_LIST_META_DATA_INTERVAL_IN_HOURS - ); - tlog(logger, `Value List Meta Data: ${JSON.stringify(valueListMetaData)}`); - if (valueListMetaData?.total_list_count) { - await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, [valueListMetaData]); + // Value list meta data + const valueListMetaData = await receiver.fetchValueListMetaData( + FETCH_VALUE_LIST_META_DATA_INTERVAL_IN_HOURS + ); + tlog(logger, `Value List Meta Data: ${JSON.stringify(valueListMetaData)}`); + if (valueListMetaData?.total_list_count) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, [valueListMetaData]); + } + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return count; + } catch (err) { + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); + return 0; } - return count; }, }; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts index 7a460caa197d7..16794dfa3f68f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts @@ -60,6 +60,5 @@ describe('timeline telemetry task test', () => { expect(mockTelemetryReceiver.buildProcessTree).toHaveBeenCalled(); expect(mockTelemetryReceiver.fetchTimelineEvents).toHaveBeenCalled(); expect(mockTelemetryReceiver.fetchTimelineEndpointAlerts).toHaveBeenCalled(); - expect(mockTelemetryEventsSender.sendOnDemand).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts index 8403bbd7f30fd..4fdfc4a726a35 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts @@ -17,9 +17,9 @@ import type { TimelineTelemetryTemplate, TimelineTelemetryEvent, } from '../types'; -import { TELEMETRY_CHANNEL_TIMELINE } from '../constants'; +import { TELEMETRY_CHANNEL_TIMELINE, TASK_METRICS_CHANNEL } from '../constants'; import { resolverEntity } from '../../../endpoint/routes/resolver/entity/utils/build_resolver_entity'; -import { tlog } from '../helpers'; +import { tlog, createTaskMetric } from '../helpers'; export function createTelemetryTimelineTaskConfig() { return { @@ -35,145 +35,159 @@ export function createTelemetryTimelineTaskConfig() { sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { - let counter = 0; - - tlog(logger, `Running task: ${taskId}`); - - const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ - receiver.fetchClusterInfo(), - receiver.fetchLicenseInfo(), - ]); - - const clusterInfo = - clusterInfoPromise.status === 'fulfilled' - ? clusterInfoPromise.value - : ({} as ESClusterInfo); - - const licenseInfo = - licenseInfoPromise.status === 'fulfilled' - ? licenseInfoPromise.value - : ({} as ESLicense | undefined); - - const now = moment(); - const startOfDay = now.startOf('day').toISOString(); - const endOfDay = now.endOf('day').toISOString(); - - const baseDocument = { - version: clusterInfo.version?.number, - cluster_name: clusterInfo.cluster_name, - cluster_uuid: clusterInfo.cluster_uuid, - license_uuid: licenseInfo?.uid, - }; - - // Fetch EP Alerts - - const endpointAlerts = await receiver.fetchTimelineEndpointAlerts(3); - - const aggregations = endpointAlerts?.aggregations as unknown as { - endpoint_alert_count: { value: number }; - }; - tlog(logger, `Endpoint alert count: ${aggregations?.endpoint_alert_count}`); - sender.getTelemetryUsageCluster()?.incrementCounter({ - counterName: 'telemetry_endpoint_alert', - counterType: 'endpoint_alert_count', - incrementBy: aggregations?.endpoint_alert_count.value, - }); - - // No EP Alerts -> Nothing to do - if ( - endpointAlerts.hits.hits?.length === 0 || - endpointAlerts.hits.hits?.length === undefined - ) { - tlog(logger, 'no endpoint alerts received. exiting telemetry task.'); - return counter; - } + const startTime = Date.now(); + const taskName = 'Security Solution Timeline telemetry'; + try { + let counter = 0; + + tlog(logger, `Running task: ${taskId}`); + + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); + + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + + const now = moment(); + const startOfDay = now.startOf('day').toISOString(); + const endOfDay = now.endOf('day').toISOString(); + + const baseDocument = { + version: clusterInfo.version?.number, + cluster_name: clusterInfo.cluster_name, + cluster_uuid: clusterInfo.cluster_uuid, + license_uuid: licenseInfo?.uid, + }; + + // Fetch EP Alerts + + const endpointAlerts = await receiver.fetchTimelineEndpointAlerts(3); + + const aggregations = endpointAlerts?.aggregations as unknown as { + endpoint_alert_count: { value: number }; + }; + tlog(logger, `Endpoint alert count: ${aggregations?.endpoint_alert_count}`); + sender.getTelemetryUsageCluster()?.incrementCounter({ + counterName: 'telemetry_endpoint_alert', + counterType: 'endpoint_alert_count', + incrementBy: aggregations?.endpoint_alert_count.value, + }); + + // No EP Alerts -> Nothing to do + if ( + endpointAlerts.hits.hits?.length === 0 || + endpointAlerts.hits.hits?.length === undefined + ) { + tlog(logger, 'no endpoint alerts received. exiting telemetry task.'); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return counter; + } - // Build process tree for each EP Alert recieved + // Build process tree for each EP Alert recieved - for (const alert of endpointAlerts.hits.hits) { - const eventId = alert._source ? alert._source['event.id'] : 'unknown'; - const alertUUID = alert._source ? alert._source['kibana.alert.uuid'] : 'unknown'; + for (const alert of endpointAlerts.hits.hits) { + const eventId = alert._source ? alert._source['event.id'] : 'unknown'; + const alertUUID = alert._source ? alert._source['kibana.alert.uuid'] : 'unknown'; - const entities = resolverEntity([alert]); + const entities = resolverEntity([alert]); - // Build Tree + // Build Tree - const tree = await receiver.buildProcessTree( - entities[0].id, - entities[0].schema, - startOfDay, - endOfDay - ); + const tree = await receiver.buildProcessTree( + entities[0].id, + entities[0].schema, + startOfDay, + endOfDay + ); - const nodeIds = [] as string[]; - if (Array.isArray(tree)) { - for (const node of tree) { - const nodeId = node?.id.toString(); - nodeIds.push(nodeId); + const nodeIds = [] as string[]; + if (Array.isArray(tree)) { + for (const node of tree) { + const nodeId = node?.id.toString(); + nodeIds.push(nodeId); + } } - } - sender.getTelemetryUsageCluster()?.incrementCounter({ - counterName: 'telemetry_timeline', - counterType: 'timeline_node_count', - incrementBy: nodeIds.length, - }); + sender.getTelemetryUsageCluster()?.incrementCounter({ + counterName: 'telemetry_timeline', + counterType: 'timeline_node_count', + incrementBy: nodeIds.length, + }); - // Fetch event lineage + // Fetch event lineage - const timelineEvents = await receiver.fetchTimelineEvents(nodeIds); - tlog(logger, `Timeline Events: ${JSON.stringify(timelineEvents)}`); - const eventsStore = new Map(); - for (const event of timelineEvents.hits.hits) { - const doc = event._source; + const timelineEvents = await receiver.fetchTimelineEvents(nodeIds); + tlog(logger, `Timeline Events: ${JSON.stringify(timelineEvents)}`); + const eventsStore = new Map(); + for (const event of timelineEvents.hits.hits) { + const doc = event._source; - if (doc !== null && doc !== undefined) { - const entityId = doc?.process?.entity_id?.toString(); - if (entityId !== null && entityId !== undefined) eventsStore.set(entityId, doc); + if (doc !== null && doc !== undefined) { + const entityId = doc?.process?.entity_id?.toString(); + if (entityId !== null && entityId !== undefined) eventsStore.set(entityId, doc); + } } - } - sender.getTelemetryUsageCluster()?.incrementCounter({ - counterName: 'telemetry_timeline', - counterType: 'timeline_event_count', - incrementBy: eventsStore.size, - }); + sender.getTelemetryUsageCluster()?.incrementCounter({ + counterName: 'telemetry_timeline', + counterType: 'timeline_event_count', + incrementBy: eventsStore.size, + }); - // Create telemetry record + // Create telemetry record - const telemetryTimeline: TimelineTelemetryEvent[] = []; - if (Array.isArray(tree)) { - for (const node of tree) { - const id = node.id.toString(); - const event = eventsStore.get(id); + const telemetryTimeline: TimelineTelemetryEvent[] = []; + if (Array.isArray(tree)) { + for (const node of tree) { + const id = node.id.toString(); + const event = eventsStore.get(id); - const timelineTelemetryEvent: TimelineTelemetryEvent = { - ...node, - event, - }; + const timelineTelemetryEvent: TimelineTelemetryEvent = { + ...node, + event, + }; - telemetryTimeline.push(timelineTelemetryEvent); + telemetryTimeline.push(timelineTelemetryEvent); + } } - } - if (telemetryTimeline.length >= 1) { - const record: TimelineTelemetryTemplate = { - '@timestamp': moment().toISOString(), - ...baseDocument, - alert_id: alertUUID, - event_id: eventId, - timeline: telemetryTimeline, - }; - - sender.sendOnDemand(TELEMETRY_CHANNEL_TIMELINE, [record]); - counter += 1; - } else { - tlog(logger, 'no events in timeline'); + if (telemetryTimeline.length >= 1) { + const record: TimelineTelemetryTemplate = { + '@timestamp': moment().toISOString(), + ...baseDocument, + alert_id: alertUUID, + event_id: eventId, + timeline: telemetryTimeline, + }; + + sender.sendOnDemand(TELEMETRY_CHANNEL_TIMELINE, [record]); + counter += 1; + } else { + tlog(logger, 'no events in timeline'); + } } + tlog(logger, `sent ${counter} timelines. concluding timeline task.`); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); + return counter; + } catch (err) { + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); + return 0; } - - tlog(logger, `sent ${counter} timelines. concluding timeline task.`); - return counter; }, }; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 82b4fde4b5992..3e0d9c1b4e1d2 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -412,3 +412,12 @@ export interface ValueListIndicatorMatchResponseAggregation { vl_used_in_indicator_match_rule_count: { value: number }; }; } + +export interface TaskMetric { + name: string; + passed: boolean; + time_executed_in_ms: number; + start_time: number; + end_time: number; + error_message?: string; +} diff --git a/x-pack/plugins/synthetics/common/constants/rest_api.ts b/x-pack/plugins/synthetics/common/constants/rest_api.ts index 85f345c0972fb..d0d783e424f3a 100644 --- a/x-pack/plugins/synthetics/common/constants/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/rest_api.ts @@ -18,7 +18,7 @@ export enum API_URLS { PING_HISTOGRAM = `/internal/uptime/ping/histogram`, SNAPSHOT_COUNT = `/internal/uptime/snapshot/count`, SYNTHETICS_SUCCESSFUL_CHECK = `/internal/uptime/synthetics/check/success`, - JOURNEY_CREATE = `/internal/uptime/journey/{checkGroup}`, + JOURNEY = `/internal/uptime/journey/{checkGroup}`, JOURNEY_FAILED_STEPS = `/internal/uptime/journeys/failed_steps`, JOURNEY_SCREENSHOT = `/internal/uptime/journey/screenshot/{checkGroup}/{stepIndex}`, JOURNEY_SCREENSHOT_BLOCKS = `/internal/uptime/journey/screenshot/block`, diff --git a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts index ed8ff0bc50e0d..7b9a1fe380a69 100644 --- a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -6,6 +6,6 @@ */ export enum SYNTHETICS_API_URLS { - MONITOR_STATUS = `/internal/synthetics/monitor/status`, SYNTHETICS_OVERVIEW = '/internal/synthetics/overview', + PINGS = '/internal/synthetics/pings', } diff --git a/x-pack/plugins/synthetics/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/synthetics/common/runtime_types/ping/synthetics.ts index c95f9c281dc92..6f2264d66f9b1 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/ping/synthetics.ts @@ -198,10 +198,23 @@ export const ScreenshotBlockDocType = t.type({ export type ScreenshotBlockDoc = t.TypeOf; +export interface PendingBlock { + status: 'pending' | 'loading'; +} + +export type StoreScreenshotBlock = ScreenshotBlockDoc | PendingBlock; +export interface ScreenshotBlockCache { + [hash: string]: StoreScreenshotBlock; +} + export function isScreenshotBlockDoc(data: unknown): data is ScreenshotBlockDoc { return isRight(ScreenshotBlockDocType.decode(data)); } +export function isPendingBlock(data: unknown): data is PendingBlock { + return ['pending', 'loading'].some((s) => s === (data as PendingBlock)?.status); +} + /** * Contains the fields requried by the Synthetics UI when utilizing screenshot refs. */ diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/browser_steps_list.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/browser_steps_list.tsx new file mode 100644 index 0000000000000..81ecb7dc60027 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/browser_steps_list.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { CSSProperties } from 'react'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButtonIcon, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; + +import { useSyntheticsSettingsContext } from '../../../contexts/synthetics_settings_context'; +import { JourneyStep } from '../../../../../../common/runtime_types'; + +import { StatusBadge, parseBadgeStatus, getTextColorForMonitorStatus } from './status_badge'; +import { JourneyStepScreenshotWithLabel } from './journey_step_screenshot_with_label'; +import { StepDurationText } from './step_duration_text'; + +interface Props { + steps: JourneyStep[]; + error?: Error; + loading: boolean; + showStepNumber: boolean; +} + +export function isStepEnd(step: JourneyStep) { + return step.synthetics?.type === 'step/end'; +} + +export const BrowserStepsList = ({ steps, error, loading, showStepNumber = false }: Props) => { + const { euiTheme } = useEuiTheme(); + const stepEnds: JourneyStep[] = steps.filter(isStepEnd); + const stepLabels = stepEnds.map((stepEnd) => stepEnd?.synthetics?.step?.name ?? ''); + + const { basePath } = useSyntheticsSettingsContext(); + + const columns: Array> = [ + ...(showStepNumber + ? [ + { + field: 'synthetics.step.index', + name: '#', + render: (stepIndex: number, item: JourneyStep) => ( + + ), + }, + ] + : []), + { + align: 'left', + field: 'timestamp', + name: STEP_LABEL, + render: (_timestamp: string, item) => ( + + ), + mobileOptions: { + render: (item: JourneyStep) => ( + + + {item.synthetics?.step?.index!}. {item.synthetics?.step?.name} + + + ), + header: STEP_LABEL, + enlarge: true, + }, + }, + { + field: 'synthetics.step.status', + name: RESULT_LABEL, + render: (pingStatus: string) => , + }, + { + align: 'left', + name: STEP_DURATION, + render: (item: JourneyStep) => { + return ; + }, + mobileOptions: { + header: STEP_DURATION, + show: true, + }, + }, + { + align: 'right', + field: 'timestamp', + name: '', + mobileOptions: { show: false }, + render: (_val: string, item) => ( + + ), + }, + ]; + + return ( + <> + + + ); +}; + +const StepNumber = ({ + stepIndex, + step, + euiTheme, +}: { + stepIndex: number; + step: JourneyStep; + euiTheme: EuiThemeComputed; +}) => { + const status = parseBadgeStatus(step.synthetics?.step?.status ?? ''); + + return ( + + {stepIndex} + + ); +}; + +const RESULT_LABEL = i18n.translate('xpack.synthetics.monitor.result.label', { + defaultMessage: 'Result', +}); + +const STEP_LABEL = i18n.translate('xpack.synthetics.monitor.step.label', { + defaultMessage: 'Step', +}); + +const STEP_DURATION = i18n.translate('xpack.synthetics.monitor.step.duration.label', { + defaultMessage: 'Duration', +}); + +const VIEW_DETAILS = i18n.translate('xpack.synthetics.monitor.step.viewDetails', { + defaultMessage: 'View Details', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.test.tsx new file mode 100644 index 0000000000000..d877820ca24fd --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '../../../utils/testing/rtl_helpers'; +import { + EmptyThumbnail, + SCREENSHOT_LOADING_ARIA_LABEL, + SCREENSHOT_NOT_AVAILABLE, +} from './empty_thumbnail'; + +describe('EmptyThumbnail', () => { + it('renders a loading placeholder for loading state', () => { + const { getByLabelText, getByTestId } = render(); + + expect(getByTestId('stepScreenshotPlaceholderLoading')).toBeInTheDocument(); + expect(getByLabelText(SCREENSHOT_LOADING_ARIA_LABEL)); + }); + + it('renders no image available when not loading', () => { + const { queryByTestId, getByLabelText } = render(); + + expect(queryByTestId('stepScreenshotPlaceholderLoading')).not.toBeInTheDocument(); + expect(getByLabelText(SCREENSHOT_NOT_AVAILABLE)); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.tsx new file mode 100644 index 0000000000000..bfc5851ade619 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/empty_thumbnail.tsx @@ -0,0 +1,85 @@ +/* + * 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 { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { useEuiTheme, useEuiBackgroundColor, EuiIcon, EuiLoadingContent } from '@elastic/eui'; + +export const THUMBNAIL_WIDTH = 96; +export const THUMBNAIL_HEIGHT = 64; + +export const thumbnailStyle = css` + padding: 0; + margin: 0; + width: ${THUMBNAIL_WIDTH}px; + height: ${THUMBNAIL_HEIGHT}px; + object-fit: contain; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +`; + +export const EmptyThumbnail = ({ + isLoading = false, + width = THUMBNAIL_WIDTH, + height = THUMBNAIL_HEIGHT, +}: { + isLoading: boolean; + width?: number; + height?: number; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ ); +}; + +export const SCREENSHOT_LOADING_ARIA_LABEL = i18n.translate( + 'xpack.synthetics.monitor.step.screenshot.ariaLabel', + { + defaultMessage: 'Step screenshot is being loaded.', + } +); + +export const SCREENSHOT_NOT_AVAILABLE = i18n.translate( + 'xpack.synthetics.monitor.step.screenshot.notAvailable', + { + defaultMessage: 'Step screenshot is not available.', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.test.tsx new file mode 100644 index 0000000000000..c738f07218842 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.test.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 { screen } from '@elastic/eui/lib/test/rtl'; +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { JourneyStepImagePopover, StepImagePopoverProps } from './journey_step_image_popover'; +import { render } from '../../../utils/testing'; + +describe('JourneyStepImagePopover', () => { + let defaultProps: StepImagePopoverProps; + + beforeEach(() => { + defaultProps = { + captionContent: 'test caption', + imageCaption:
test caption element
, + imgSrc: 'http://sample.com/sampleImageSrc.png', + isImagePopoverOpen: false, + isStepFailed: false, + isLoading: false, + }; + }); + + it('opens displays full-size image on click, hides after close is clicked', async () => { + const { getByAltText } = render(); + + expect(screen.queryByTestSubject('deactivateFullScreenButton')).toBeNull(); + + const caption = getByAltText('test caption'); + fireEvent.click(caption); + + await waitFor(() => { + fireEvent.click(screen.getByTestSubject('deactivateFullScreenButton')); + }); + + await waitFor(() => { + expect(screen.queryByTestSubject('deactivateFullScreenButton')).toBeNull(); + }); + }); + + it('shows the popover when `isOpen` is true', () => { + defaultProps.isImagePopoverOpen = true; + + const { getByAltText } = render(); + + expect(getByAltText(`A larger version of the screenshot for this journey step's thumbnail.`)); + }); + + it('renders caption content', () => { + const { getByRole } = render(); + const image = getByRole('img'); + expect(image).toHaveAttribute('alt', 'test caption'); + expect(image).toHaveAttribute('src', 'http://sample.com/sampleImageSrc.png'); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx new file mode 100644 index 0000000000000..48ff7237223fb --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx @@ -0,0 +1,211 @@ +/* + * 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 { css } from '@emotion/react'; +import { EuiImage, EuiPopover, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ScreenshotRefImageData } from '../../../../../../common/runtime_types'; +import { useCompositeImage } from '../../../hooks/use_composite_image'; + +import { EmptyThumbnail, thumbnailStyle } from './empty_thumbnail'; + +const POPOVER_IMG_HEIGHT = 360; +const POPOVER_IMG_WIDTH = 640; + +interface ScreenshotImageProps { + captionContent: string; + imageCaption: JSX.Element; + isStepFailed: boolean; + isLoading: boolean; +} + +const ScreenshotThumbnail: React.FC = ({ + captionContent, + imageCaption, + imageData, + isStepFailed, + isLoading, +}) => { + return imageData ? ( + + ) : ( + + ); +}; +/** + * This component provides an intermediate step for composite images. It causes a loading spinner to appear + * while the image is being re-assembled, then calls the default image component and provides a data URL for the image. + */ +const RecomposedScreenshotImage: React.FC< + ScreenshotImageProps & { + imgRef: ScreenshotRefImageData; + setImageData: React.Dispatch; + imageData: string | undefined; + } +> = ({ + captionContent, + imageCaption, + imageData, + imgRef, + setImageData, + isStepFailed, + isLoading, +}) => { + // initially an undefined URL value is passed to the image display, and a loading spinner is rendered. + // `useCompositeImage` will call `setImageData` when the image is composited, and the updated `imageData` will display. + useCompositeImage(imgRef, setImageData, imageData); + + return ( + + ); +}; + +export interface StepImagePopoverProps { + captionContent: string; + imageCaption: JSX.Element; + imgSrc?: string; + imgRef?: ScreenshotRefImageData; + isImagePopoverOpen: boolean; + isStepFailed: boolean; + isLoading: boolean; +} + +const JourneyStepImage: React.FC< + Omit & { + setImageData: React.Dispatch; + imageData: string | undefined; + } +> = ({ + captionContent, + imageCaption, + imageData, + imgRef, + imgSrc, + setImageData, + isStepFailed, + isLoading, +}) => { + if (imgSrc) { + return ( + + ); + } else if (imgRef) { + return ( + + ); + } + return null; +}; + +export const JourneyStepImagePopover: React.FC = ({ + captionContent, + imageCaption, + imgRef, + imgSrc, + isImagePopoverOpen, + isStepFailed, + isLoading, +}) => { + const { euiTheme } = useEuiTheme(); + + const [imageData, setImageData] = React.useState(imgSrc || undefined); + + React.useEffect(() => { + // for legacy screenshots, when a new image arrives, we must overwrite it + if (imgSrc && imgSrc !== imageData) { + setImageData(imgSrc); + } + }, [imgSrc, imageData]); + + const setImageDataCallback = React.useCallback( + (newImageData: string | undefined) => setImageData(newImageData), + [setImageData] + ); + + const isImageLoading = isLoading || (!!imgRef && !imageData); + + return ( + + } + isOpen={isImagePopoverOpen} + closePopover={() => {}} + > + {imageData && !isLoading ? ( + + ) : ( + + )} + + ); +}; + +export const fullSizeImageAlt = i18n.translate('xpack.synthetics.monitor.step.thumbnail.alt', { + defaultMessage: `A larger version of the screenshot for this journey step's thumbnail.`, +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.test.tsx new file mode 100644 index 0000000000000..4c95fade23d1a --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { fireEvent, waitFor } from '@testing-library/react'; +import moment from 'moment'; +import { JourneyStepScreenshotContainer } from './journey_step_screenshot_container'; +import { render } from '../../../utils/testing'; +import * as observabilityPublic from '@kbn/observability-plugin/public'; +import { getShortTimeStamp } from '../../../utils/monitor_test_result/timestamp'; +import '../../../utils/testing/__mocks__/use_composite_image.mock'; +import { mockRef } from '../../../utils/testing/__mocks__/screenshot_ref.mock'; +import * as retrieveHooks from './use_retrieve_step_image'; + +jest.mock('@kbn/observability-plugin/public'); + +describe('JourneyStepScreenshotContainer', () => { + let checkGroup: string; + let timestamp: string; + const { FETCH_STATUS } = observabilityPublic; + + beforeAll(() => { + checkGroup = 'f58a484f-2ffb-11eb-9b35-025000000001'; + timestamp = '2020-11-26T15:28:56.896Z'; + }); + + it.each([[FETCH_STATUS.PENDING], [FETCH_STATUS.LOADING]])( + 'displays spinner when loading step image', + (fetchStatus) => { + jest + .spyOn(observabilityPublic, 'useFetcher') + .mockReturnValue({ status: fetchStatus, data: null, refetch: () => null, loading: true }); + const { getByTestId } = render( + + ); + expect(getByTestId('stepScreenshotPlaceholderLoading')).toBeInTheDocument(); + } + ); + + it('displays no image available when img src is unavailable and fetch status is successful', () => { + jest + .spyOn(observabilityPublic, 'useFetcher') + .mockReturnValue({ status: FETCH_STATUS.SUCCESS, data: null, refetch: () => null }); + const { getByTestId } = render( + + ); + expect(getByTestId('stepScreenshotNotAvailable')).toBeInTheDocument(); + }); + + it('displays image when img src is available from useFetcher', () => { + const src = 'http://sample.com/sampleImageSrc.png'; + jest.spyOn(retrieveHooks, 'useRetrieveStepImage').mockReturnValue({ + loading: false, + data: { maxSteps: 2, stepName: 'test', src }, + attempts: 1, + }); + + const { container } = render( + + ); + expect(container.querySelector('img')?.src).toBe(src); + }); + + it('displays popover image when mouse enters img caption, and hides onLeave', async () => { + const src = 'http://sample.com/sampleImageSrc.png'; + jest.spyOn(retrieveHooks, 'useRetrieveStepImage').mockReturnValue({ + loading: false, + data: { maxSteps: 1, stepName: null, src }, + attempts: 1, + }); + const { getByAltText, getAllByText, queryByAltText } = render( + + ); + + const caption = getAllByText('Nov 26, 2020 10:28:56 AM'); + fireEvent.mouseEnter(caption[0]); + + const altText = `A larger version of the screenshot for this journey step's thumbnail.`; + + await waitFor(() => getByAltText(altText)); + + fireEvent.mouseLeave(caption[0]); + + await waitFor(() => expect(queryByAltText(altText)).toBeNull()); + }); + + it('handles screenshot ref data', async () => { + jest.spyOn(retrieveHooks, 'useRetrieveStepImage').mockReturnValue({ + loading: false, + data: mockRef, + attempts: 1, + }); + + const { getByAltText, getByText, getByRole, getAllByText, queryByAltText } = render( + + ); + + await waitFor(() => getByRole('img')); + const caption = getAllByText('Nov 26, 2020 10:28:56 AM'); + fireEvent.mouseEnter(caption[0]); + + const altText = `A larger version of the screenshot for this journey step's thumbnail.`; + + await waitFor(() => getByAltText(altText)); + + fireEvent.mouseLeave(caption[0]); + + await waitFor(() => expect(queryByAltText(altText)).toBeNull()); + expect(getByText('Step: 1 of 1')); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.tsx new file mode 100644 index 0000000000000..6c2653f7efaa6 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_container.tsx @@ -0,0 +1,153 @@ +/* + * 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, { useContext, useEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import useIntersection from 'react-use/lib/useIntersection'; +import { i18n } from '@kbn/i18n'; + +import { + isScreenshotImageBlob, + isScreenshotRef, + ScreenshotRefImageData, +} from '../../../../../../common/runtime_types'; + +import { SyntheticsSettingsContext } from '../../../contexts'; + +import { useRetrieveStepImage } from './use_retrieve_step_image'; +import { ScreenshotOverlayFooter } from './screenshot_overlay_footer'; +import { JourneyStepImagePopover } from './journey_step_image_popover'; +import { EmptyThumbnail } from './empty_thumbnail'; + +interface Props { + checkGroup?: string; + stepLabels?: string[]; + stepStatus?: string; + initialStepNo?: number; + allStepsLoaded?: boolean; + retryFetchOnRevisit?: boolean; // Set to `true` fro "Run Once" / "Test Now" modes +} + +export const JourneyStepScreenshotContainer = ({ + stepLabels = [], + checkGroup, + stepStatus, + allStepsLoaded, + initialStepNo = 1, + retryFetchOnRevisit = false, +}: Props) => { + const [stepNumber, setStepNumber] = useState(initialStepNo); + const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); + + const [stepImages, setStepImages] = useState([]); + + const intersectionRef = React.useRef(null); + + const { basePath } = useContext(SyntheticsSettingsContext); + + const imgPath = `${basePath}/internal/uptime/journey/screenshot/${checkGroup}/${stepNumber}`; + const stepLabel = stepLabels[stepNumber - 1] ?? ''; + + const intersection = useIntersection(intersectionRef, { + root: null, + rootMargin: '0px', + threshold: 1, + }); + + const [screenshotRef, setScreenshotRef] = useState(undefined); + + const isScreenshotRefValid = Boolean( + screenshotRef && screenshotRef?.ref?.screenshotRef?.synthetics?.step?.index === stepNumber + ); + const { data, loading } = useRetrieveStepImage({ + hasImage: Boolean(stepImages[stepNumber - 1]) || isScreenshotRefValid, + hasIntersected: Boolean(intersection && intersection.intersectionRatio === 1), + stepStatus, + imgPath, + retryFetchOnRevisit, + }); + + useEffect(() => { + if (isScreenshotRef(data)) { + setScreenshotRef(data); + } else if (isScreenshotImageBlob(data)) { + setStepImages((prevState) => [...prevState, data?.src]); + } + }, [data]); + + let imgSrc; + if (isScreenshotImageBlob(data)) { + imgSrc = stepImages?.[stepNumber - 1] ?? data.src; + } + + const captionContent = formatCaptionContent(stepNumber, data?.maxSteps); + + const [numberOfCaptions, setNumberOfCaptions] = useState(0); + + // Overlay Footer has next and previous buttons to traverse journey's steps + const overlayFooter = ( + setNumberOfCaptions((prevVal) => (val ? prevVal + 1 : prevVal - 1))} + /> + ); + + useEffect(() => { + // This is a hack to get state if image is in full screen, we should refactor + // it once eui image exposes it's full screen state + // we are checking if number of captions are 2, that means + // image is in full screen mode since caption is also rendered on + // full screen image + // we dont want to change image displayed in thumbnail + if (numberOfCaptions === 1 && stepNumber !== initialStepNo) { + setStepNumber(initialStepNo); + } + }, [numberOfCaptions, initialStepNo, stepNumber]); + + return ( +
setIsImagePopoverOpen(true)} + onMouseLeave={() => setIsImagePopoverOpen(false)} + ref={intersectionRef} + > + {imgSrc || screenshotRef ? ( + + ) : ( + + )} +
+ ); +}; + +export const formatCaptionContent = (stepNumber: number, totalSteps?: number) => + i18n.translate('xpack.synthetics.monitor.stepOfSteps', { + defaultMessage: 'Step: {stepNumber} of {totalSteps}', + values: { + stepNumber, + totalSteps, + }, + }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_with_label.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_with_label.tsx new file mode 100644 index 0000000000000..12dcd4db95f00 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_screenshot_with_label.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { CSSProperties } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import { JourneyStep } from '../../../../../../common/runtime_types'; +import { JourneyStepScreenshotContainer } from './journey_step_screenshot_container'; +import { getTextColorForMonitorStatus, parseBadgeStatus } from './status_badge'; + +interface Props { + step: JourneyStep; + stepLabels?: string[]; + allStepsLoaded?: boolean; + compactView?: boolean; +} + +export const JourneyStepScreenshotWithLabel = ({ + step, + stepLabels = [], + compactView, + allStepsLoaded, +}: Props) => { + const { euiTheme } = useEuiTheme(); + const status = parseBadgeStatus(step.synthetics.step?.status ?? ''); + const textColor = euiTheme.colors[getTextColorForMonitorStatus(status)] as CSSProperties['color']; + + return ( + + + + + + + {step.synthetics?.step?.name} + + + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.test.tsx new file mode 100644 index 0000000000000..f38084971114b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { render } from '../../../utils/testing'; +import { ScreenshotOverlayFooter, ScreenshotOverlayFooterProps } from './screenshot_overlay_footer'; +import { getShortTimeStamp } from '../../../utils/monitor_test_result/timestamp'; +import moment from 'moment'; +import { mockRef } from '../../../utils/testing/__mocks__/screenshot_ref.mock'; + +describe('ScreenshotOverlayFooter', () => { + let defaultProps: ScreenshotOverlayFooterProps; + + beforeEach(() => { + defaultProps = { + captionContent: 'test caption content', + imgSrc: 'http://sample.com/sampleImageSrc.png', + maxSteps: 3, + setStepNumber: jest.fn(), + stepNumber: 2, + label: getShortTimeStamp(moment('2020-11-26T15:28:56.896Z')), + onVisible: jest.fn(), + isLoading: false, + }; + }); + + it('labels prev and next buttons', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('Previous step')); + expect(getByLabelText('Next step')); + }); + + it('increments step number on next click', async () => { + const { getByLabelText } = render(); + + const nextButton = getByLabelText('Next step'); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(defaultProps.setStepNumber).toHaveBeenCalledTimes(1); + expect(defaultProps.setStepNumber).toHaveBeenCalledWith(3); + }); + }); + + it('decrements step number on prev click', async () => { + const { getByLabelText } = render(); + + const nextButton = getByLabelText('Previous step'); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(defaultProps.setStepNumber).toHaveBeenCalledTimes(1); + expect(defaultProps.setStepNumber).toHaveBeenCalledWith(1); + }); + }); + + it('disables `next` button on final step', () => { + defaultProps.stepNumber = 3; + + const { getByLabelText } = render(); + + // getByLabelText('Next step'); + expect(getByLabelText('Next step')).toHaveAttribute('disabled'); + expect(getByLabelText('Previous step')).not.toHaveAttribute('disabled'); + }); + + it('disables `prev` button on final step', () => { + defaultProps.stepNumber = 1; + + const { getByLabelText } = render(); + + expect(getByLabelText('Next step')).not.toHaveAttribute('disabled'); + expect(getByLabelText('Previous step')).toHaveAttribute('disabled'); + }); + + it('renders a timestamp', () => { + const { getByText } = render(); + + getByText('Nov 26, 2020 10:28:56 AM'); + }); + + it('renders caption content', () => { + const { getByText } = render(); + + getByText('test caption content'); + }); + + it('renders caption content for screenshot ref data', async () => { + const { getByText } = render( + + ); + + getByText('test caption content'); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.tsx new file mode 100644 index 0000000000000..3a01a74721d40 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/screenshot_overlay_footer.tsx @@ -0,0 +1,132 @@ +/* + * 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, { MouseEvent, useEffect } from 'react'; +import { css } from '@emotion/react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, + useIsWithinMaxBreakpoint, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ScreenshotRefImageData } from '../../../../../../common/runtime_types'; + +export interface ScreenshotOverlayFooterProps { + captionContent: string; + imgSrc?: string; + imgRef?: ScreenshotRefImageData; + maxSteps?: number; + setStepNumber: React.Dispatch>; + stepNumber: number; + label?: string; + onVisible: (val: boolean) => void; + isLoading: boolean; +} + +export const ScreenshotOverlayFooter: React.FC = ({ + captionContent, + imgRef, + imgSrc, + maxSteps, + setStepNumber, + stepNumber, + isLoading, + label, + onVisible, +}) => { + const { euiTheme } = useEuiTheme(); + + useEffect(() => { + onVisible(true); + return () => { + onVisible(false); + }; + // Empty deps to only trigger effect once on init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isSmall = useIsWithinMaxBreakpoint('m'); + + return ( +
{ + // we don't want this to be captured by row click which leads to step list page + evt.stopPropagation(); + }} + onKeyDown={(evt) => { + // Just to satisfy ESLint + }} + > +
+ {(imgSrc || imgRef) && ( + + + ) => { + setStepNumber(stepNumber - 1); + evt.preventDefault(); + }} + iconType="arrowLeft" + aria-label={prevAriaLabel} + isLoading={isLoading} + > + {prevAriaLabel} + + + + {captionContent} + + + ) => { + setStepNumber(stepNumber + 1); + evt.stopPropagation(); + }} + iconType="arrowRight" + iconSide="right" + aria-label={nextAriaLabel} + isLoading={isLoading} + > + {nextAriaLabel} + + + + )} + + {label} + +
+
+ ); +}; + +export const prevAriaLabel = i18n.translate('xpack.synthetics.monitor.step.previousStep', { + defaultMessage: 'Previous step', +}); + +export const nextAriaLabel = i18n.translate('xpack.synthetics.monitor.step.nextStep', { + defaultMessage: 'Next step', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx new file mode 100644 index 0000000000000..c1da7e0035097 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Ping } from '../../../../../../common/runtime_types'; +import { formatTestDuration } from '../../../utils/monitor_test_result/test_time_formats'; + +export const SinglePingResult = ({ ping, loading }: { ping: Ping; loading: boolean }) => { + const ip = !loading ? ping?.resolve?.ip : undefined; + const durationUs = !loading ? ping?.monitor?.duration?.us : undefined; + const rtt = !loading ? ping?.resolve?.rtt?.us : undefined; + const url = !loading ? ping?.url?.full : undefined; + const responseStatus = !loading ? ping?.http?.response?.status_code : undefined; + + return ( + + IP + {ip} + {DURATION_LABEL} + + {formatTestDuration(durationUs)} + + rtt + {formatTestDuration(rtt)} + URL + {url} + + {responseStatus ? ( + <> + Response status + + {responseStatus} + + + ) : null} + + ); +}; + +const DURATION_LABEL = i18n.translate('xpack.synthetics.monitor.duration.label', { + defaultMessage: 'Duration', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/status_badge.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/status_badge.tsx new file mode 100644 index 0000000000000..a18c0d7a2ca7e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/status_badge.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge, IconColor, EuiThemeComputed } from '@elastic/eui'; + +type MonitorStatus = 'succeeded' | 'failed' | 'skipped'; +export const StatusBadge = ({ status }: { status: MonitorStatus }) => { + return ( + + {status === 'succeeded' ? COMPLETE_LABEL : status === 'failed' ? FAILED_LABEL : SKIPPED_LABEL} + + ); +}; + +export const parseBadgeStatus = (status: string) => { + switch (status) { + case 'succeeded': + case 'success': + case 'up': + return 'succeeded'; + case 'fail': + case 'failed': + case 'down': + return 'failed'; + case 'skip': + case 'skipped': + return 'skipped'; + default: + return 'skipped'; + } +}; + +export const getBadgeColorForMonitorStatus = (status: MonitorStatus): IconColor => { + return status === 'succeeded' ? 'success' : status === 'failed' ? 'danger' : 'default'; +}; + +export const getTextColorForMonitorStatus = ( + status: MonitorStatus +): keyof EuiThemeComputed['colors'] => { + return status === 'skipped' ? 'disabledText' : 'text'; +}; + +export const COMPLETE_LABEL = i18n.translate('xpack.synthetics.monitorStatus.complete', { + defaultMessage: 'Complete', +}); + +export const FAILED_LABEL = i18n.translate('xpack.synthetics.monitorStatus.failed', { + defaultMessage: 'Failed', +}); + +export const SKIPPED_LABEL = i18n.translate('xpack.synthetics.monitorStatus.skipped', { + defaultMessage: 'Skipped', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/step_duration_text.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/step_duration_text.tsx new file mode 100644 index 0000000000000..68f5d919f90e3 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/step_duration_text.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { CSSProperties, useMemo } from 'react'; +import { EuiText, useEuiTheme } from '@elastic/eui'; +import { JourneyStep } from '../../../../../../common/runtime_types'; +import { formatTestDuration } from '../../../utils/monitor_test_result/test_time_formats'; + +import { parseBadgeStatus, getTextColorForMonitorStatus } from './status_badge'; + +export const StepDurationText = ({ step }: { step: JourneyStep }) => { + const { euiTheme } = useEuiTheme(); + + const stepDuration = useMemo(() => { + const status = parseBadgeStatus(step.synthetics.step?.status ?? ''); + const color = euiTheme.colors[getTextColorForMonitorStatus(status)] as CSSProperties['color']; + if (status === 'skipped') { + return { text: '-', color }; + } + + return { + text: formatTestDuration(step.synthetics.step?.duration?.us), + color, + }; + }, [euiTheme.colors, step.synthetics.step?.duration?.us, step.synthetics.step?.status]); + + return ( + + {stepDuration.text} + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/use_retrieve_step_image.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/use_retrieve_step_image.ts new file mode 100644 index 0000000000000..f840658a3dc48 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/use_retrieve_step_image.ts @@ -0,0 +1,87 @@ +/* + * 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 { useFetcher } from '@kbn/observability-plugin/public'; +import { + ScreenshotImageBlob, + ScreenshotRefImageData, +} from '../../../../../../common/runtime_types'; +import { getJourneyScreenshot } from '../../../state'; + +type ImageResponse = ScreenshotImageBlob | ScreenshotRefImageData | null; +interface DataResult { + [imgPath: string]: { attempts: number; data: ImageResponse; loading: boolean }; +} + +function getUpdatedState( + prevState: DataResult, + imgPath: string, + increment: boolean, + data?: ImageResponse, + loading?: boolean +) { + const newAttempts = (prevState[imgPath]?.attempts ?? 0) + (increment ? 1 : 0); + const newData = data ?? prevState[imgPath]?.data ?? null; + const newLoading = loading ?? prevState[imgPath]?.loading ?? false; + return { + ...prevState, + [imgPath]: { attempts: newAttempts, data: newData, loading: newLoading }, + }; +} + +export const useRetrieveStepImage = ({ + stepStatus, + hasImage, + hasIntersected, + imgPath, + retryFetchOnRevisit, +}: { + imgPath: string; + stepStatus?: string; + hasImage: boolean; + hasIntersected: boolean; + + /** + * Whether to retry screenshot image fetch on revisit (when intersection change triggers). + * Will only re-fetch if an image fetch wasn't successful in previous attempts. + * Set this to `true` fro "Run Once" / "Test Now" modes + */ + retryFetchOnRevisit: boolean; +}) => { + const [imgState, setImgState] = useState({}); + + const skippedStep = stepStatus === 'skipped'; + + useFetcher(() => { + const hasBeenRetriedBefore = (imgState[imgPath]?.attempts ?? 0) > 0; + const shouldRetry = retryFetchOnRevisit || !hasBeenRetriedBefore; + + if (!skippedStep && hasIntersected && !hasImage && shouldRetry) { + setImgState((prev) => { + return getUpdatedState(prev, imgPath, false, undefined, true); + }); + return getJourneyScreenshot(imgPath) + .then((resp) => { + setImgState((prev) => { + return getUpdatedState(prev, imgPath, true, resp, false); + }); + + return resp; + }) + .catch(() => { + setImgState((prev) => { + return getUpdatedState(prev, imgPath, true, null, false); + }); + }); + } else { + return new Promise((resolve) => resolve(null)); + } + }, [skippedStep, hasIntersected, imgPath, retryFetchOnRevisit]); + + return imgState[imgPath] ?? { data: null, loading: false }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_journey_steps.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_journey_steps.tsx new file mode 100644 index 0000000000000..a6d2070d0e96a --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_journey_steps.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useFetcher } from '@kbn/observability-plugin/public'; +import { SyntheticsJourneyApiResponse } from '../../../../../../common/runtime_types'; +import { fetchJourneySteps } from '../../../state'; + +export const useJourneySteps = (checkGroup: string | undefined) => { + const { data, loading } = useFetcher(() => { + if (!checkGroup) { + return Promise.resolve(null); + } + + return fetchJourneySteps({ checkGroup }); + }, [checkGroup]); + + return { data: data as SyntheticsJourneyApiResponse, loading: loading ?? false }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/hooks/use_selected_location.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx similarity index 79% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/hooks/use_selected_location.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx index 895168657ba1b..779b8d8001ad5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/hooks/use_selected_location.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx @@ -7,13 +7,13 @@ import { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { selectSelectedLocationId, setMonitorSummaryLocationAction } from '../../../state'; +import { selectSelectedLocationId, setMonitorDetailsLocationAction } from '../../../state'; import { useUrlParams, useLocations } from '../../../hooks'; export const useSelectedLocation = () => { const [getUrlParams, updateUrlParams] = useUrlParams(); const { locations } = useLocations(); - const selectedLocation = useSelector(selectSelectedLocationId); + const selectedLocationId = useSelector(selectSelectedLocationId); const dispatch = useDispatch(); const { locationId: urlLocationId } = getUrlParams(); @@ -26,10 +26,10 @@ export const useSelectedLocation = () => { } } - if (urlLocationId && selectedLocation !== urlLocationId) { - dispatch(setMonitorSummaryLocationAction(urlLocationId)); + if (urlLocationId && selectedLocationId !== urlLocationId) { + dispatch(setMonitorDetailsLocationAction(urlLocationId)); } - }, [dispatch, updateUrlParams, locations, urlLocationId, selectedLocation]); + }, [dispatch, updateUrlParams, locations, urlLocationId, selectedLocationId]); return useMemo( () => locations.find((loc) => loc.id === urlLocationId) ?? null, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx new file mode 100644 index 0000000000000..6e4d972fb445b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { + getMonitorAction, + selectEncryptedSyntheticsSavedMonitors, + selectMonitorListState, + selectorMonitorDetailsState, +} from '../../../state'; + +export const useSelectedMonitor = () => { + const { monitorId } = useParams<{ monitorId: string }>(); + const monitorsList = useSelector(selectEncryptedSyntheticsSavedMonitors); + const { loading: monitorListLoading } = useSelector(selectMonitorListState); + const monitorFromList = useMemo( + () => monitorsList.find((monitor) => monitor.id === monitorId) ?? null, + [monitorId, monitorsList] + ); + + const { syntheticsMonitor, syntheticsMonitorLoading } = useSelector(selectorMonitorDetailsState); + const dispatch = useDispatch(); + + const isMonitorFromListValid = monitorId && monitorFromList && monitorFromList?.id === monitorId; + const isLoadedSyntheticsMonitorValid = + monitorId && syntheticsMonitor && syntheticsMonitor?.id === monitorId; + const availableMonitor = isLoadedSyntheticsMonitorValid + ? syntheticsMonitor + : isMonitorFromListValid + ? monitorFromList + : null; + + useEffect(() => { + if (monitorId && !availableMonitor && !syntheticsMonitorLoading) { + dispatch(getMonitorAction.get({ monitorId })); + } + }, [dispatch, monitorId, availableMonitor, syntheticsMonitorLoading]); + + return { + monitor: availableMonitor, + loading: syntheticsMonitorLoading || monitorListLoading, + }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/last_run_info.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/last_run_info.tsx similarity index 98% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/last_run_info.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/last_run_info.tsx index 4c3f4e885bc86..d69cde15f734d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/last_run_info.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/last_run_info.tsx @@ -16,7 +16,6 @@ import { useSelectedLocation } from './hooks/use_selected_location'; export const MonitorSummaryLastRunInfo = ({ ping }: { ping: Ping }) => { const selectedLocation = useSelectedLocation(); const isBrowserType = ping.monitor.type === 'browser'; - const theme = useTheme(); return ( @@ -26,7 +25,7 @@ export const MonitorSummaryLastRunInfo = ({ ping }: { ping: Ping }) => { {isBrowserType ? SUCCESS_LABEL : UP_LABEL} - ) : ping.monitor.status === 'up' ? ( + ) : ping.monitor.status === 'down' ? ( {isBrowserType ? FAILED_LABEL : DOWN_LABEL} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page.tsx new file mode 100644 index 0000000000000..ee9ba46c561bb --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { useSelectedMonitor } from './hooks/use_selected_monitor'; +import { useSelectedLocation } from './hooks/use_selected_location'; +import { getMonitorAction, getMonitorRecentPingsAction } from '../../state/monitor_details'; +import { useMonitorListBreadcrumbs } from '../monitors_page/hooks/use_breadcrumbs'; +export const MonitorDetailsPage = () => { + const { monitor } = useSelectedMonitor(); + + useMonitorListBreadcrumbs([{ text: monitor?.name ?? '' }]); + + const dispatch = useDispatch(); + + const selectedLocation = useSelectedLocation(); + const { monitorId } = useParams<{ monitorId: string }>(); + + useEffect(() => { + dispatch(getMonitorAction.get({ monitorId })); + + if (selectedLocation) { + dispatch(getMonitorRecentPingsAction.get({ monitorId, locationId: selectedLocation.id })); + } + }, [dispatch, monitorId, selectedLocation]); + + return <>; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_header.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_header.tsx new file mode 100644 index 0000000000000..0425ab3ce309e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_header.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { MonitorDetailsTabs } from './monitor_detials_tabs'; + +export const MonitorDetailsPageHeader = () => { + return ; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_title.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_title.tsx new file mode 100644 index 0000000000000..954ecc77c26c0 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_details_page_title.tsx @@ -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 React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSelectedMonitor } from './hooks/use_selected_monitor'; +import { useSelectedLocation } from './hooks/use_selected_location'; +import { getMonitorRecentPingsAction, selectLatestPing, selectPingsLoading } from '../../state'; +import { MonitorSummaryLastRunInfo } from './last_run_info'; + +export const MonitorDetailsPageTitle = () => { + const dispatch = useDispatch(); + + const latestPing = useSelector(selectLatestPing); + const pingsLoading = useSelector(selectPingsLoading); + + const { monitorId } = useParams<{ monitorId: string }>(); + const { monitor } = useSelectedMonitor(); + const location = useSelectedLocation(); + + useEffect(() => { + const locationId = location?.label; + if (monitorId && locationId) { + dispatch(getMonitorRecentPingsAction.get({ monitorId, locationId })); + } + }, [dispatch, monitorId, location]); + + return ( + + {monitor?.name} + + {pingsLoading || (latestPing && latestPing.monitor.id !== monitorId) ? ( + + ) : latestPing ? ( + + ) : ( + + {i18n.translate('xpack.synthetics.monitorSummary.noLastRunInformationAvailable', { + defaultMessage: 'No last run information available for {location} yet.', + values: { + location: location?.label, + }, + })} + + )} + + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_tabs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_detials_tabs.tsx similarity index 83% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_tabs.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_detials_tabs.tsx index 725d209c8200e..6667034da5fdf 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_tabs.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_detials_tabs.tsx @@ -8,11 +8,11 @@ import { EuiIcon, EuiSpacer, EuiTabbedContent } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ErrorsTabContent } from './tabs_content/errors_tab_content'; -import { HistoryTabContent } from './tabs_content/history_tab_content'; -import { SummaryTabContent } from './tabs_content/summary_tab_content'; +import { ErrorsTabContent } from './monitor_errors/monitor_errors'; +import { HistoryTabContent } from './monitor_history/monitor_history'; +import { MonitorSummary } from './monitor_summary/monitor_summary'; -export const MonitorSummaryTabs = () => { +export const MonitorDetailsTabs = () => { const tabs = [ { id: 'summary', @@ -20,7 +20,7 @@ export const MonitorSummaryTabs = () => { content: ( <> - + ), }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/errors_tab_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/errors_tab_content.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/history_tab_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/history_tab_content.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/availability_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/availability_panel.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/availability_sparklines.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/availability_sparklines.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/duration_panel.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/duration_trend.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/duration_trend.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx new file mode 100644 index 0000000000000..82a3eb3c6b7d8 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.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 from 'react'; +import { css } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +export const KpiWrapper: React.FC> = ({ children }) => { + const { euiTheme } = useEuiTheme(); + + const wrapperStyle = css` + border: none; + & > span.euiLoadingSpinner { + margin: ${euiTheme.size.s}; + } + + .legacyMtrVis__container > div { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `; + + return
{children}
; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx new file mode 100644 index 0000000000000..85adcd7ff3c0c --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { Criteria } from '@elastic/eui/src/components/basic_table/basic_table'; +import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; + +import { ConfigKey, DataStream, JourneyStep, Ping } from '../../../../../../common/runtime_types'; +import { + formatTestDuration, + formatTestRunAt, +} from '../../../utils/monitor_test_result/test_time_formats'; +import { useSyntheticsSettingsContext } from '../../../contexts/synthetics_settings_context'; + +import { sortPings } from '../../../utils/monitor_test_result/sort_pings'; +import { checkIsStalePing } from '../../../utils/monitor_test_result/check_pings'; +import { selectPingsLoading, selectMonitorRecentPings, selectPingsError } from '../../../state'; +import { parseBadgeStatus, StatusBadge } from '../../common/monitor_test_result/status_badge'; +import { isStepEnd } from '../../common/monitor_test_result/browser_steps_list'; +import { JourneyStepScreenshotContainer } from '../../common/monitor_test_result/journey_step_screenshot_container'; + +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; +import { useJourneySteps } from '../hooks/use_journey_steps'; + +type SortableField = 'timestamp' | 'monitor.status' | 'monitor.duration.us'; + +export const LastTenTestRuns = () => { + const { basePath } = useSyntheticsSettingsContext(); + + const [sortField, setSortField] = useState('timestamp'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const pings = useSelector(selectMonitorRecentPings); + const sortedPings = useMemo(() => { + return sortPings(pings, sortField, sortDirection); + }, [pings, sortField, sortDirection]); + const pingsLoading = useSelector(selectPingsLoading); + const pingsError = useSelector(selectPingsError); + const { monitor } = useSelectedMonitor(); + + const isBrowserMonitor = monitor?.[ConfigKey.MONITOR_TYPE] === DataStream.BROWSER; + const hasStalePings = checkIsStalePing(monitor, pings?.[0]); + const loading = hasStalePings || pingsLoading; + + const sorting: EuiTableSortingType = { + sort: { + field: sortField as keyof Ping, + direction: sortDirection as 'asc' | 'desc', + }, + }; + + const handleTableChange = ({ page, sort }: Criteria) => { + if (sort !== undefined) { + setSortField(sort.field as SortableField); + setSortDirection(sort.direction); + } + }; + + const columns: Array> = [ + ...((isBrowserMonitor + ? [ + { + align: 'left', + field: 'timestamp', + name: SCREENSHOT_LABEL, + render: (_timestamp: string, item) => , + }, + ] + : []) as Array>), + { + align: 'left', + valign: 'middle', + field: 'timestamp', + name: '@timestamp', + sortable: true, + render: (timestamp: string, ping: Ping) => ( + + ), + }, + { + align: 'left', + valign: 'middle', + field: 'monitor.status', + name: RESULT_LABEL, + sortable: true, + render: (status: string) => , + }, + { + align: 'left', + field: 'error.message', + name: MESSAGE_LABEL, + textOnly: true, + render: (errorMessage: string) => ( + {errorMessage?.length > 0 ? errorMessage : '-'} + ), + }, + { + align: 'right', + valign: 'middle', + field: 'monitor.duration.us', + name: DURATION_LABEL, + sortable: true, + render: (durationUs: number) => {formatTestDuration(durationUs)}, + }, + ]; + + return ( + + + + +

{pings?.length >= 10 ? LAST_10_TEST_RUNS : TEST_RUNS}

+
+
+ + + + {i18n.translate('xpack.synthetics.monitorDetails.summary.viewHistory', { + defaultMessage: 'View History', + })} + + +
+ +
+ ); +}; + +const JourneyScreenshot = ({ ping }: { ping: Ping }) => { + const { data: stepsData, loading: stepsLoading } = useJourneySteps(ping?.monitor?.check_group); + const stepEnds: JourneyStep[] = (stepsData?.steps ?? []).filter(isStepEnd); + const stepLabels = stepEnds.map((stepEnd) => stepEnd?.synthetics?.step?.name ?? ''); + + const lastSignificantStep = useMemo(() => { + const copy = [...stepEnds]; + // Sort desc by timestamp + copy.sort( + (stepA, stepB) => + Number(new Date(stepB['@timestamp'])) - Number(new Date(stepA['@timestamp'])) + ); + return copy.find( + (stepEnd) => parseBadgeStatus(stepEnd?.synthetics?.step?.status ?? 'skipped') !== 'skipped' + ); + }, [stepEnds]); + + return ( + + ); +}; + +const TestDetailsLink = ({ + isBrowserMonitor, + timestamp, + ping, +}: { + isBrowserMonitor: boolean; + timestamp: string; + ping: Ping; +}) => { + const { euiTheme } = useEuiTheme(); + const { basePath } = useSyntheticsSettingsContext(); + + const timestampText = ( + + {formatTestRunAt(timestamp)} + + ); + + return isBrowserMonitor ? ( + + {timestampText} + + ) : ( + timestampText + ); +}; + +const TEST_RUNS = i18n.translate('xpack.synthetics.monitorDetails.summary.testRuns', { + defaultMessage: 'Test Runs', +}); + +const LAST_10_TEST_RUNS = i18n.translate( + 'xpack.synthetics.monitorDetails.summary.lastTenTestRuns', + { + defaultMessage: 'Last 10 Test Runs', + } +); + +const SCREENSHOT_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.screenshot', { + defaultMessage: 'Screenshot', +}); + +const RESULT_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.result', { + defaultMessage: 'Result', +}); + +const MESSAGE_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.message', { + defaultMessage: 'Message', +}); + +const DURATION_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.duration', { + defaultMessage: 'Duration', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx new file mode 100644 index 0000000000000..355dbfa19499e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; + +import { + ConfigKey, + DataStream, + EncryptedSyntheticsSavedMonitor, + Ping, +} from '../../../../../../common/runtime_types'; +import { checkIsStalePing } from '../../../utils/monitor_test_result/check_pings'; +import { formatTestRunAt } from '../../../utils/monitor_test_result/test_time_formats'; + +import { useSyntheticsSettingsContext } from '../../../contexts'; +import { selectLatestPing, selectPingsLoading } from '../../../state'; +import { BrowserStepsList } from '../../common/monitor_test_result/browser_steps_list'; +import { SinglePingResult } from '../../common/monitor_test_result/single_ping_result'; +import { parseBadgeStatus, StatusBadge } from '../../common/monitor_test_result/status_badge'; + +import { useJourneySteps } from '../hooks/use_journey_steps'; +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; + +export const LastTestRun = () => { + const { euiTheme } = useEuiTheme(); + const latestPing = useSelector(selectLatestPing); + const pingsLoading = useSelector(selectPingsLoading); + const { monitor } = useSelectedMonitor(); + + const { data: stepsData, loading: stepsLoading } = useJourneySteps( + latestPing?.monitor?.check_group + ); + + const hasStalePings = checkIsStalePing(monitor, latestPing); + const loading = hasStalePings || stepsLoading || pingsLoading; + + return ( + + + {!loading && latestPing?.error ? ( + + + {i18n.translate('xpack.synthetics.monitorDetails.summary.viewErrorDetails', { + defaultMessage: 'View error details', + })} + + + ) : null} + + + + {monitor?.type === DataStream.BROWSER ? ( + + ) : ( + + )} + + ); +}; + +const PanelHeader = ({ + monitor, + latestPing, + loading, +}: { + monitor: EncryptedSyntheticsSavedMonitor | null; + latestPing: Ping; + loading: boolean; +}) => { + const { euiTheme } = useEuiTheme(); + + const { basePath } = useSyntheticsSettingsContext(); + + const lastRunTimestamp = useMemo( + () => (latestPing?.timestamp ? formatTestRunAt(latestPing?.timestamp) : ''), + [latestPing?.timestamp] + ); + + const isBrowserMonitor = monitor?.[ConfigKey.MONITOR_TYPE] === DataStream.BROWSER; + + const TitleNode = ( + +

{LAST_TEST_RUN_LABEL}

+
+ ); + + if (loading) { + return ( + <> + + {TitleNode} + + + + + + + {isBrowserMonitor ? : null} + + + ); + } + + if (!latestPing) { + return <>{TitleNode}; + } + + return ( + <> + + {TitleNode} + + 0 ? 'fail' : 'success')} + /> + + + + {lastRunTimestamp} + + + + {isBrowserMonitor ? ( + + + {i18n.translate('xpack.synthetics.monitorDetails.summary.viewTestRun', { + defaultMessage: 'View test run', + })} + + + ) : null} + + + ); +}; + +const LAST_TEST_RUN_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.summary.lastTestRunTitle', + { + defaultMessage: 'Last test run', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/locations_status.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/locations_status.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/locations_status.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/locations_status.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/monitor_details_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx similarity index 77% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/monitor_details_panel.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx index 6209ddb7c5d2e..3d8013c79beff 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/monitor_details_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { css } from '@emotion/react'; import { EuiDescriptionList, EuiDescriptionListTitle, @@ -13,38 +14,46 @@ import { EuiBadge, EuiSpacer, EuiLink, - EuiLoadingSpinner, + EuiLoadingContent, + useEuiTheme, } from '@elastic/eui'; import { capitalize } from 'lodash'; import { i18n } from '@kbn/i18n'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; import { MonitorTags } from './monitor_tags'; import { MonitorEnabled } from '../../monitors_page/management/monitor_list_table/monitor_enabled'; import { LocationsStatus } from './locations_status'; -import { - getSyntheticsMonitorAction, - selectMonitorStatus, - syntheticsMonitorSelector, -} from '../../../state/monitor_summary'; +import { getMonitorAction, selectLatestPing } from '../../../state'; import { ConfigKey } from '../../../../../../common/runtime_types'; export const MonitorDetailsPanel = () => { - const { data } = useSelector(selectMonitorStatus); + const { euiTheme } = useEuiTheme(); + const latestPing = useSelector(selectLatestPing); const { monitorId } = useParams<{ monitorId: string }>(); const dispatch = useDispatch(); - const { data: monitor, loading } = useSelector(syntheticsMonitorSelector); + const { monitor, loading } = useSelectedMonitor(); - if (!data) { - return ; + if ( + (latestPing && latestPing?.config_id !== monitorId) || + (monitor && monitor.id !== monitorId) + ) { + return ; } + const wrapperStyle = css` + .euiDescriptionList.euiDescriptionList--column > *, + .euiDescriptionList.euiDescriptionList--responsiveColumn > * { + margin-top: ${euiTheme.size.s}; + } + `; + return ( - +
{ENABLED_LABEL} @@ -55,14 +64,14 @@ export const MonitorDetailsPanel = () => { id={monitorId} monitor={monitor} reloadPage={() => { - dispatch(getSyntheticsMonitorAction.get(monitorId)); + dispatch(getMonitorAction.get({ monitorId })); }} /> )} {MONITOR_TYPE_LABEL} - {capitalize(data.monitor.type)} + {capitalize(monitor?.type)} {FREQUENCY_LABEL} Every 10 mins @@ -72,8 +81,8 @@ export const MonitorDetailsPanel = () => { {URL_LABEL} - - {data.url?.full} + + {latestPing?.url?.full} {TAGS_LABEL} @@ -81,17 +90,10 @@ export const MonitorDetailsPanel = () => { {monitor && } - +
); }; -const Wrapper = euiStyled.div` - .euiDescriptionList.euiDescriptionList--column > *, - .euiDescriptionList.euiDescriptionList--responsiveColumn > * { - margin-top: ${(props) => props.theme.eui.euiSizeS}; - } -`; - const FREQUENCY_LABEL = i18n.translate('xpack.synthetics.management.monitorList.frequency', { defaultMessage: 'Frequency', }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/summary_tab_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx similarity index 89% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/summary_tab_content.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx index afe940fc06400..0349b3e96cea1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/summary_tab_content.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx @@ -23,8 +23,10 @@ import { AvailabilityPanel } from './availability_panel'; import { DurationPanel } from './duration_panel'; import { MonitorDetailsPanel } from './monitor_details_panel'; import { AvailabilitySparklines } from './availability_sparklines'; +import { LastTestRun } from './last_test_run'; +import { LastTenTestRuns } from './last_ten_test_runs'; -export const SummaryTabContent = () => { +export const MonitorSummary = () => { const { euiTheme } = useEuiTheme(); return ( @@ -57,7 +59,7 @@ export const SummaryTabContent = () => { {/* TODO: Add error metric and sparkline*/} - + @@ -79,17 +81,19 @@ export const SummaryTabContent = () => { - - {/* TODO: Add status panel*/} - + + {/* /!* TODO: Add status panel*!/ */} + {/* */} - {/* TODO: Add last run panel*/} + + + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/monitor_tags.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_tags.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/monitor_tags.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_tags.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/step_duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/step_duration_panel.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/step_duration_panel.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/step_duration_panel.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/run_test_manually.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/run_test_manually.tsx rename to x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary.tsx deleted file mode 100644 index de5871304eccf..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary.tsx +++ /dev/null @@ -1,27 +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, { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { useParams } from 'react-router-dom'; -import { getSyntheticsMonitorAction, selectMonitorStatus } from '../../state/monitor_summary'; -import { useMonitorListBreadcrumbs } from '../monitors_page/hooks/use_breadcrumbs'; -export const MonitorSummaryPage = () => { - const { data } = useSelector(selectMonitorStatus); - - useMonitorListBreadcrumbs([{ text: data?.monitor.name ?? '' }]); - - const dispatch = useDispatch(); - - const { monitorId } = useParams<{ monitorId: string }>(); - - useEffect(() => { - dispatch(getSyntheticsMonitorAction.get(monitorId)); - }, [dispatch, monitorId]); - - return <>; -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_header_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_header_content.tsx deleted file mode 100644 index 1276de0c32974..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_header_content.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useSelector } from 'react-redux'; -import { MonitorSummaryTabs } from './monitor_summary_tabs'; -import { selectMonitorStatus } from '../../state/monitor_summary'; - -export const MonitorSummaryHeaderContent = () => { - const { data } = useSelector(selectMonitorStatus); - - if (!data) { - return <>; - } - - return ( - <> - - - ); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_title.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_title.tsx deleted file mode 100644 index 9e295496dd77c..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/monitor_summary_title.tsx +++ /dev/null @@ -1,41 +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, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { MonitorSummaryLastRunInfo } from './last_run_info'; -import { getMonitorStatusAction, selectMonitorStatus } from '../../state'; -import { RunTestManually } from './run_test_manually'; - -export const MonitorSummaryTitle = () => { - const dispatch = useDispatch(); - - const { data } = useSelector(selectMonitorStatus); - - const { monitorId } = useParams<{ monitorId: string }>(); - - useEffect(() => { - dispatch(getMonitorStatusAction.get({ monitorId, dateStart: 'now-30d', dateEnd: 'now' })); - }, [dispatch, monitorId]); - - return ( - - - - {data?.monitor.name} - - {data && } - - - - - - - - ); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/kpi_wrapper.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/kpi_wrapper.tsx deleted file mode 100644 index 4496bc0031364..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_summary/tabs_content/kpi_wrapper.tsx +++ /dev/null @@ -1,20 +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 { euiStyled } from '@kbn/kibana-react-plugin/common'; - -export const KpiWrapper = euiStyled.div` - & .euiLoadingSpinner { - margin: ${({ theme }) => theme.eui.euiSizeS}; - } - - & .legacyMtrVis__container > div { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -`; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/empty_state/empty_state_error.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/empty_state/empty_state_error.tsx index 002b22f48b186..3f3a3552446f2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/empty_state/empty_state_error.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/empty_state/empty_state_error.tsx @@ -8,15 +8,15 @@ import React, { Fragment } from 'react'; import { EuiEmptyPrompt, EuiPanel, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import { IHttpSerializedFetchError } from '../../../../state'; interface EmptyStateErrorProps { - errors: Array>; + errors: IHttpSerializedFetchError[]; } export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { const unauthorized = errors.find( - (error) => error.message && error.message.includes('unauthorized') + (error) => error?.body?.message && error.body.message.includes('unauthorized') ); return ( @@ -47,11 +47,7 @@ export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { body={ {!unauthorized && - errors.map((error) => ( -

- {error.body?.message || error.message} -

- ))} + errors.map((error) =>

{error.body?.message}

)}
} /> diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts index 0aabe7b54714a..7ec5fccf3d6e4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts @@ -14,3 +14,4 @@ export * from './use_last_x_checks'; export * from './use_last_50_duration_chart'; export * from './use_location_name'; export * from './use_status_by_location'; +export * from './use_composite_image'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.ts index 0902481c51a9c..4225f888a09bb 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.ts @@ -68,7 +68,8 @@ export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { const params = useUrlParams()[0](); const kibana = useKibana(); const setBreadcrumbs = kibana.services.chrome?.setBreadcrumbs; - const uptimePath = kibana.services.application?.getUrlForApp(PLUGIN.SYNTHETICS_PLUGIN_ID) ?? ''; + const syntheticsPath = + kibana.services.application?.getUrlForApp(PLUGIN.SYNTHETICS_PLUGIN_ID) ?? ''; const observabilityPath = kibana.services.application?.getUrlForApp('observability-overview') ?? ''; const navigate = kibana.services.application?.navigateToUrl; @@ -77,10 +78,10 @@ export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { if (setBreadcrumbs) { setBreadcrumbs( handleBreadcrumbClick( - makeBaseBreadcrumb(uptimePath, observabilityPath, params).concat(extraCrumbs), + makeBaseBreadcrumb(syntheticsPath, observabilityPath, params).concat(extraCrumbs), navigate ) ); } - }, [uptimePath, observabilityPath, extraCrumbs, navigate, params, setBreadcrumbs]); + }, [syntheticsPath, observabilityPath, extraCrumbs, navigate, params, setBreadcrumbs]); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.test.tsx new file mode 100644 index 0000000000000..f77d169dc739e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.test.tsx @@ -0,0 +1,200 @@ +/* + * 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 * as redux from 'react-redux'; +import { renderHook } from '@testing-library/react-hooks'; +import { ScreenshotRefImageData, ScreenshotBlockCache } from '../../../../common/runtime_types'; +import { fetchBlocksAction } from '../state'; +import { shouldCompose, useCompositeImage } from './use_composite_image'; +import * as compose from '../utils/monitor_test_result/compose_screenshot_images'; + +const MIME = 'image/jpeg'; + +describe('use composite image', () => { + let imageData: string | undefined; + let imgRef: ScreenshotRefImageData; + let curRef: ScreenshotRefImageData; + let blocks: ScreenshotBlockCache; + + beforeEach(() => { + imgRef = { + stepName: 'step-1', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '123', + monitor: { + check_group: 'check-group', + }, + screenshot_ref: { + width: 100, + height: 200, + blocks: [ + { + hash: 'hash1', + top: 0, + left: 0, + width: 10, + height: 10, + }, + { + hash: 'hash2', + top: 0, + left: 10, + width: 10, + height: 10, + }, + ], + }, + synthetics: { + package_version: 'v1', + step: { index: 0, name: 'first' }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + curRef = { + stepName: 'step-1', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '234', + monitor: { + check_group: 'check-group-2', + }, + screenshot_ref: { + width: 100, + height: 200, + blocks: [ + { + hash: 'hash1', + top: 0, + left: 0, + width: 10, + height: 10, + }, + { + hash: 'hash2', + top: 0, + left: 10, + width: 10, + height: 10, + }, + ], + }, + synthetics: { + package_version: 'v1', + step: { index: 1, name: 'second' }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + blocks = { + hash1: { + id: 'id1', + synthetics: { + blob: 'blob', + blob_mime: MIME, + }, + }, + hash2: { + id: 'id2', + synthetics: { + blob: 'blob', + blob_mime: MIME, + }, + }, + }; + }); + + describe('shouldCompose', () => { + it('returns true if all blocks are loaded and ref is new', () => { + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true); + }); + + it('returns false if a required block is pending', () => { + blocks.hash2 = { status: 'pending' }; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns false if a required block is missing', () => { + delete blocks.hash2; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns false if imageData is defined and the refs have matching step index/check_group', () => { + imageData = 'blob'; + curRef.ref.screenshotRef.synthetics.step.index = 0; + curRef.ref.screenshotRef.monitor.check_group = 'check-group'; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns true if imageData is defined and the refs have different step names', () => { + imageData = 'blob'; + curRef.ref.screenshotRef.synthetics.step.index = 0; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true); + }); + }); + + describe('useCompositeImage', () => { + let useDispatchMock: jest.Mock; + let canvasMock: unknown; + let removeChildSpy: jest.Mock; + let selectorSpy: jest.SpyInstance; + let composeSpy: jest.SpyInstance; + + beforeEach(() => { + useDispatchMock = jest.fn(); + removeChildSpy = jest.fn(); + canvasMock = { + parentElement: { + removeChild: removeChildSpy, + }, + toDataURL: jest.fn().mockReturnValue('compose success'), + }; + // @ts-expect-error mocking canvas element for testing + jest.spyOn(document, 'createElement').mockReturnValue(canvasMock); + jest.spyOn(redux, 'useDispatch').mockReturnValue(useDispatchMock); + selectorSpy = jest.spyOn(redux, 'useSelector').mockReturnValue({ blocks }); + composeSpy = jest + .spyOn(compose, 'composeScreenshotRef') + .mockReturnValue(new Promise((r) => r([]))); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not compose if all blocks are not loaded', () => { + blocks = {}; + renderHook(() => useCompositeImage(imgRef, jest.fn(), imageData)); + + expect(useDispatchMock).toHaveBeenCalledWith(fetchBlocksAction(['hash1', 'hash2'])); + }); + + it('composes when all required blocks are loaded', async () => { + const onComposeImageSuccess = jest.fn(); + const { waitFor } = renderHook(() => useCompositeImage(imgRef, onComposeImageSuccess)); + + expect(selectorSpy).toHaveBeenCalled(); + expect(composeSpy).toHaveBeenCalledTimes(1); + expect(composeSpy.mock.calls[0][0]).toEqual(imgRef); + expect(composeSpy.mock.calls[0][1]).toBe(canvasMock); + expect(composeSpy.mock.calls[0][2]).toBe(blocks); + + await waitFor( + () => { + expect(onComposeImageSuccess).toHaveBeenCalledTimes(1); + expect(onComposeImageSuccess).toHaveBeenCalledWith('compose success'); + }, + { timeout: 10000 } + ); + }); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.ts new file mode 100644 index 0000000000000..5cdaffb83fa21 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_composite_image.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useDispatch, useSelector } from 'react-redux'; +import React from 'react'; +import { composeScreenshotRef } from '../utils/monitor_test_result/compose_screenshot_images'; +import { + ScreenshotRefImageData, + ScreenshotBlockCache, + StoreScreenshotBlock, +} from '../../../../common/runtime_types'; +import { fetchBlocksAction, isPendingBlock } from '../state'; +import { selectBrowserJourneyState } from '../state'; + +function allBlocksLoaded(blocks: { [key: string]: StoreScreenshotBlock }, hashes: string[]) { + for (const hash of hashes) { + if (!blocks[hash] || isPendingBlock(blocks[hash])) { + return false; + } + } + return true; +} + +/** + * Checks if two refs are the same. If the ref is unchanged, there's no need + * to run the expensive draw procedure. + * + * The key fields here are `step.index` and `check_group`, as there's a 1:1 between + * journey and check group, and each step has a unique index within a journey. + */ +const isNewRef = ( + { + ref: { + screenshotRef: { + synthetics: { + step: { index: indexA }, + }, + monitor: { check_group: checkGroupA }, + }, + }, + }: ScreenshotRefImageData, + { + ref: { + screenshotRef: { + synthetics: { + step: { index: indexB }, + }, + monitor: { check_group: checkGroupB }, + }, + }, + }: ScreenshotRefImageData +): boolean => indexA !== indexB || checkGroupA !== checkGroupB; + +export function shouldCompose( + imageData: string | undefined, + imgRef: ScreenshotRefImageData, + curRef: ScreenshotRefImageData, + blocks: ScreenshotBlockCache +): boolean { + return ( + allBlocksLoaded( + blocks, + imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash) + ) && + (typeof imageData === 'undefined' || isNewRef(imgRef, curRef)) + ); +} + +/** + * Assembles the data for a composite image and returns the composite to a callback. + * @param imgRef the data and dimensions for the composite image. + * @param onComposeImageSuccess sends the composited image to this callback. + * @param imageData this is the composited image value, if it is truthy the function will skip the compositing process + */ +export const useCompositeImage = ( + imgRef: ScreenshotRefImageData, + onComposeImageSuccess: React.Dispatch, + imageData?: string +): void => { + const dispatch = useDispatch(); + const { blocks }: { blocks: ScreenshotBlockCache } = useSelector(selectBrowserJourneyState); + + React.useEffect(() => { + dispatch( + fetchBlocksAction(imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash)) + ); + }, [dispatch, imgRef.ref.screenshotRef.screenshot_ref.blocks]); + + const [curRef, setCurRef] = React.useState(imgRef); + + React.useEffect(() => { + const canvas = document.createElement('canvas'); + + async function compose() { + await composeScreenshotRef(imgRef, canvas, blocks); + const imgData = canvas.toDataURL('image/jpg', 1.0); + onComposeImageSuccess(imgData); + } + + // if the URL is truthy it means it's already been composed, so there + // is no need to call the function + if (shouldCompose(imageData, imgRef, curRef, blocks)) { + compose(); + setCurRef(imgRef); + } + return () => { + canvas.parentElement?.removeChild(canvas); + }; + }, [blocks, curRef, imageData, imgRef, onComposeImageSuccess]); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx index 8da37518fbede..3c1081f9f1f04 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx @@ -17,9 +17,9 @@ import { useInspectorContext } from '@kbn/observability-plugin/public'; import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-plugin/public'; import { MonitorAddPage } from './components/monitor_add_edit/monitor_add_page'; import { MonitorEditPage } from './components/monitor_add_edit/monitor_edit_page'; -import { MonitorSummaryHeaderContent } from './components/monitor_summary/monitor_summary_header_content'; -import { MonitorSummaryTitle } from './components/monitor_summary/monitor_summary_title'; -import { MonitorSummaryPage } from './components/monitor_summary/monitor_summary'; +import { MonitorDetailsPageHeader } from './components/monitor_details/monitor_details_page_header'; +import { MonitorDetailsPageTitle } from './components/monitor_details/monitor_details_page_title'; +import { MonitorDetailsPage } from './components/monitor_details/monitor_details_page'; import { GettingStartedPage } from './components/getting_started/getting_started_page'; import { MonitorsPageHeader } from './components/monitors_page/management/page_header/monitors_page_header'; import { OverviewPage } from './components/monitors_page/overview/overview_page'; @@ -78,16 +78,16 @@ const getRoutes = ( }, }, { - title: i18n.translate('xpack.synthetics.monitorSummaryRoute.title', { - defaultMessage: 'Monitor summary | {baseTitle}', + title: i18n.translate('xpack.synthetics.monitorDetails.title', { + defaultMessage: 'Synthetics Monitor Details | {baseTitle}', values: { baseTitle }, }), path: MONITOR_ROUTE, - component: () => , - dataTestSubj: 'syntheticsGettingStartedPage', + component: () => , + dataTestSubj: 'syntheticsMonitorDetailsPage', pageHeader: { - children: , - pageTitle: , + children: , + pageTitle: , // rightSideItems: [], }, }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/actions.ts new file mode 100644 index 0000000000000..3a151ced4246c --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/actions.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAction } from '@reduxjs/toolkit'; +import { PutBlocksPayload } from './models'; + +// This action denotes a set of blocks is required +export const fetchBlocksAction = createAction('[BROWSER JOURNEY] FETCH BLOCKS'); + +// This action denotes a request for a set of blocks is in flight +export const setBlockLoadingAction = createAction( + '[BROWSER JOURNEY] SET BLOCKS IN FLIGHT' +); + +// Block data has been received, and should be added to the store +export const putBlocksAction = createAction( + '[BROWSER JOURNEY] PUT SCREENSHOT BLOCKS' +); + +// Updates the total size of the image blob data cached in the store +export const putCacheSize = createAction('[BROWSER JOURNEY] PUT CACHE SIZE'); + +// Keeps track of the most-requested blocks +export const updateHitCountsAction = createAction('[BROWSER JOURNEY] UPDATE HIT COUNTS'); + +// Reduce the cache size to the value in the action payload +export const pruneCacheAction = createAction( + '[BROWSER JOURNEY] PRUNE SCREENSHOT BLOCK CACHE' +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/api.ts new file mode 100644 index 0000000000000..a6fd6185af06b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/api.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { apiService } from '../../../../utils/api_service'; +import { + FailedStepsApiResponse, + FailedStepsApiResponseType, + ScreenshotBlockDoc, + ScreenshotImageBlob, + ScreenshotRefImageData, + SyntheticsJourneyApiResponse, + SyntheticsJourneyApiResponseType, + Ping, + PingType, +} from '../../../../../common/runtime_types'; +import { API_URLS } from '../../../../../common/constants'; + +export interface FetchJourneyStepsParams { + checkGroup: string; + syntheticEventTypes?: string[]; +} + +export async function fetchScreenshotBlockSet(params: string[]): Promise { + return apiService.post(API_URLS.JOURNEY_SCREENSHOT_BLOCKS, { + hashes: params, + }); +} + +export async function fetchJourneySteps( + params: FetchJourneyStepsParams +): Promise { + return apiService.get( + API_URLS.JOURNEY.replace('{checkGroup}', params.checkGroup), + { syntheticEventTypes: params.syntheticEventTypes }, + SyntheticsJourneyApiResponseType + ); +} + +export async function fetchJourneysFailedSteps({ + checkGroups, +}: { + checkGroups: string[]; +}): Promise { + return apiService.get(API_URLS.JOURNEY_FAILED_STEPS, { checkGroups }, FailedStepsApiResponseType); +} + +export async function fetchLastSuccessfulCheck({ + monitorId, + timestamp, + stepIndex, + location, +}: { + monitorId: string; + timestamp: string; + stepIndex: number; + location?: string; +}): Promise { + return await apiService.get( + API_URLS.SYNTHETICS_SUCCESSFUL_CHECK, + { + monitorId, + timestamp, + stepIndex, + location, + }, + PingType + ); +} + +export async function getJourneyScreenshot( + imgSrc: string +): Promise { + try { + const imgRequest = new Request(imgSrc); + + const response = await fetch(imgRequest); + + if (response.status !== 200) { + return null; + } + + const contentType = response.headers.get('content-type'); + const stepName = response.headers.get('caption-name'); + const maxSteps = Number(response.headers.get('max-steps') ?? 0); + if (contentType?.indexOf('application/json') !== -1) { + return { + stepName, + maxSteps, + ref: await response.json(), + }; + } else { + return { + stepName, + maxSteps, + src: URL.createObjectURL(await response.blob()), + }; + } + } catch (e) { + return null; + } +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/effects.ts new file mode 100644 index 0000000000000..ad85440ee9708 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/effects.ts @@ -0,0 +1,87 @@ +/* + * 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 { Action } from 'redux-actions'; +import { all, call, fork, put, select, takeEvery, throttle } from 'redux-saga/effects'; +import { ScreenshotBlockDoc, ScreenshotBlockCache } from '../../../../../common/runtime_types'; +import { fetchScreenshotBlockSet } from './api'; + +import { + fetchBlocksAction, + setBlockLoadingAction, + pruneCacheAction, + putBlocksAction, + putCacheSize, + updateHitCountsAction, +} from './actions'; + +import { isPendingBlock } from './models'; + +import { selectBrowserJourneyState } from './selectors'; + +export function* browserJourneyEffects() { + yield all([fork(fetchScreenshotBlocks), fork(generateBlockStatsOnPut), fork(pruneBlockCache)]); +} + +function* fetchBlocks(hashes: string[]) { + yield put(setBlockLoadingAction(hashes)); + const blocks: ScreenshotBlockDoc[] = yield call(fetchScreenshotBlockSet, hashes); + yield put(putBlocksAction({ blocks })); +} + +function* fetchScreenshotBlocks() { + /** + * We maintain a list of each hash and how many times it is requested so we can avoid + * subsequent re-requests if the block is dropped due to cache pruning. + */ + yield takeEvery(String(fetchBlocksAction), function* (action: Action) { + if (action.payload.length > 0) { + yield put(updateHitCountsAction(action.payload)); + } + }); + + /** + * We do a short delay to allow multiple item renders to queue up before dispatching + * a fetch to the backend. + */ + yield throttle(20, String(fetchBlocksAction), function* () { + const { blocks }: { blocks: ScreenshotBlockCache } = yield select(selectBrowserJourneyState); + const toFetch = Object.keys(blocks).filter((hash) => { + const block = blocks[hash]; + return isPendingBlock(block) && block.status !== 'loading'; + }); + + if (toFetch.length > 0) { + yield fork(fetchBlocks, toFetch); + } + }); +} + +function* generateBlockStatsOnPut() { + yield takeEvery( + String(putBlocksAction), + function* (action: Action<{ blocks: ScreenshotBlockDoc[] }>) { + const batchSize = action.payload.blocks.reduce((total, cur) => { + return cur.synthetics.blob.length + total; + }, 0); + yield put(putCacheSize(batchSize)); + } + ); +} + +// 4 MB cap for cache size +const MAX_CACHE_SIZE = 4000000; + +function* pruneBlockCache() { + yield takeEvery(String(putCacheSize), function* (_action: Action) { + const { cacheSize }: { cacheSize: number } = yield select(selectBrowserJourneyState); + + if (cacheSize > MAX_CACHE_SIZE) { + yield put(pruneCacheAction(cacheSize - MAX_CACHE_SIZE)); + } + }); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/index.ts new file mode 100644 index 0000000000000..75b82f989358b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/index.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { isScreenshotBlockDoc } from '../../../../../common/runtime_types'; + +import type { BrowserJourneyState } from './models'; +import { + pruneCacheAction, + putBlocksAction, + putCacheSize, + updateHitCountsAction, + fetchBlocksAction, + setBlockLoadingAction, +} from './actions'; + +const initialState: BrowserJourneyState = { + blocks: {}, + cacheSize: 0, + hitCount: [], +}; + +export const browserJourneyReducer = createReducer(initialState, (builder) => { + builder + /** + * When removing blocks from the cache, we receive an action with a number. + * The number equates to the desired ceiling size of the cache. We then discard + * blocks, ordered by the least-requested. We continue dropping blocks until + * the newly-pruned size will be less than the ceiling supplied by the action. + */ + .addCase(pruneCacheAction, (state, action) => { + handlePruneAction(state, action.payload); + }) + + /** + * Keep track of the least- and most-requested blocks, so when it is time to + * prune we keep the most commonly-used ones. + */ + .addCase(updateHitCountsAction, (state, action) => { + handleUpdateHitCountsAction(state, action.payload); + }) + + .addCase(putCacheSize, (state, action) => { + state.cacheSize = state.cacheSize + action.payload; + }) + + .addCase(fetchBlocksAction, (state, action) => { + state.blocks = { + ...state.blocks, + ...action.payload + // there's no need to overwrite existing blocks because the key + // is either storing a pending req or a cached result + .filter((b) => !state.blocks[b]) + // convert the list of new hashes in the payload to an object that + // will combine with with the existing blocks cache + .reduce( + (acc, cur) => ({ + ...acc, + [cur]: { status: 'pending' }, + }), + {} + ), + }; + }) + + .addCase(setBlockLoadingAction, (state, action) => { + state.blocks = { + ...state.blocks, + ...action.payload.reduce( + (acc, cur) => ({ + ...acc, + [cur]: { status: 'loading' }, + }), + {} + ), + }; + }) + + .addCase(putBlocksAction, (state, action) => { + state.blocks = { + ...state.blocks, + ...action.payload.blocks.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }; + }); +}); + +function handlePruneAction(state: BrowserJourneyState, pruneSize: number) { + const { blocks, hitCount } = state; + const hashesToPrune: string[] = []; + let sizeToRemove = 0; + let removeIndex = hitCount.length - 1; + while (sizeToRemove < pruneSize && removeIndex >= 0) { + const { hash } = hitCount[removeIndex]; + removeIndex--; + if (!blocks[hash]) continue; + const block = blocks[hash]; + if (isScreenshotBlockDoc(block)) { + sizeToRemove += block.synthetics.blob.length; + hashesToPrune.push(hash); + } + } + for (const hash of hashesToPrune) { + delete blocks[hash]; + } + + state.cacheSize = state.cacheSize - sizeToRemove; + state.hitCount = hitCount.slice(0, removeIndex + 1); +} + +function handleUpdateHitCountsAction(state: BrowserJourneyState, hashes: string[]) { + const newHitCount = [...state.hitCount]; + const hitTime = Date.now(); + hashes.forEach((hash) => { + const countItem = newHitCount.find((item) => item.hash === hash); + if (!countItem) { + newHitCount.push({ hash, hitTime }); + } else { + countItem.hitTime = hitTime; + } + }); + // sorts in descending order + newHitCount.sort((a, b) => b.hitTime - a.hitTime); + + state.hitCount = newHitCount; +} + +export * from './api'; +export * from './models'; +export * from './actions'; +export * from './effects'; +export * from './selectors'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/models.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/models.ts new file mode 100644 index 0000000000000..12d6074300c89 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/models.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + PendingBlock, + ScreenshotBlockCache, + ScreenshotBlockDoc, +} from '../../../../../common/runtime_types'; + +export function isPendingBlock(data: unknown): data is PendingBlock { + return ['pending', 'loading'].some((s) => s === (data as PendingBlock)?.status); +} + +export interface CacheHitCount { + hash: string; + hitTime: number; +} + +export interface BrowserJourneyState { + blocks: ScreenshotBlockCache; + cacheSize: number; + hitCount: CacheHitCount[]; +} + +export interface PutBlocksPayload { + blocks: ScreenshotBlockDoc[]; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/selectors.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/selectors.ts new file mode 100644 index 0000000000000..eae2632d9ae5a --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/browser_journey/selectors.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SyntheticsAppState } from '../root_reducer'; + +export const selectBrowserJourneyState = (state: SyntheticsAppState) => state.browserJourney; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts index 6076292c34550..727fd0dfcd4c2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts @@ -15,5 +15,6 @@ export * from './index_status'; export * from './synthetics_enablement'; export * from './service_locations'; export * from './monitor_list'; -export * from './monitor_summary'; +export * from './monitor_details'; export * from './overview'; +export * from './browser_journey'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/index.ts index d1592e26bf17d..f5351c65d0d6b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; import { createReducer } from '@reduxjs/toolkit'; +import { IHttpSerializedFetchError, serializeHttpFetchError } from '../utils/http_error'; import { StatesIndexStatus } from '../../../../../common/runtime_types'; import { getIndexStatus, getIndexStatusSuccess, getIndexStatusFail } from './actions'; @@ -14,7 +14,7 @@ import { getIndexStatus, getIndexStatusSuccess, getIndexStatusFail } from './act export interface IndexStatusState { data: StatesIndexStatus | null; loading: boolean; - error: IHttpFetchError | null; + error: IHttpSerializedFetchError | null; } const initialState: IndexStatusState = { @@ -33,7 +33,7 @@ export const indexStatusReducer = createReducer(initialState, (builder) => { state.loading = false; }) .addCase(getIndexStatusFail, (state, action) => { - state.error = action.payload as IHttpFetchError; + state.error = serializeHttpFetchError(action.payload); state.loading = false; }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/actions.ts new file mode 100644 index 0000000000000..a80196275a759 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/actions.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAction } from '@reduxjs/toolkit'; +import { + Ping, + PingsResponse, + EncryptedSyntheticsSavedMonitor, +} from '../../../../../common/runtime_types'; +import { QueryParams } from './api'; +import { createAsyncAction } from '../utils/actions'; + +export const setMonitorDetailsLocationAction = createAction( + '[MONITOR SUMMARY] SET LOCATION' +); + +export const getMonitorStatusAction = createAsyncAction('[MONITOR DETAILS] GET'); + +export const getMonitorAction = createAsyncAction< + { monitorId: string }, + EncryptedSyntheticsSavedMonitor +>('[MONITOR DETAILS] GET MONITOR'); + +export const getMonitorRecentPingsAction = createAsyncAction< + { monitorId: string; locationId: string }, + PingsResponse +>('[MONITOR DETAILS] GET RECENT PINGS'); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/api.ts new file mode 100644 index 0000000000000..f2541b119e56d --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/api.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObject } from '@kbn/core/types'; +import { apiService } from '../../../../utils/api_service'; +import { + EncryptedSyntheticsSavedMonitor, + PingsResponse, + PingsResponseType, + SyntheticsMonitor, +} from '../../../../../common/runtime_types'; +import { API_URLS, SYNTHETICS_API_URLS } from '../../../../../common/constants'; + +export interface QueryParams { + monitorId: string; + dateStart: string; + dateEnd: string; +} + +export const fetchMonitorRecentPings = async ({ + monitorId, + locationId, +}: { + monitorId: string; + locationId: string; +}): Promise => { + const from = new Date(0).toISOString(); + const to = new Date().toISOString(); + const locations = JSON.stringify([locationId]); + const sort = 'desc'; + const size = 10; + + return await apiService.get( + SYNTHETICS_API_URLS.PINGS, + { monitorId, from, to, locations, sort, size }, + PingsResponseType + ); +}; + +export const fetchSyntheticsMonitor = async ({ + monitorId, +}: { + monitorId: string; +}): Promise => { + const savedObject = (await apiService.get( + `${API_URLS.SYNTHETICS_MONITORS}/${monitorId}` + )) as SavedObject; + + return { + id: savedObject.id, + ...savedObject.attributes, + updated_at: savedObject.updated_at, + } as EncryptedSyntheticsSavedMonitor; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/effects.ts new file mode 100644 index 0000000000000..1b1b686400d88 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/effects.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { takeLeading } from 'redux-saga/effects'; +import { fetchEffectFactory } from '../utils/fetch_effect'; +import { getMonitorRecentPingsAction, getMonitorAction } from './actions'; +import { fetchSyntheticsMonitor, fetchMonitorRecentPings } from './api'; + +export function* fetchSyntheticsMonitorEffect() { + yield takeLeading( + getMonitorRecentPingsAction.get, + fetchEffectFactory( + fetchMonitorRecentPings, + getMonitorRecentPingsAction.success, + getMonitorRecentPingsAction.fail + ) + ); + + yield takeLeading( + getMonitorAction.get, + fetchEffectFactory(fetchSyntheticsMonitor, getMonitorAction.success, getMonitorAction.fail) + ); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts new file mode 100644 index 0000000000000..a2d9379df778e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { createReducer } from '@reduxjs/toolkit'; +import { IHttpSerializedFetchError, serializeHttpFetchError } from '../utils/http_error'; +import { + getMonitorRecentPingsAction, + setMonitorDetailsLocationAction, + getMonitorAction, +} from './actions'; +import { EncryptedSyntheticsSavedMonitor, Ping } from '../../../../../common/runtime_types'; + +export interface MonitorDetailsState { + pings: Ping[]; + loading: boolean; + syntheticsMonitorLoading: boolean; + syntheticsMonitor: EncryptedSyntheticsSavedMonitor | null; + error: IHttpSerializedFetchError | null; + selectedLocationId: string | null; +} + +const initialState: MonitorDetailsState = { + pings: [], + loading: false, + syntheticsMonitor: null, + syntheticsMonitorLoading: false, + error: null, + selectedLocationId: null, +}; + +export const monitorDetailsReducer = createReducer(initialState, (builder) => { + builder + .addCase(setMonitorDetailsLocationAction, (state, action) => { + state.selectedLocationId = action.payload; + }) + + .addCase(getMonitorRecentPingsAction.get, (state) => { + state.loading = true; + }) + .addCase(getMonitorRecentPingsAction.success, (state, action) => { + state.pings = action.payload.pings; + state.loading = false; + }) + .addCase(getMonitorRecentPingsAction.fail, (state, action) => { + state.error = serializeHttpFetchError(action.payload as IHttpFetchError); + state.loading = false; + }) + + .addCase(getMonitorAction.get, (state) => { + state.syntheticsMonitorLoading = true; + }) + .addCase(getMonitorAction.success, (state, action) => { + state.syntheticsMonitor = action.payload; + state.syntheticsMonitorLoading = false; + }) + .addCase(getMonitorAction.fail, (state, action) => { + state.error = serializeHttpFetchError(action.payload as IHttpFetchError); + state.syntheticsMonitorLoading = false; + }); +}); + +export * from './actions'; +export * from './effects'; +export * from './selectors'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/selectors.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/selectors.ts similarity index 53% rename from x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/selectors.ts rename to x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/selectors.ts index d361024e839f2..5c6ba75e8cd6d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/selectors.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/selectors.ts @@ -8,13 +8,19 @@ import { createSelector } from 'reselect'; import type { SyntheticsAppState } from '../root_reducer'; -const getState = (appState: SyntheticsAppState) => appState.monitorStatus; +const getState = (appState: SyntheticsAppState) => appState.monitorDetails; + +export const selectorMonitorDetailsState = createSelector(getState, (state) => state); export const selectSelectedLocationId = createSelector( getState, (state) => state.selectedLocationId ); -export const selectMonitorStatus = createSelector(getState, (state) => state); +export const selectLatestPing = createSelector(getState, (state) => state.pings?.[0] ?? null); + +export const selectPingsLoading = createSelector(getState, (state) => state.loading); + +export const selectMonitorRecentPings = createSelector(getState, (state) => state.pings); -export const syntheticsMonitorSelector = (state: SyntheticsAppState) => state.syntheticsMonitor; +export const selectPingsError = createSelector(getState, (state) => state.error); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/actions.ts deleted file mode 100644 index 9595243a53bc3..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/actions.ts +++ /dev/null @@ -1,21 +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 { createAction } from '@reduxjs/toolkit'; -import { Ping, SyntheticsMonitor } from '../../../../../common/runtime_types'; -import { QueryParams } from './api'; -import { createAsyncAction } from '../utils/actions'; - -export const setMonitorSummaryLocationAction = createAction( - '[MONITOR SUMMARY] SET LOCATION' -); - -export const getMonitorStatusAction = createAsyncAction('[MONITOR SUMMARY] GET'); - -export const getSyntheticsMonitorAction = createAsyncAction( - 'fetchSyntheticsMonitorAction' -); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/api.ts deleted file mode 100644 index af01acf97592d..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/api.ts +++ /dev/null @@ -1,29 +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 { SavedObject } from '@kbn/core/types'; -import { apiService } from '../../../../utils/api_service'; -import { Ping, SyntheticsMonitor } from '../../../../../common/runtime_types'; -import { API_URLS, SYNTHETICS_API_URLS } from '../../../../../common/constants'; - -export interface QueryParams { - monitorId: string; - dateStart: string; - dateEnd: string; -} - -export const fetchMonitorStatus = async (params: QueryParams): Promise => { - return await apiService.get(SYNTHETICS_API_URLS.MONITOR_STATUS, { ...params }); -}; - -export const fetchSyntheticsMonitor = async (monitorId: string): Promise => { - const { attributes } = (await apiService.get( - `${API_URLS.SYNTHETICS_MONITORS}/${monitorId}` - )) as SavedObject; - - return attributes; -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/effects.ts deleted file mode 100644 index 9a1b52e1e24df..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/effects.ts +++ /dev/null @@ -1,33 +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 { takeLeading } from 'redux-saga/effects'; -import { fetchEffectFactory } from '../utils/fetch_effect'; -import { getMonitorStatusAction, getSyntheticsMonitorAction } from './actions'; -import { fetchMonitorStatus, fetchSyntheticsMonitor } from './api'; - -export function* fetchMonitorStatusEffect() { - yield takeLeading( - getMonitorStatusAction.get, - fetchEffectFactory( - fetchMonitorStatus, - getMonitorStatusAction.success, - getMonitorStatusAction.fail - ) - ); -} - -export function* fetchSyntheticsMonitorEffect() { - yield takeLeading( - getSyntheticsMonitorAction.get, - fetchEffectFactory( - fetchSyntheticsMonitor, - getSyntheticsMonitorAction.success, - getSyntheticsMonitorAction.fail - ) - ); -} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/index.ts deleted file mode 100644 index 04941c6286211..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/index.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 { createReducer } from '@reduxjs/toolkit'; -import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; - -import { getMonitorStatusAction, setMonitorSummaryLocationAction } from './actions'; -import type { Ping } from '../../../../../common/runtime_types'; - -export interface MonitorSummaryState { - data: Ping | null; - loading: boolean; - error: IHttpFetchError | null; - selectedLocationId: string | null; -} - -const initialState: MonitorSummaryState = { - data: null, - loading: false, - error: null, - selectedLocationId: null, -}; - -export const monitorStatusReducer = createReducer(initialState, (builder) => { - builder - .addCase(setMonitorSummaryLocationAction, (state, action) => { - state.selectedLocationId = action.payload; - }) - .addCase(getMonitorStatusAction.get, (state) => { - state.loading = true; - }) - .addCase(getMonitorStatusAction.success, (state, action) => { - state.data = action.payload; - state.loading = false; - }) - .addCase(getMonitorStatusAction.fail, (state, action) => { - state.error = action.payload as IHttpFetchError; - state.loading = false; - }); -}); - -export * from './actions'; -export * from './effects'; -export * from './selectors'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/synthetics_montior_reducer.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/synthetics_montior_reducer.ts deleted file mode 100644 index 6fa133842f4fc..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_summary/synthetics_montior_reducer.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createReducer } from '@reduxjs/toolkit'; -import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; - -import type { SyntheticsMonitor } from '../../../../../common/runtime_types'; -import { getSyntheticsMonitorAction } from './actions'; - -export interface SyntheticsMonitorState { - data: SyntheticsMonitor | null; - loading: boolean; - error: IHttpFetchError | null; -} - -const initialState: SyntheticsMonitorState = { - data: null, - loading: false, - error: null, -}; - -export const syntheticsMonitorReducer = createReducer(initialState, (builder) => { - builder - .addCase(getSyntheticsMonitorAction.get, (state) => { - state.loading = true; - }) - .addCase(getSyntheticsMonitorAction.success, (state, action) => { - state.data = action.payload; - state.loading = false; - }) - .addCase(getSyntheticsMonitorAction.fail, (state, action) => { - state.error = action.payload as IHttpFetchError; - state.loading = false; - }); -}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts index 389b21ea5ea1b..1345f6c2b4c39 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts @@ -6,12 +6,13 @@ */ import { all, fork } from 'redux-saga/effects'; -import { fetchMonitorStatusEffect, fetchSyntheticsMonitorEffect } from './monitor_summary'; +import { fetchSyntheticsMonitorEffect } from './monitor_details'; import { fetchIndexStatusEffect } from './index_status'; import { fetchSyntheticsEnablementEffect } from './synthetics_enablement'; import { fetchMonitorListEffect, upsertMonitorEffect } from './monitor_list'; import { fetchMonitorOverviewEffect, quietFetchOverviewEffect } from './overview'; import { fetchServiceLocationsEffect } from './service_locations'; +import { browserJourneyEffects } from './browser_journey'; export const rootEffect = function* root(): Generator { yield all([ @@ -20,9 +21,9 @@ export const rootEffect = function* root(): Generator { fork(upsertMonitorEffect), fork(fetchServiceLocationsEffect), fork(fetchMonitorListEffect), - fork(fetchMonitorStatusEffect), fork(fetchSyntheticsMonitorEffect), fork(fetchMonitorOverviewEffect), fork(quietFetchOverviewEffect), + fork(browserJourneyEffects), ]); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts index 34e036bf6fac4..c83605ffad1f8 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts @@ -7,17 +7,15 @@ import { combineReducers } from '@reduxjs/toolkit'; -import { - syntheticsMonitorReducer, - SyntheticsMonitorState, -} from './monitor_summary/synthetics_montior_reducer'; -import { monitorStatusReducer, MonitorSummaryState } from './monitor_summary'; +import { monitorDetailsReducer, MonitorDetailsState } from './monitor_details'; import { uiReducer, UiState } from './ui'; import { indexStatusReducer, IndexStatusState } from './index_status'; import { syntheticsEnablementReducer, SyntheticsEnablementState } from './synthetics_enablement'; import { monitorListReducer, MonitorListState } from './monitor_list'; import { serviceLocationsReducer, ServiceLocationsState } from './service_locations'; import { monitorOverviewReducer, MonitorOverviewState } from './overview'; +import { BrowserJourneyState } from './browser_journey/models'; +import { browserJourneyReducer } from './browser_journey'; export interface SyntheticsAppState { ui: UiState; @@ -25,9 +23,9 @@ export interface SyntheticsAppState { syntheticsEnablement: SyntheticsEnablementState; monitorList: MonitorListState; serviceLocations: ServiceLocationsState; - monitorStatus: MonitorSummaryState; - syntheticsMonitor: SyntheticsMonitorState; + monitorDetails: MonitorDetailsState; overview: MonitorOverviewState; + browserJourney: BrowserJourneyState; } export const rootReducer = combineReducers({ @@ -36,7 +34,7 @@ export const rootReducer = combineReducers({ syntheticsEnablement: syntheticsEnablementReducer, monitorList: monitorListReducer, serviceLocations: serviceLocationsReducer, - monitorStatus: monitorStatusReducer, - syntheticsMonitor: syntheticsMonitorReducer, + monitorDetails: monitorDetailsReducer, overview: monitorOverviewReducer, + browserJourney: browserJourneyReducer, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/utils/http_error.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/utils/http_error.ts index 34c5a021c5c03..5c296eedb79f9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/utils/http_error.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/utils/http_error.ts @@ -18,6 +18,18 @@ export interface IHttpSerializedFetchError { } export const serializeHttpFetchError = (error: IHttpFetchError): IHttpSerializedFetchError => { + if (error.name && !error.body) { + return { + name: error.name, + body: { + error: error.toString(), + message: error.message, + statusCode: undefined, + }, + requestUrl: error?.request?.url, + }; + } + const body = error.body as { error: string; message: string; statusCode: number }; return { name: error.name, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts new file mode 100644 index 0000000000000..043aefbac819b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.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 { EncryptedSyntheticsSavedMonitor, Ping } from '../../../../../common/runtime_types'; + +/** + * Checks if the loaded/cached pings are of the current selected monitors + */ +export function checkIsStalePing( + monitor: EncryptedSyntheticsSavedMonitor | null, + ping: Ping | undefined +) { + if (!monitor?.id || !ping?.monitor?.id) { + return true; + } + + return monitor.id !== ping.monitor.id && monitor.id !== ping.config_id; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.test.ts new file mode 100644 index 0000000000000..8d91afc3c9937 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { ScreenshotRefImageData } from '../../../../../common/runtime_types'; +import { composeScreenshotRef } from './compose_screenshot_images'; + +describe('composeScreenshotRef', () => { + let getContextMock: jest.Mock; + let drawImageMock: jest.Mock; + let ref: ScreenshotRefImageData; + let contextMock: unknown; + + beforeEach(() => { + drawImageMock = jest.fn(); + contextMock = { + drawImage: drawImageMock, + }; + getContextMock = jest.fn().mockReturnValue(contextMock); + ref = { + stepName: 'step', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '123', + monitor: { check_group: 'check-group' }, + screenshot_ref: { + blocks: [ + { + hash: '123', + top: 0, + left: 0, + width: 10, + height: 10, + }, + ], + height: 100, + width: 100, + }, + synthetics: { + package_version: 'v1', + step: { + name: 'step-name', + index: 0, + }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + }); + + it('throws error when blob does not exist', async () => { + try { + // @ts-expect-error incomplete invocation for test + await composeScreenshotRef(ref, { getContext: getContextMock }, {}); + } catch (e: any) { + expect(e).toMatchInlineSnapshot( + `[Error: Error processing image. Expected image data with hash 123 is missing]` + ); + expect(getContextMock).toHaveBeenCalled(); + expect(getContextMock.mock.calls[0][0]).toBe('2d'); + expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false }); + } + }); + + it('throws error when block is pending', async () => { + try { + await composeScreenshotRef( + ref, + // @ts-expect-error incomplete invocation for test + { getContext: getContextMock }, + { '123': { status: 'pending' } } + ); + } catch (e: any) { + expect(e).toMatchInlineSnapshot( + `[Error: Error processing image. Expected image data with hash 123 is missing]` + ); + expect(getContextMock).toHaveBeenCalled(); + expect(getContextMock.mock.calls[0][0]).toBe('2d'); + expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false }); + } + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.ts new file mode 100644 index 0000000000000..ed9f4836d9b88 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/compose_screenshot_images.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isScreenshotBlockDoc, + ScreenshotRefImageData, + ScreenshotBlockCache, +} from '../../../../../common/runtime_types'; + +/** + * Draws image fragments on a canvas. + * @param data Contains overall image size, fragment dimensions, and the blobs of image data to render. + * @param canvas A canvas to use for the rendering. + * @returns A promise that will resolve when the final draw operation completes. + */ +export async function composeScreenshotRef( + data: ScreenshotRefImageData, + canvas: HTMLCanvasElement, + blocks: ScreenshotBlockCache +) { + const { + ref: { screenshotRef }, + } = data; + + const ctx = canvas.getContext('2d', { alpha: false }); + + canvas.width = screenshotRef.screenshot_ref.width; + canvas.height = screenshotRef.screenshot_ref.height; + + /** + * We need to treat each operation as an async task, otherwise we will race between drawing image + * chunks and extracting the final data URL from the canvas; without this, the image could be blank or incomplete. + */ + const drawOperations: Array> = []; + + for (const { hash, top, left, width, height } of screenshotRef.screenshot_ref.blocks) { + drawOperations.push( + new Promise((resolve, reject) => { + const img = new Image(); + const blob = blocks[hash]; + if (!blob || !isScreenshotBlockDoc(blob)) { + reject(Error(`Error processing image. Expected image data with hash ${hash} is missing`)); + } else { + img.onload = () => { + ctx?.drawImage(img, left, top, width, height); + resolve(); + }; + img.src = `data:image/jpg;base64,${blob.synthetics.blob}`; + } + }) + ); + } + + // once all `draw` operations finish, caller can extract img string + return Promise.all(drawOperations); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/sort_pings.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/sort_pings.ts new file mode 100644 index 0000000000000..7922e046dfbaf --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/sort_pings.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get as getProp } from 'lodash'; +import { Ping } from '../../../../../common/runtime_types'; + +export function sortPings(pings: Ping[], sortField: string, sortDirection: 'asc' | 'desc') { + const toSort = [...pings]; + toSort.sort((a, b) => { + let propA = getProp(a, sortField) ?? null; + let propB = getProp(b, sortField) ?? null; + + if (propA === null || propB === null) { + return 0; + } + + if (sortField === 'timestamp') { + propA = new Date(propA); + propB = new Date(propB); + } + + if (sortField === 'monitor.status') { + propA = propA === 'up' ? -1 : 1; + propB = propB === 'up' ? -1 : 1; + } + + if (typeof propA === 'string') { + return sortDirection === 'asc' ? propA.localeCompare(propB) : propB.localeCompare(propA); + } + + return sortDirection === 'asc' ? propA - propB : propB - propA; + }); + + return toSort; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/test_time_formats.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/test_time_formats.ts new file mode 100644 index 0000000000000..005872224b08e --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/test_time_formats.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 moment from 'moment'; +import { i18n } from '@kbn/i18n'; + +/** + * Formats the microseconds (µ) into either milliseconds (ms) or seconds (s) based on the duration value + * @param us {number} duration value in microseconds + */ +export const formatTestDuration = (us?: number) => { + const microSecs = us ?? 0; + const secs = microSecs / (1000 * 1000); + if (secs >= 1) { + return `${secs.toFixed(1)} s`; + } + + return `${(microSecs / 1000).toFixed(0)} ms`; +}; + +export function formatTestRunAt(timestamp: string) { + const stampedMoment = moment(timestamp); + const startOfToday = moment().startOf('day'); + const startOfYesterday = moment().add(-1, 'day'); + + const dateStr = + stampedMoment > startOfToday + ? `${TODAY_LABEL}` + : stampedMoment > startOfYesterday + ? `${YESTERDAY_LABEL}` + : `${stampedMoment.format('ll')} `; + + const timeStr = stampedMoment.format('HH:mm:ss'); + + return `${dateStr} @ ${timeStr}`; +} + +const TODAY_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.today', { + defaultMessage: 'Today', +}); + +const YESTERDAY_LABEL = i18n.translate('xpack.synthetics.monitorDetails.summary.yesterday', { + defaultMessage: 'Yesterday', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/timestamp.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/timestamp.ts new file mode 100644 index 0000000000000..c9c4d022b869d --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/timestamp.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 moment from 'moment'; +import { SHORT_TIMESPAN_LOCALE, SHORT_TS_LOCALE } from '../../../../../common/constants'; + +export const parseTimestamp = (tsValue: string): moment.Moment => { + let parsed = Date.parse(tsValue); + if (isNaN(parsed)) { + parsed = parseInt(tsValue, 10); + } + return moment(parsed); +}; + +export const getShortTimeStamp = (timeStamp: moment.Moment, relative = false) => { + if (relative) { + const prevLocale: string = moment.locale() ?? 'en'; + + const shortLocale = moment.locale(SHORT_TS_LOCALE) === SHORT_TS_LOCALE; + + if (!shortLocale) { + moment.defineLocale(SHORT_TS_LOCALE, SHORT_TIMESPAN_LOCALE); + } + + let shortTimestamp; + if (typeof (timeStamp as unknown) === 'string') { + shortTimestamp = parseTimestamp(timeStamp as unknown as string).fromNow(); + } else { + shortTimestamp = timeStamp.fromNow(); + } + + // Reset it so, it doesn't impact other part of the app + moment.locale(prevLocale); + return shortTimestamp; + } else { + if (moment().diff(timeStamp, 'd') >= 1) { + return timeStamp.format('ll LTS'); + } + return timeStamp.format('LTS'); + } +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/screenshot_ref.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/screenshot_ref.mock.ts new file mode 100644 index 0000000000000..f704c3309c1f9 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/screenshot_ref.mock.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ScreenshotRefImageData } from '../../../../../../common/runtime_types'; + +export const mockRef: ScreenshotRefImageData = { + maxSteps: 1, + stepName: 'load homepage', + ref: { + screenshotRef: { + '@timestamp': '2021-06-08T19:42:30.257Z', + synthetics: { + package_version: '1.0.0-beta.2', + step: { name: 'load homepage', index: 1 }, + type: 'step/screenshot_ref', + }, + screenshot_ref: { + blocks: [ + { + top: 0, + left: 0, + width: 160, + hash: 'd518801fc523cf02727cd520f556c4113b3098c7', + height: 90, + }, + { + top: 0, + left: 160, + width: 160, + hash: 'fa90345d5d7b05b1601e9ee645e663bc358869e0', + height: 90, + }, + ], + width: 1280, + height: 720, + }, + monitor: { check_group: 'a567cc7a-c891-11eb-bdf9-3e22fb19bf97' }, + }, + }, +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index a91c95f89abc8..57ce5e39a8dbd 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -8,8 +8,11 @@ import { SyntheticsAppState } from '../../../state/root_reducer'; import { ConfigKey, + DataStream, DEFAULT_THROTTLING, LocationStatus, + ScheduleUnit, + SourceType, } from '../../../../../../common/runtime_types'; /** @@ -93,15 +96,255 @@ export const mockState: SyntheticsAppState = { loading: false, }, syntheticsEnablement: { loading: false, error: null, enablement: null }, - monitorStatus: { - data: null, - loading: false, - error: null, - selectedLocationId: null, - }, - syntheticsMonitor: { - data: null, + monitorDetails: getMonitorDetailsMockSlice(), + browserJourney: getBrowserJourneyMockSlice(), +}; + +function getBrowserJourneyMockSlice() { + return { + blocks: { + '4bae236101175ae7746cb922f4c511083af4fbcd': { + id: '4bae236101175ae7746cb922f4c511083af4fbcd', + synthetics: { + blob: '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//9k=', + blob_mime: 'image/jpeg', + }, + }, + ec95c047e2e05a27598451fdaa7f24db973eb933: { + id: 'ec95c047e2e05a27598451fdaa7f24db973eb933', + synthetics: { + blob: '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAMI/8QAGhABAAMBAQEAAAAAAAAAAAAAAAECAwQRIf/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwDNfT0bdXTr0dWum3RtedNNdLTa17TPs2mZ+zMz99TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/2Q==', + blob_mime: 'image/jpeg', + }, + }, + }, + cacheSize: 0, + hitCount: [ + { hash: '4bae236101175ae7746cb922f4c511083af4fbcd', hitTime: 1658682270849 }, + { hash: 'ec95c047e2e05a27598451fdaa7f24db973eb933', hitTime: 1658682270849 }, + ], + }; +} + +function getMonitorDetailsMockSlice() { + return { + pings: [ + { + summary: { up: 1, down: 0 }, + agent: { + name: 'cron-b010e1cc9518984e-27644714-4pd4h', + id: 'f8721d90-5aec-4815-a6f1-f4d4a6fb7482', + type: 'heartbeat', + ephemeral_id: 'd6a60494-5e52-418f-922b-8e90f0b4013c', + version: '8.3.0', + }, + synthetics: { + journey: { name: 'inline', id: 'inline', tags: null }, + type: 'heartbeat/summary', + }, + monitor: { + duration: { us: 269722 }, + origin: SourceType.UI, + name: 'One pixel monitor', + check_group: '051aba1c-0b74-11ed-9f0e-ba4e6fa109d5', + id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + timespan: { lt: '2022-07-24T17:24:06.094Z', gte: '2022-07-24T17:14:06.094Z' }, + type: DataStream.BROWSER, + status: 'up', + }, + url: { + scheme: 'data', + domain: '', + full: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + }, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'cron-b010e1cc9518984e-27644714-4pd4h', + ip: ['10.1.11.162'], + mac: ['ba:4e:6f:a1:09:d5'], + }, + '@timestamp': '2022-07-24T17:14:05.079Z', + ecs: { version: '8.0.0' }, + config_id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + 'event.type': 'journey/end', + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-07-24T17:14:07Z', + type: 'heartbeat/summary', + dataset: 'browser', + }, + timestamp: '2022-07-24T17:14:05.079Z', + docId: 'AkYzMYIBqL6WCtugsFck', + }, + { + summary: { up: 1, down: 0 }, + agent: { + name: 'cron-b010e1cc9518984e-27644704-zs98t', + id: 'a9620214-591d-48e7-9e5d-10b7a9fb1a03', + type: 'heartbeat', + ephemeral_id: 'c5110885-81b4-4e9a-8747-690d19fbd225', + version: '8.3.0', + }, + synthetics: { + journey: { name: 'inline', id: 'inline', tags: null }, + type: 'heartbeat/summary', + }, + monitor: { + duration: { us: 227326 }, + origin: SourceType.UI, + name: 'One pixel monitor', + id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + check_group: '9eb87e53-0b72-11ed-b34f-aa618b4334ae', + timespan: { lt: '2022-07-24T17:14:05.020Z', gte: '2022-07-24T17:04:05.020Z' }, + type: DataStream.BROWSER, + status: 'up', + }, + url: { + scheme: 'data', + domain: '', + full: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + }, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'cron-b010e1cc9518984e-27644704-zs98t', + ip: ['10.1.9.133'], + mac: ['aa:61:8b:43:34:ae'], + }, + '@timestamp': '2022-07-24T17:04:03.769Z', + ecs: { version: '8.0.0' }, + config_id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + 'event.type': 'journey/end', + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-07-24T17:04:06Z', + type: 'heartbeat/summary', + dataset: 'browser', + }, + timestamp: '2022-07-24T17:04:03.769Z', + docId: 'mkYqMYIBqL6WCtughFUq', + }, + { + summary: { up: 1, down: 0 }, + agent: { + name: 'job-b010e1cc9518984e-dkw5k', + id: 'e3a4e3a8-bdd1-44fe-86f5-e451b80f80c5', + type: 'heartbeat', + ephemeral_id: 'f41a13ab-a85d-4614-89c0-8dbad6a32868', + version: '8.3.0', + }, + synthetics: { + journey: { name: 'inline', id: 'inline', tags: null }, + type: 'heartbeat/summary', + }, + monitor: { + duration: { us: 207700 }, + origin: SourceType.UI, + name: 'One pixel monitor', + timespan: { lt: '2022-07-24T17:11:49.702Z', gte: '2022-07-24T17:01:49.702Z' }, + check_group: '4e00ac5a-0b72-11ed-a97e-5203642c687d', + id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + type: DataStream.BROWSER, + status: 'up', + }, + url: { + scheme: 'data', + domain: '', + full: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + }, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'job-b010e1cc9518984e-dkw5k', + ip: ['10.1.9.132'], + mac: ['52:03:64:2c:68:7d'], + }, + '@timestamp': '2022-07-24T17:01:48.326Z', + ecs: { version: '8.0.0' }, + config_id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + 'event.type': 'journey/end', + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-07-24T17:01:50Z', + type: 'heartbeat/summary', + dataset: 'browser', + }, + timestamp: '2022-07-24T17:01:48.326Z', + docId: 'kUYoMYIBqL6WCtugc1We', + }, + ], loading: false, + syntheticsMonitor: { + id: '4afd3980-0b72-11ed-9c10-b57918ea89d6', + type: DataStream.BROWSER, + enabled: true, + schedule: { unit: ScheduleUnit.MINUTES, number: '10' }, + 'service.name': '', + tags: [], + timeout: null, + name: 'One pixel monitor', + locations: [{ isServiceManaged: true, id: 'us_central' }], + namespace: 'default', + origin: SourceType.UI, + journey_id: '', + project_id: '', + playwright_options: '', + __ui: { + script_source: { is_generated_script: false, file_name: '' }, + is_zip_url_tls_enabled: false, + is_tls_enabled: false, + }, + params: '', + 'url.port': null, + 'source.inline.script': + "step('Goto one pixel image', async () => {\\n await page.goto('data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');\\n});", + 'source.project.content': '', + 'source.zip_url.url': '', + 'source.zip_url.username': '', + 'source.zip_url.password': '', + 'source.zip_url.folder': '', + 'source.zip_url.proxy_url': '', + urls: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + screenshots: 'on', + synthetics_args: [], + 'filter_journeys.match': '', + 'filter_journeys.tags': [], + ignore_https_errors: false, + 'throttling.is_enabled': true, + 'throttling.download_speed': '5', + 'throttling.upload_speed': '3', + 'throttling.latency': '20', + 'throttling.config': '5d/3u/20l', + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.key': '', + 'ssl.key_passphrase': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 1, + updated_at: '2022-07-24T17:15:46.342Z', + }, + syntheticsMonitorLoading: false, error: null, - }, -}; + selectedLocationId: 'us_central', + }; +} diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/use_composite_image.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/use_composite_image.mock.ts similarity index 76% rename from x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/use_composite_image.mock.ts rename to x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/use_composite_image.mock.ts index 64bc0776b8207..1e2aafff28dca 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/use_composite_image.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/use_composite_image.mock.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ScreenshotRefImageData } from '../../../../common/runtime_types/ping/synthetics'; -import * as composeScreenshotImages from '../../hooks/use_composite_image'; +import { ScreenshotRefImageData } from '../../../../../../common/runtime_types'; +import * as composeScreenshotImages from '../../../hooks/use_composite_image'; jest .spyOn(composeScreenshotImages, 'useCompositeImage') diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx index 6a429d98756af..3e2225af63a1a 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx @@ -13,8 +13,8 @@ import { render } from '../../../../../lib/helper/rtl_helpers'; import * as observabilityPublic from '@kbn/observability-plugin/public'; import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; import moment from 'moment'; -import '../../../../../lib/__mocks__/use_composite_image.mock'; -import { mockRef } from '../../../../../lib/__mocks__/screenshot_ref.mock'; +import '../../../../../lib/__mocks__/legacy_use_composite_image.mock'; +import { mockRef } from '../../../../../lib/__mocks__/legacy_screenshot_ref.mock'; jest.mock('@kbn/observability-plugin/public'); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx index d52c9ab0c896a..18df82473468b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx @@ -11,7 +11,7 @@ import { render } from '../../../../../lib/helper/rtl_helpers'; import { StepImageCaption, StepImageCaptionProps } from './step_image_caption'; import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; import moment from 'moment'; -import { mockRef } from '../../../../../lib/__mocks__/screenshot_ref.mock'; +import { mockRef } from '../../../../../lib/__mocks__/legacy_screenshot_ref.mock'; describe('StepImageCaption', () => { let defaultProps: StepImageCaptionProps; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/synthetics/step_screenshot_display.test.tsx index 83e1a4e938650..cbfd48e31788b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/synthetics/step_screenshot_display.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/synthetics/step_screenshot_display.test.tsx @@ -9,8 +9,8 @@ import React from 'react'; import { StepScreenshotDisplay } from './step_screenshot_display'; import { render } from '../../lib/helper/rtl_helpers'; import * as observabilityPublic from '@kbn/observability-plugin/public'; -import '../../lib/__mocks__/use_composite_image.mock'; -import { mockRef } from '../../lib/__mocks__/screenshot_ref.mock'; +import '../../lib/__mocks__/legacy_use_composite_image.mock'; +import { mockRef } from '../../lib/__mocks__/legacy_screenshot_ref.mock'; jest.mock('@kbn/observability-plugin/public'); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.test.tsx index 50fc366f50dbe..d26342aca54c5 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.test.tsx @@ -7,8 +7,7 @@ import * as redux from 'react-redux'; import { renderHook } from '@testing-library/react-hooks'; -import { ScreenshotRefImageData } from '../../../common/runtime_types'; -import { ScreenshotBlockCache } from '../state/reducers/synthetics'; +import { ScreenshotRefImageData, ScreenshotBlockCache } from '../../../common/runtime_types'; import { shouldCompose, useCompositeImage } from './use_composite_image'; import * as compose from '../lib/helper/compose_screenshot_images'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.ts b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.ts index ca783bdd290c4..9978a6c920d77 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_composite_image.ts @@ -8,13 +8,13 @@ import { useDispatch, useSelector } from 'react-redux'; import React from 'react'; import { composeScreenshotRef } from '../lib/helper/compose_screenshot_images'; -import { ScreenshotRefImageData } from '../../../common/runtime_types/ping/synthetics'; import { - fetchBlocksAction, - isPendingBlock, + ScreenshotRefImageData, ScreenshotBlockCache, StoreScreenshotBlock, -} from '../state/reducers/synthetics'; + isPendingBlock, +} from '../../../common/runtime_types'; +import { fetchBlocksAction } from '../state/reducers/synthetics'; import { syntheticsSelector } from '../state/selectors'; function allBlocksLoaded(blocks: { [key: string]: StoreScreenshotBlock }, hashes: string[]) { diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/screenshot_ref.mock.ts b/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/legacy_screenshot_ref.mock.ts similarity index 100% rename from x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/screenshot_ref.mock.ts rename to x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/legacy_screenshot_ref.mock.ts diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/legacy_use_composite_image.mock.ts b/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/legacy_use_composite_image.mock.ts new file mode 100644 index 0000000000000..01a2e75b9a5ff --- /dev/null +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/__mocks__/legacy_use_composite_image.mock.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 { ScreenshotRefImageData } from '../../../../common/runtime_types'; +import * as composeScreenshotImages from '../../hooks/use_composite_image'; + +jest + .spyOn(composeScreenshotImages, 'useCompositeImage') + .mockImplementation( + ( + _imgRef: ScreenshotRefImageData, + callback: React.Dispatch, + url?: string + ) => { + if (!url) { + callback('img src'); + } + } + ); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/helper/compose_screenshot_images.ts b/x-pack/plugins/synthetics/public/legacy_uptime/lib/helper/compose_screenshot_images.ts index 86c7a001b95ab..ea9593ee1b0b7 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/helper/compose_screenshot_images.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/helper/compose_screenshot_images.ts @@ -8,8 +8,8 @@ import { isScreenshotBlockDoc, ScreenshotRefImageData, -} from '../../../../common/runtime_types/ping/synthetics'; -import { ScreenshotBlockCache } from '../../state/reducers/synthetics'; + ScreenshotBlockCache, +} from '../../../../common/runtime_types'; /** * Draws image fragments on a canvas. diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/monitor_management.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/monitor_management.ts index 02857fafb69a9..67ac37e4b101d 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/monitor_management.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/monitor_management.ts @@ -35,7 +35,7 @@ export const setMonitor = async ({ id?: string; }): Promise<{ attributes: { errors: ServiceLocationErrors } } | SyntheticsMonitor> => { if (id) { - return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${id}`); + return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${id}`, monitor); } else { return await apiService.post(API_URLS.SYNTHETICS_MONITORS, monitor, undefined, { preserve_namespace: true, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/effects/synthetic_journey_blocks.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/effects/synthetic_journey_blocks.ts index 6ffbeb6978f75..9c020db333bb9 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/effects/synthetic_journey_blocks.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/effects/synthetic_journey_blocks.ts @@ -7,16 +7,18 @@ import { Action } from 'redux-actions'; import { call, fork, put, select, takeEvery, throttle } from 'redux-saga/effects'; -import { ScreenshotBlockDoc } from '../../../../common/runtime_types/ping/synthetics'; +import { + ScreenshotBlockDoc, + ScreenshotBlockCache, + isPendingBlock, +} from '../../../../common/runtime_types'; import { fetchScreenshotBlockSet } from '../api/journey'; import { fetchBlocksAction, setBlockLoadingAction, - isPendingBlock, pruneCacheAction, putBlocksAction, putCacheSize, - ScreenshotBlockCache, updateHitCountsAction, } from '../reducers/synthetics'; import { syntheticsSelector } from '../selectors'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.test.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.test.ts index 06d738d01b42f..1e38c89dc8208 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.test.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.test.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { isPendingBlock } from '../../../../common/runtime_types'; import { fetchBlocksAction, - isPendingBlock, pruneCacheAction, setBlockLoadingAction, putBlocksAction, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.ts index 2a0cf7188a9e8..c523e72b64977 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/reducers/synthetics.ts @@ -9,20 +9,9 @@ import { createAction, handleActions, Action } from 'redux-actions'; import { isScreenshotBlockDoc, ScreenshotBlockDoc, + ScreenshotBlockCache, } from '../../../../common/runtime_types/ping/synthetics'; -export interface PendingBlock { - status: 'pending' | 'loading'; -} - -export function isPendingBlock(data: unknown): data is PendingBlock { - return ['pending', 'loading'].some((s) => s === (data as PendingBlock)?.status); -} -export type StoreScreenshotBlock = ScreenshotBlockDoc | PendingBlock; -export interface ScreenshotBlockCache { - [hash: string]: StoreScreenshotBlock; -} - export interface CacheHitCount { hash: string; hitTime: number; diff --git a/x-pack/plugins/synthetics/server/common/pings/query_pings.ts b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts new file mode 100644 index 0000000000000..b6d1b42923928 --- /dev/null +++ b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { UMElasticsearchQueryFn } from '../../legacy_uptime/lib/adapters/framework'; +import { + GetPingsParams, + HttpResponseBody, + PingsResponse, + Ping, +} from '../../../common/runtime_types'; + +const DEFAULT_PAGE_SIZE = 25; + +/** + * This branch of filtering is used for monitors of type `browser`. This monitor + * type represents an unbounded set of steps, with each `check_group` representing + * a distinct journey. The document containing the `summary` field is indexed last, and + * contains the data necessary for querying a journey. + * + * Because of this, when querying for "pings", it is important that we treat `browser` summary + * checks as the "ping" we want. Without this filtering, we will receive >= N pings for a journey + * of N steps, because an individual step may also contain multiple documents. + */ +const REMOVE_NON_SUMMARY_BROWSER_CHECKS = { + must_not: [ + { + bool: { + filter: [ + { + term: { + 'monitor.type': 'browser', + }, + }, + { + bool: { + must_not: [ + { + exists: { + field: 'summary', + }, + }, + ], + }, + }, + ], + }, + }, + ], +}; + +function isStringArray(value: unknown): value is string[] { + if (!Array.isArray(value)) return false; + // are all array items strings + if (!value.some((s) => typeof s !== 'string')) return true; + throw Error('Excluded locations can only be strings'); +} + +export const queryPings: UMElasticsearchQueryFn = async ({ + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size: sizeParam, + locations, + excludedLocations, +}) => { + const size = sizeParam ?? DEFAULT_PAGE_SIZE; + + const searchBody = { + size, + ...(index ? { from: index * size } : {}), + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: from, lte: to } } }, + ...(monitorId ? [{ term: { config_id: monitorId } }] : []), + ...(status ? [{ term: { 'monitor.status': status } }] : []), + ] as QueryDslQueryContainer[], + ...REMOVE_NON_SUMMARY_BROWSER_CHECKS, + }, + }, + sort: [{ '@timestamp': { order: (sort ?? 'desc') as 'asc' | 'desc' } }], + ...((locations ?? []).length > 0 + ? { post_filter: { terms: { 'observer.geo.name': locations as unknown as string[] } } } + : {}), + }; + + // if there are excluded locations, add a clause to the query's filter + const excludedLocationsArray: unknown = excludedLocations && JSON.parse(excludedLocations); + if (isStringArray(excludedLocationsArray) && excludedLocationsArray.length > 0) { + searchBody.query.bool.filter.push({ + bool: { + must_not: [ + { + terms: { + 'observer.geo.name': excludedLocationsArray, + }, + }, + ], + }, + }); + } + + const { + body: { + hits: { hits, total }, + }, + } = await uptimeEsClient.search({ body: searchBody }); + + const pings: Ping[] = hits.map((doc: any) => { + const { _id, _source } = doc; + // Calculate here the length of the content string in bytes, this is easier than in client JS, where + // we don't have access to Buffer.byteLength. There are some hacky ways to do this in the + // client but this is cleaner. + const httpBody: HttpResponseBody | undefined = _source?.http?.response?.body; + if (httpBody && httpBody.content) { + httpBody.content_bytes = Buffer.byteLength(httpBody.content); + } + + return { ..._source, timestamp: _source['@timestamp'], docId: _id }; + }); + + return { + total: total.value, + pings, + }; +}; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_journey_screenshot_blocks.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_journey_screenshot_blocks.ts index 1de6df8e8dfd9..954fa1d76e113 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_journey_screenshot_blocks.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_journey_screenshot_blocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ScreenshotBlockDoc } from '../../../../common/runtime_types/ping/synthetics'; +import { ScreenshotBlockDoc } from '../../../../common/runtime_types'; import { UMElasticsearchQueryFn } from '../adapters/framework'; interface ScreenshotBlockResultType { diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/routes/pings/journeys.ts b/x-pack/plugins/synthetics/server/legacy_uptime/routes/pings/journeys.ts index cd72875cf85fd..2e9be1ddf3f74 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/routes/pings/journeys.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/routes/pings/journeys.ts @@ -12,7 +12,7 @@ import { API_URLS } from '../../../../common/constants'; export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: API_URLS.JOURNEY_CREATE, + path: API_URLS.JOURNEY, validate: { params: schema.object({ checkGroup: schema.string(), diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 4190943a1acf2..6e60de0848706 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { createGetMonitorStatusRoute } from './monitor_summary/monitor_status'; import { getAPIKeySyntheticsRoute } from './monitor_cruds/get_api_key'; import { getServiceLocationsRoute } from './synthetics_service/get_service_locations'; import { deleteSyntheticsMonitorRoute } from './monitor_cruds/delete_monitor'; @@ -26,6 +25,7 @@ import { installIndexTemplatesRoute } from './synthetics_service/install_index_t import { editSyntheticsMonitorRoute } from './monitor_cruds/edit_monitor'; import { addSyntheticsMonitorRoute } from './monitor_cruds/add_monitor'; import { addSyntheticsProjectMonitorRoute } from './monitor_cruds/add_monitor_project'; +import { syntheticsGetPingsRoute } from './pings'; import { SyntheticsRestApiRouteFactory, SyntheticsStreamingRouteFactory, @@ -47,7 +47,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ testNowMonitorRoute, getServiceAllowedRoute, getAPIKeySyntheticsRoute, - createGetMonitorStatusRoute, + syntheticsGetPingsRoute, ]; export const syntheticsAppStreamingApiRoutes: SyntheticsStreamingRouteFactory[] = [ diff --git a/x-pack/plugins/synthetics/server/routes/monitor_summary/monitor_status.ts b/x-pack/plugins/synthetics/server/routes/monitor_summary/monitor_status.ts deleted file mode 100644 index 885bd60bf452a..0000000000000 --- a/x-pack/plugins/synthetics/server/routes/monitor_summary/monitor_status.ts +++ /dev/null @@ -1,82 +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 { schema, TypeOf } from '@kbn/config-schema'; -import { UMServerLibs } from '../../legacy_uptime/uptime_server'; -import { syntheticsMonitorType } from '../../../common/types/saved_objects'; -import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes'; -import { SYNTHETICS_API_URLS } from '../../../common/constants'; -import { ConfigKey, MonitorFields } from '../../../common/runtime_types'; - -const queryParams = schema.object({ - monitorId: schema.string(), - dateStart: schema.string(), - dateEnd: schema.string(), -}); - -type QueryParams = TypeOf; - -export const createGetMonitorStatusRoute: SyntheticsRestApiRouteFactory = (libs: UMServerLibs) => ({ - method: 'GET', - path: SYNTHETICS_API_URLS.MONITOR_STATUS, - validate: { - query: queryParams, - }, - handler: async ({ uptimeEsClient, request, server, savedObjectsClient }): Promise => { - const { monitorId, dateStart, dateEnd } = request.query as QueryParams; - - const latestMonitor = await libs.requests.getLatestMonitor({ - uptimeEsClient, - monitorId, - dateStart, - dateEnd, - }); - - if (latestMonitor.docId) { - return latestMonitor; - } - - if (!server.savedObjectsClient) { - return null; - } - - try { - const { - saved_objects: [monitorSavedObject], - } = await savedObjectsClient.find({ - type: syntheticsMonitorType, - perPage: 1, - page: 1, - filter: `${syntheticsMonitorType}.id: "${syntheticsMonitorType}:${monitorId}" OR ${syntheticsMonitorType}.attributes.${ConfigKey.CUSTOM_HEARTBEAT_ID}: "${monitorId}"`, - }); - - if (!monitorSavedObject) { - return null; - } - - const { - [ConfigKey.URLS]: url, - [ConfigKey.NAME]: name, - [ConfigKey.HOSTS]: host, - [ConfigKey.MONITOR_TYPE]: type, - } = monitorSavedObject.attributes as Partial; - - return { - url: { - full: url || host, - }, - monitor: { - name, - type, - id: monitorSavedObject.id, - }, - }; - } catch (e) { - server.logger.error(e); - } - }, -}); diff --git a/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts new file mode 100644 index 0000000000000..e94c928caed53 --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { UMServerLibs } from '../../legacy_uptime/lib/lib'; +import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types'; +import { queryPings } from '../../common/pings/query_pings'; + +export const syntheticsGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: SYNTHETICS_API_URLS.PINGS, + validate: { + query: schema.object({ + from: schema.string(), + to: schema.string(), + locations: schema.maybe(schema.string()), + excludedLocations: schema.maybe(schema.string()), + monitorId: schema.maybe(schema.string()), + index: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + status: schema.maybe(schema.string()), + }), + }, + handler: async ({ uptimeEsClient, request, response }): Promise => { + const { from, to, index, monitorId, status, sort, size, locations, excludedLocations } = + request.query; + + return await queryPings({ + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size, + locations: locations ? JSON.parse(locations) : [], + excludedLocations, + }); + }, +}); diff --git a/x-pack/plugins/synthetics/server/routes/pings/index.ts b/x-pack/plugins/synthetics/server/routes/pings/index.ts new file mode 100644 index 0000000000000..7bc2a27c155bb --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/pings/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { syntheticsGetPingsRoute } from './get_pings'; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 23f3a1dbd18c9..0c389000f6c80 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -4644,6 +4644,19 @@ "properties": { "all": { "properties": { + "assignees": { + "properties": { + "total": { + "type": "long" + }, + "totalWithZero": { + "type": "long" + }, + "totalWithAtLeastOne": { + "type": "long" + } + } + }, "total": { "type": "long" }, @@ -4707,6 +4720,19 @@ }, "sec": { "properties": { + "assignees": { + "properties": { + "total": { + "type": "long" + }, + "totalWithZero": { + "type": "long" + }, + "totalWithAtLeastOne": { + "type": "long" + } + } + }, "total": { "type": "long" }, @@ -4723,6 +4749,19 @@ }, "obs": { "properties": { + "assignees": { + "properties": { + "total": { + "type": "long" + }, + "totalWithZero": { + "type": "long" + }, + "totalWithAtLeastOne": { + "type": "long" + } + } + }, "total": { "type": "long" }, @@ -4739,6 +4778,19 @@ }, "main": { "properties": { + "assignees": { + "properties": { + "total": { + "type": "long" + }, + "totalWithZero": { + "type": "long" + }, + "totalWithAtLeastOne": { + "type": "long" + } + } + }, "total": { "type": "long" }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx index c149a6c4143cd..0b7db1ebeceba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx @@ -326,7 +326,7 @@ export const RuleQuickEditButtons: React.FunctionComponent = ({ > 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 02f2a722c9012..3bde062935a86 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 @@ -1876,7 +1876,8 @@ describe('rules_list with disabled items', () => { }); }); -describe('Rules list bulk actions', () => { +// Failing: https://github.com/elastic/kibana/issues/141052 +describe.skip('Rules list bulk actions', () => { let wrapper: ReactWrapper; async function setup(authorized: boolean = true) { diff --git a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts index 8c0191187bc4b..d4d4c48c60336 100644 --- a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts +++ b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts @@ -27,86 +27,88 @@ export default function ({ getService }: FtrProviderContext) { const mapUsage = apiResponse.stack_stats.kibana.plugins.maps; delete mapUsage.timeCaptured; - expect(mapUsage.geoShapeAggLayersCount).eql(1); - expect(mapUsage.indexPatternsWithGeoFieldCount).eql(6); - expect(mapUsage.indexPatternsWithGeoPointFieldCount).eql(4); - expect(mapUsage.indexPatternsWithGeoShapeFieldCount).eql(2); - expect(mapUsage.mapsTotalCount).eql(26); - expect(mapUsage.basemaps).eql({}); - expect(mapUsage.joins).eql({ term: { min: 1, max: 1, total: 3, avg: 0.11538461538461539 } }); - expect(mapUsage.layerTypes).eql({ - es_docs: { min: 1, max: 2, total: 18, avg: 0.6923076923076923 }, - es_agg_grids: { min: 1, max: 1, total: 6, avg: 0.23076923076923078 }, - es_point_to_point: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - es_top_hits: { min: 1, max: 1, total: 2, avg: 0.07692307692307693 }, - es_agg_heatmap: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - kbn_tms_raster: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - ems_basemap: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - ems_region: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - }); - expect(mapUsage.resolutions).eql({ - coarse: { min: 1, max: 1, total: 4, avg: 0.15384615384615385 }, - super_fine: { min: 1, max: 1, total: 3, avg: 0.11538461538461539 }, - }); - expect(mapUsage.scalingOptions).eql({ - limit: { min: 1, max: 2, total: 14, avg: 0.5384615384615384 }, - clusters: { min: 1, max: 1, total: 1, avg: 0.038461538461538464 }, - mvt: { min: 1, max: 1, total: 3, avg: 0.11538461538461539 }, - }); - expect(mapUsage.attributesPerMap).eql({ - customIconsCount: { - avg: 0, - max: 0, - min: 0, + expect(mapUsage).eql({ + geoShapeAggLayersCount: 1, + indexPatternsWithGeoFieldCount: 6, + indexPatternsWithGeoPointFieldCount: 4, + indexPatternsWithGeoShapeFieldCount: 2, + mapsTotalCount: 27, + basemaps: {}, + joins: { term: { min: 1, max: 1, total: 3, avg: 0.1111111111111111 } }, + layerTypes: { + es_docs: { min: 1, max: 2, total: 19, avg: 0.7037037037037037 }, + es_agg_grids: { min: 1, max: 1, total: 6, avg: 0.2222222222222222 }, + es_point_to_point: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, + es_top_hits: { min: 1, max: 1, total: 2, avg: 0.07407407407407407 }, + es_agg_heatmap: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, + kbn_tms_raster: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, + ems_basemap: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, + ems_region: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, }, - dataSourcesCount: { - avg: 1.1538461538461537, - max: 5, - min: 1, + resolutions: { + coarse: { min: 1, max: 1, total: 4, avg: 0.14814814814814814 }, + super_fine: { min: 1, max: 1, total: 3, avg: 0.1111111111111111 }, }, - emsVectorLayersCount: { - idThatDoesNotExitForEMSFileSource: { - avg: 0.038461538461538464, - max: 1, - min: 1, - }, + scalingOptions: { + limit: { min: 1, max: 2, total: 14, avg: 0.5185185185185185 }, + clusters: { min: 1, max: 1, total: 1, avg: 0.037037037037037035 }, + mvt: { min: 1, max: 1, total: 4, avg: 0.14814814814814814 }, }, - layerTypesCount: { - BLENDED_VECTOR: { - avg: 0.038461538461538464, - max: 1, - min: 1, + attributesPerMap: { + customIconsCount: { + avg: 0, + max: 0, + min: 0, }, - EMS_VECTOR_TILE: { - avg: 0.038461538461538464, - max: 1, + dataSourcesCount: { + avg: 1.1481481481481481, + max: 5, min: 1, }, - GEOJSON_VECTOR: { - avg: 0.8076923076923077, - max: 4, - min: 1, + emsVectorLayersCount: { + idThatDoesNotExitForEMSFileSource: { + avg: 0.037037037037037035, + max: 1, + min: 1, + }, }, - HEATMAP: { - avg: 0.038461538461538464, - max: 1, - min: 1, + layerTypesCount: { + BLENDED_VECTOR: { + avg: 0.037037037037037035, + max: 1, + min: 1, + }, + EMS_VECTOR_TILE: { + avg: 0.037037037037037035, + max: 1, + min: 1, + }, + GEOJSON_VECTOR: { + avg: 0.7777777777777778, + max: 4, + min: 1, + }, + HEATMAP: { + avg: 0.037037037037037035, + max: 1, + min: 1, + }, + MVT_VECTOR: { + avg: 0.25925925925925924, + max: 1, + min: 1, + }, + RASTER_TILE: { + avg: 0.037037037037037035, + max: 1, + min: 1, + }, }, - MVT_VECTOR: { - avg: 0.23076923076923078, - max: 1, + layersCount: { + avg: 1.1851851851851851, + max: 6, min: 1, }, - RASTER_TILE: { - avg: 0.038461538461538464, - max: 1, - min: 1, - }, - }, - layersCount: { - avg: 1.1923076923076923, - max: 6, - min: 1, }, }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/all_types.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/all_types.ts index 323fe9041e1b6..354ba79a46a56 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/all_types.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/all_types.ts @@ -12,6 +12,7 @@ import { deleteAllAlerts, deleteSignalsIndex, getSecurityTelemetryStats, + removeTimeFieldsFromTelemetryStats, } from '../../../../utils'; import { deleteAllExceptions } from '../../../../../lists_api_integration/utils'; @@ -41,14 +42,43 @@ export default ({ getService }: FtrProviderContext) => { await deleteAllExceptions(supertest, log); }); - it('should have initialized empty/zero values when no rules are running', async () => { + it('should only have task metric values when no rules are running', async () => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); expect(stats).to.eql({ - detection_rules: [], - security_lists: [], - endpoints: [], - diagnostics: [], + detection_rules: [ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ], + security_lists: [ + [ + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, + ], + ], + endpoints: [ + [ + { + name: 'Security Solution Telemetry Endpoint Metrics and Info task', + passed: true, + }, + ], + ], + diagnostics: [ + [ + { + name: 'Security Solution Telemetry Diagnostics task', + passed: true, + }, + ], + ], }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/detection_rules.ts index 627faebb2daaa..eb5f5c9a923bb 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/detection_rules.ts @@ -21,6 +21,7 @@ import { getSecurityTelemetryStats, createExceptionList, createExceptionListItem, + removeTimeFieldsFromTelemetryStats, } from '../../../../utils'; import { deleteAllExceptions } from '../../../../../lists_api_integration/utils'; @@ -100,7 +101,15 @@ export default ({ getService }: FtrProviderContext) => { // Get the stats and ensure they're empty await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).to.eql([]); + removeTimeFieldsFromTelemetryStats(stats); + expect(stats.detection_rules).to.eql([ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ]); }); }); @@ -148,7 +157,15 @@ export default ({ getService }: FtrProviderContext) => { // Get the stats and ensure they're empty await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).to.eql([]); + removeTimeFieldsFromTelemetryStats(stats); + expect(stats.detection_rules).to.eql([ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ]); }); }); @@ -196,7 +213,15 @@ export default ({ getService }: FtrProviderContext) => { // Get the stats and ensure they're empty await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).to.eql([]); + removeTimeFieldsFromTelemetryStats(stats); + expect(stats.detection_rules).to.eql([ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ]); }); }); @@ -244,7 +269,15 @@ export default ({ getService }: FtrProviderContext) => { // Get the stats and ensure they're empty await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).to.eql([]); + removeTimeFieldsFromTelemetryStats(stats); + expect(stats.detection_rules).to.eql([ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ]); }); }); @@ -292,7 +325,15 @@ export default ({ getService }: FtrProviderContext) => { // Get the stats and ensure they're empty await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).to.eql([]); + removeTimeFieldsFromTelemetryStats(stats); + expect(stats.detection_rules).to.eql([ + [ + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, + ], + ]); }); }); }); @@ -350,7 +391,7 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - expect(stats.detection_rules).length(1); + expect(stats.detection_rules).length(2); const detectionRule = stats.detection_rules[0][0]; expect(detectionRule['@timestamp']).to.be.a('string'); expect(detectionRule.cluster_uuid).to.be.a('string'); @@ -408,9 +449,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule); + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)); expect(detectionRules).to.eql([ { @@ -428,6 +470,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[0].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); @@ -479,9 +525,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule); + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)); expect(detectionRules).to.eql([ { @@ -499,6 +546,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[0].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); @@ -550,9 +601,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule); + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)); expect(detectionRules).to.eql([ { @@ -570,6 +622,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[0].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); @@ -621,9 +677,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule); + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)); expect(detectionRules).to.eql([ { @@ -641,6 +698,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[0].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); @@ -692,9 +753,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule); + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)); expect(detectionRules).to.eql([ { @@ -712,6 +774,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[0].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); @@ -787,11 +853,12 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const detectionRules = stats.detection_rules .flat() - .map((obj: { detection_rule: any }) => obj.detection_rule) + .map((obj: any) => (obj.passed != null ? obj : obj.detection_rule)) .sort((obj1: { entries: { name: number } }, obj2: { entries: { name: number } }) => { - return obj1.entries.name - obj2.entries.name; + return obj1?.entries?.name - obj2?.entries?.name; }); expect(detectionRules).to.eql([ @@ -825,6 +892,10 @@ export default ({ getService }: FtrProviderContext) => { os_types: [], rule_version: detectionRules[1].rule_version, }, + { + name: 'Security Solution Detection Rule Lists Telemetry', + passed: true, + }, ]); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/security_lists.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/security_lists.ts index c56936f016b58..4db09b123d3db 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/security_lists.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/task_based/security_lists.ts @@ -19,6 +19,7 @@ import { getSecurityTelemetryStats, createExceptionListItem, createExceptionList, + removeTimeFieldsFromTelemetryStats, } from '../../../../utils'; import { deleteAllExceptions } from '../../../../../lists_api_integration/utils'; @@ -72,10 +73,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - + removeTimeFieldsFromTelemetryStats(stats); const trustedApplication = stats.security_lists .flat() - .map((obj: { trusted_application: any }) => obj.trusted_application); + .map((obj: any) => (obj.passed != null ? obj : obj.trusted_application)); expect(trustedApplication).to.eql([ { created_at: trustedApplication[0].created_at, @@ -95,6 +96,10 @@ export default ({ getService }: FtrProviderContext) => { policies: [], }, }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); @@ -138,12 +143,12 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); - + removeTimeFieldsFromTelemetryStats(stats); const trustedApplication = stats.security_lists .flat() - .map((obj: { trusted_application: any }) => obj.trusted_application) + .map((obj: any) => (obj.passed != null ? obj : obj.trusted_application)) .sort((obj1: { entries: { name: number } }, obj2: { entries: { name: number } }) => { - return obj1.entries.name - obj2.entries.name; + return obj1?.entries?.name - obj2?.entries?.name; }); expect(trustedApplication).to.eql([ @@ -183,6 +188,10 @@ export default ({ getService }: FtrProviderContext) => { policies: [], }, }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); @@ -210,9 +219,10 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const securityLists = stats.security_lists .flat() - .map((obj: { endpoint_exception: any }) => obj.endpoint_exception); + .map((obj: any) => (obj.passed != null ? obj : obj.endpoint_exception)); expect(securityLists).to.eql([ { created_at: securityLists[0].created_at, @@ -228,6 +238,10 @@ export default ({ getService }: FtrProviderContext) => { name: ENDPOINT_LIST_ID, os_types: [], }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); @@ -271,11 +285,12 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const securityLists = stats.security_lists .flat() - .map((obj: { endpoint_exception: any }) => obj.endpoint_exception) + .map((obj: any) => (obj.passed != null ? obj : obj.endpoint_exception)) .sort((obj1: { entries: { name: number } }, obj2: { entries: { name: number } }) => { - return obj1.entries.name - obj2.entries.name; + return obj1?.entries?.name - obj2?.entries?.name; }); expect(securityLists).to.eql([ @@ -307,6 +322,10 @@ export default ({ getService }: FtrProviderContext) => { name: ENDPOINT_LIST_ID, os_types: [], }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); @@ -346,9 +365,11 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const endPointEventFilter = stats.security_lists .flat() - .map((obj: { endpoint_event_filter: any }) => obj.endpoint_event_filter); + .map((obj: any) => (obj.passed != null ? obj : obj.endpoint_event_filter)); + expect(endPointEventFilter).to.eql([ { created_at: endPointEventFilter[0].created_at, @@ -364,6 +385,10 @@ export default ({ getService }: FtrProviderContext) => { name: ENDPOINT_EVENT_FILTERS_LIST_ID, os_types: ['linux'], }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); @@ -407,11 +432,12 @@ export default ({ getService }: FtrProviderContext) => { await retry.try(async () => { const stats = await getSecurityTelemetryStats(supertest, log); + removeTimeFieldsFromTelemetryStats(stats); const endPointEventFilter = stats.security_lists .flat() - .map((obj: { endpoint_event_filter: any }) => obj.endpoint_event_filter) + .map((obj: any) => (obj.passed != null ? obj : obj.endpoint_event_filter)) .sort((obj1: { entries: { name: number } }, obj2: { entries: { name: number } }) => { - return obj1.entries.name - obj2.entries.name; + return obj1?.entries?.name - obj2?.entries?.name; }); expect(endPointEventFilter).to.eql([ @@ -443,6 +469,10 @@ export default ({ getService }: FtrProviderContext) => { name: ENDPOINT_EVENT_FILTERS_LIST_ID, os_types: ['macos'], }, + { + name: 'Security Solution Lists Telemetry', + passed: true, + }, ]); }); }); diff --git a/x-pack/test/detection_engine_api_integration/utils/index.ts b/x-pack/test/detection_engine_api_integration/utils/index.ts index 31d13a52f72da..093be64c26d8a 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -80,6 +80,7 @@ export * from './get_web_hook_action'; export * from './index_event_log_execution_events'; export * from './install_prepackaged_rules'; export * from './refresh_index'; +export * from './remove_time_fields_from_telemetry_stats'; export * from './remove_server_generated_properties'; export * from './remove_server_generated_properties_including_rule_id'; export * from './resolve_simple_rule_output'; diff --git a/x-pack/test/detection_engine_api_integration/utils/remove_time_fields_from_telemetry_stats.ts b/x-pack/test/detection_engine_api_integration/utils/remove_time_fields_from_telemetry_stats.ts new file mode 100644 index 0000000000000..7c0931b4b5ae9 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/remove_time_fields_from_telemetry_stats.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { unset } from 'lodash'; + +export const removeTimeFieldsFromTelemetryStats = (stats: any) => { + Object.entries(stats).forEach(([, value]: [unknown, any]) => { + value.forEach((entry: any, i: number) => { + entry.forEach((e: any, j: number) => { + unset(value, `[${i}][${j}].time_executed_in_ms`); + unset(value, `[${i}][${j}].start_time`); + unset(value, `[${i}][${j}].end_time`); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts index 1040498a9bd05..3b1f9c2065180 100644 --- a/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -6,6 +6,8 @@ */ import expect from '@kbn/expect'; +import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; + import { FtrProviderContext } from '../../../../ftr_provider_context'; const DRILLDOWN_TO_PIE_CHART_NAME = 'Go to pie chart dashboard'; @@ -18,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardDrilldownsManage = getService('dashboardDrilldownsManage'); const PageObjects = getPageObjects([ 'dashboard', + 'dashboardControls', 'common', 'header', 'timePicker', @@ -46,20 +49,202 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); - await clearFilters(dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME); - await clearFilters(dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME); }); - const clearFilters = async (dashboardName: string) => { - await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); - await filterBar.removeAllFilters(); - await PageObjects.dashboard.clearUnsavedChanges(); - }; + describe('test dashboard to dashboard drilldown', async () => { + before(async () => { + await createDrilldown(); + }); + + after(async () => { + await cleanFiltersAndTimePicker(dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME); + await cleanFiltersAndTimePicker(dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME); + }); + + it('use dashboard to dashboard drilldown via onClick action', async () => { + await testCircularDashboardDrilldowns( + dashboardDrilldownPanelActions.clickActionByText.bind(dashboardDrilldownPanelActions) // preserve 'this' + ); + }); + + it('use dashboard to dashboard drilldown via getHref action', async () => { + await testCircularDashboardDrilldowns( + dashboardDrilldownPanelActions.openHrefByText.bind(dashboardDrilldownPanelActions) // preserve 'this' + ); + }); + + it('delete dashboard to dashboard drilldown', async () => { + // delete drilldown + await PageObjects.dashboard.switchToEditMode(); + await dashboardPanelActions.openContextMenu(); + await dashboardDrilldownPanelActions.expectExistsManageDrilldownsAction(); + await dashboardDrilldownPanelActions.clickManageDrilldowns(); + await dashboardDrilldownsManage.expectsManageDrilldownsFlyoutOpen(); + + await dashboardDrilldownsManage.deleteDrilldownsByTitles([DRILLDOWN_TO_AREA_CHART_NAME]); + await dashboardDrilldownsManage.closeFlyout(); + + // check that drilldown notification badge is not shown + expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(0); + }); + + it('browser back/forward navigation works after drilldown navigation', async () => { + await PageObjects.dashboard.loadSavedDashboard( + dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME + ); + const originalTimeRangeDurationHours = + await PageObjects.timePicker.getTimeDurationInHours(); + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await navigateWithinDashboard(async () => { + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); + }); + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + + await navigateWithinDashboard(async () => { + await browser.goBack(); + }); + + expect(await PageObjects.timePicker.getTimeDurationInHours()).to.be( + originalTimeRangeDurationHours + ); + }); + + const testCircularDashboardDrilldowns = async ( + drilldownAction: (text: string) => Promise + ) => { + await testPieChartDashboardDrilldown(drilldownAction); + expect(await filterBar.getFilterCount()).to.be(1); + + const originalTimeRangeDurationHours = + await PageObjects.timePicker.getTimeDurationInHours(); + await PageObjects.dashboard.clearUnsavedChanges(); + + // brush area chart and drilldown back to pie chat dashboard + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await navigateWithinDashboard(async () => { + await drilldownAction(DRILLDOWN_TO_PIE_CHART_NAME); + }); + + // because filters are preserved during navigation, we expect that only one slice is displayed (filter is still applied) + expect(await filterBar.getFilterCount()).to.be(1); + await pieChart.expectPieSliceCount(1); + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + await PageObjects.dashboard.clearUnsavedChanges(); + }; + + const cleanFiltersAndTimePicker = async (dashboardName: string) => { + await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); + await filterBar.removeAllFilters(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.dashboard.clearUnsavedChanges(); + }; + }); - it('create dashboard to dashboard drilldown', async () => { + describe('test dashboard to dashboard drilldown with controls', async () => { + before('add controls and make selections', async () => { + /** Source Dashboard */ + await createDrilldown(); + await addControls(dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME, [ + { field: 'geo.src', type: OPTIONS_LIST_CONTROL }, + { field: 'bytes', type: RANGE_SLIDER_CONTROL }, + ]); + const controlIds = await PageObjects.dashboardControls.getAllControlIds(); + const [optionsListControl, rangeSliderControl] = controlIds; + await PageObjects.dashboardControls.optionsListOpenPopover(optionsListControl); + await PageObjects.dashboardControls.optionsListPopoverSelectOption('CN'); + await PageObjects.dashboardControls.optionsListPopoverSelectOption('US'); + await PageObjects.dashboardControls.rangeSliderWaitForLoading(); // wait for range slider to respond to options list selections before proceeding + await PageObjects.dashboardControls.rangeSliderSetLowerBound(rangeSliderControl, '1000'); + await PageObjects.dashboardControls.rangeSliderSetUpperBound(rangeSliderControl, '15000'); + await PageObjects.dashboard.clickQuickSave(); + await PageObjects.dashboard.waitForRenderComplete(); + + /** Destination Dashboard */ + await addControls(dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME, [ + { field: 'geo.src', type: OPTIONS_LIST_CONTROL }, + ]); + }); + + after(async () => { + await cleanFiltersAndControls(dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME); + await cleanFiltersAndControls(dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME); + }); + + it('use dashboard to dashboard drilldown via onClick action', async () => { + await testSingleDashboardDrilldown( + dashboardDrilldownPanelActions.clickActionByText.bind(dashboardDrilldownPanelActions) // preserve 'this' + ); + }); + + it('use dashboard to dashboard drilldown via getHref action', async () => { + await testSingleDashboardDrilldown( + dashboardDrilldownPanelActions.openHrefByText.bind(dashboardDrilldownPanelActions) // preserve 'this' + ); + }); + + const addControls = async ( + dashboardName: string, + controls: Array<{ field: string; type: string }> + ) => { + await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); + await PageObjects.common.clearAllToasts(); // toasts get in the way of bottom "Save and close" button in create control flyout + + for (const control of controls) { + await PageObjects.dashboardControls.createControl({ + controlType: control.type, + dataViewTitle: 'logstash-*', + fieldName: control.field, + }); + } + await PageObjects.dashboard.clickQuickSave(); + }; + + const testSingleDashboardDrilldown = async ( + drilldownAction: (text: string) => Promise + ) => { + await testPieChartDashboardDrilldown(drilldownAction); + + // drilldown creates filter pills for control selections + expect(await filterBar.hasFilter('geo.src', 'CN, US')).to.be(true); + expect(await filterBar.hasFilter('bytes', '1,000 to 15,000')).to.be(true); + + // control filter pills impact destination dashboard controls + const controlIds = await PageObjects.dashboardControls.getAllControlIds(); + const optionsListControl = controlIds[0]; + await PageObjects.dashboardControls.optionsListOpenPopover(optionsListControl); + expect( + await PageObjects.dashboardControls.optionsListPopoverGetAvailableOptionsCount() + ).to.equal(2); + await PageObjects.dashboardControls.optionsListEnsurePopoverIsClosed(optionsListControl); + + // can clear unsaved changes badge after drilldown with controls + await PageObjects.dashboard.clearUnsavedChanges(); + + // clean up filters in destination dashboard + await filterBar.removeAllFilters(); + expect(await filterBar.getFilterCount()).to.be(0); + await PageObjects.dashboard.clickQuickSave(); + }; + + const cleanFiltersAndControls = async (dashboardName: string) => { + await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); + await filterBar.removeAllFilters(); + await PageObjects.dashboardControls.deleteAllControls(); + await PageObjects.dashboard.clickQuickSave(); + }; + }); + + const createDrilldown = async () => { await PageObjects.dashboard.gotoDashboardEditMode( dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME ); + await PageObjects.common.clearAllToasts(); // toasts get in the way of bottom "Create drilldown" button in flyout // create drilldown await dashboardPanelActions.openContextMenu(); await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction(); @@ -87,63 +272,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } ); await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); - }); - - it('use dashboard to dashboard drilldown via onClick action', async () => { - await testDashboardDrilldown( - dashboardDrilldownPanelActions.clickActionByText.bind(dashboardDrilldownPanelActions) // preserve 'this' - ); - }); - - it('use dashboard to dashboard drilldown via getHref action', async () => { - await testDashboardDrilldown( - dashboardDrilldownPanelActions.openHrefByText.bind(dashboardDrilldownPanelActions) // preserve 'this' - ); - }); - - it('delete dashboard to dashboard drilldown', async () => { - // delete drilldown - await PageObjects.dashboard.switchToEditMode(); - await dashboardPanelActions.openContextMenu(); - await dashboardDrilldownPanelActions.expectExistsManageDrilldownsAction(); - await dashboardDrilldownPanelActions.clickManageDrilldowns(); - await dashboardDrilldownsManage.expectsManageDrilldownsFlyoutOpen(); - - await dashboardDrilldownsManage.deleteDrilldownsByTitles([DRILLDOWN_TO_AREA_CHART_NAME]); - await dashboardDrilldownsManage.closeFlyout(); - - // check that drilldown notification badge is not shown - expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(0); - }); - - it('browser back/forward navigation works after drilldown navigation', async () => { - await PageObjects.dashboard.loadSavedDashboard( - dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME - ); - const originalTimeRangeDurationHours = - await PageObjects.timePicker.getTimeDurationInHours(); - await brushAreaChart(); - await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); - await navigateWithinDashboard(async () => { - await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); - }); - // check that new time range duration was applied - const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); - expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); - - await navigateWithinDashboard(async () => { - await browser.goBack(); - }); + }; - expect(await PageObjects.timePicker.getTimeDurationInHours()).to.be( - originalTimeRangeDurationHours + const testPieChartDashboardDrilldown = async ( + drilldownAction: (text: string) => Promise + ) => { + await PageObjects.dashboard.gotoDashboardEditMode( + dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME ); - }); - const testDashboardDrilldown = async (drilldownAction: (text: string) => Promise) => { // trigger drilldown action by clicking on a pie and picking drilldown action by it's name - await pieChart.clickOnPieSlice('40000'); - await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await retry.waitFor('drilldown action menu to appear', async () => { + // avoid flakiness of context menu opening + await pieChart.clickOnPieSlice('40000'); // + return await testSubjects.exists('multipleActionsContextMenu'); + }); const href = await dashboardDrilldownPanelActions.getActionHrefByText( DRILLDOWN_TO_AREA_CHART_NAME @@ -160,25 +303,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); // check that we drilled-down with filter from pie chart - expect(await filterBar.getFilterCount()).to.be(1); - const originalTimeRangeDurationHours = - await PageObjects.timePicker.getTimeDurationInHours(); - await PageObjects.dashboard.clearUnsavedChanges(); - - // brush area chart and drilldown back to pie chat dashboard - await brushAreaChart(); - await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); - await navigateWithinDashboard(async () => { - await drilldownAction(DRILLDOWN_TO_PIE_CHART_NAME); - }); - - // because filters are preserved during navigation, we expect that only one slice is displayed (filter is still applied) - expect(await filterBar.getFilterCount()).to.be(1); - await pieChart.expectPieSliceCount(1); - // check that new time range duration was applied - const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); - expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); - await PageObjects.dashboard.clearUnsavedChanges(); + expect(await filterBar.hasFilter('memory', '40,000 to 80,000')).to.be(true); }; }); diff --git a/x-pack/test/functional/apps/maps/group2/adhoc_data_view.ts b/x-pack/test/functional/apps/maps/group2/adhoc_data_view.ts new file mode 100644 index 0000000000000..15824a1beb63f --- /dev/null +++ b/x-pack/test/functional/apps/maps/group2/adhoc_data_view.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const security = getService('security'); + const PageObjects = getPageObjects(['maps']); + + describe('maps adhoc data view', () => { + before(async () => { + await security.testUser.setRoles(['global_maps_all', 'test_logstash_reader'], { + skipBrowserRefresh: true, + }); + await PageObjects.maps.loadSavedMap('adhoc data view'); + }); + + it('should render saved map with adhoc data view', async () => { + const tooltipText = await PageObjects.maps.getLayerTocTooltipMsg('adhocDataView'); + expect(tooltipText).to.equal( + 'adhocDataView\nFound 908 documents.\nResults narrowed by global search\nResults narrowed by global time' + ); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/group2/index.js b/x-pack/test/functional/apps/maps/group2/index.js index b8b3a15a10ed5..884c7a500c446 100644 --- a/x-pack/test/functional/apps/maps/group2/index.js +++ b/x-pack/test/functional/apps/maps/group2/index.js @@ -59,6 +59,7 @@ export default function ({ loadTestFile, getService }) { }); loadTestFile(require.resolve('./es_geo_grid_source')); + loadTestFile(require.resolve('./adhoc_data_view')); loadTestFile(require.resolve('./embeddable')); }); } diff --git a/x-pack/test/functional/fixtures/kbn_archiver/maps.json b/x-pack/test/functional/fixtures/kbn_archiver/maps.json index fcd89cc41c724..92507529a1b8a 100644 --- a/x-pack/test/functional/fixtures/kbn_archiver/maps.json +++ b/x-pack/test/functional/fixtures/kbn_archiver/maps.json @@ -990,6 +990,28 @@ "version": "WzQ5LDJd" } +{ + "id": "68f85360-3913-11ed-aa60-654006132508", + "type": "map", + "namespaces": [ + "default" + ], + "updated_at": "2022-09-20T18:39:40.421Z", + "version": "WzM0OSwxXQ==", + "attributes": { + "title": "adhoc data view", + "description": "", + "layerListJSON": "[{\"id\":\"e5b830e4-d939-4b82-b3df-ccb4c3fce478\",\"sourceDescriptor\":{\"geoField\":\"geo.coordinates\",\"id\":\"5883a356-bef3-4e87-89f4-6dfd95a5794d\",\"indexPatternId\":\"1a9589c7-c919-4d35-bd4c-fbe1bcf8dfe4\",\"label\":\"logstash-*\",\"scalingType\":\"MVT\",\"tooltipProperties\":[],\"type\":\"ES_SEARCH\"},\"type\":\"MVT_VECTOR\",\"visible\":true,\"style\":{},\"label\":\"adhocDataView\"}]", + "mapStateJSON": "{\"adHocDataViews\":[{\"id\":\"1a9589c7-c919-4d35-bd4c-fbe1bcf8dfe4\",\"title\":\"logstash-*\",\"timeFieldName\":\"@timestamp\",\"sourceFilters\":[],\"fieldFormats\":{},\"runtimeFieldMap\":{},\"fieldAttrs\":{},\"allowNoIndex\":false,\"name\":\"logstash adhoc data view\"}],\"zoom\":2.36,\"center\":{\"lon\":-116.75537,\"lat\":55.05932},\"timeFilters\":{\"from\":\"2015-09-20T00:00:00.000Z\",\"to\":\"2015-09-22T00:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[{\"meta\":{\"index\":\"1a9589c7-c919-4d35-bd4c-fbe1bcf8dfe4\",\"params\":{\"lt\":1000,\"gte\":0},\"field\":\"bytes\",\"alias\":null,\"negate\":false,\"disabled\":false,\"type\":\"range\",\"key\":\"bytes\",\"value\":{\"lt\":1000,\"gte\":0}},\"query\":{\"range\":{\"bytes\":{\"lt\":1000,\"gte\":0}}},\"$state\":{\"store\":\"appState\"}}],\"settings\":{\"autoFitToDataBounds\":false,\"backgroundColor\":\"#ffffff\",\"customIcons\":[],\"disableInteractive\":false,\"disableTooltipControl\":false,\"hideToolbarOverlay\":false,\"hideLayerControl\":false,\"hideViewControl\":false,\"initialLocation\":\"LAST_SAVED_LOCATION\",\"fixedLocation\":{\"lat\":0,\"lon\":0,\"zoom\":2},\"browserLocation\":{\"zoom\":2},\"keydownScrollZoom\":false,\"maxZoom\":24,\"minZoom\":0,\"showScaleControl\":false,\"showSpatialFilters\":true,\"showTimesliderToggleButton\":true,\"spatialFiltersAlpa\":0.3,\"spatialFiltersFillColor\":\"#DA8B45\",\"spatialFiltersLineColor\":\"#DA8B45\"}}", + "uiStateJSON": "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}" + }, + "references": [], + "migrationVersion": { + "map": "8.4.0" + }, + "coreMigrationVersion": "8.5.0" +} + { "attributes": { "description": "", diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index a5b13c083b278..730486ccf94f5 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -96,7 +96,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - describe('when there is data,', () => { + // Version specific: https://github.com/elastic/kibana/issues/141298 + describe.skip('when there is data,', () => { before(async () => { indexedData = await endpointTestResources.loadEndpointData({ numHosts: 3 }); await pageObjects.endpoint.navigateToEndpointList(); diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index f74434f72227e..58a434bd0ca91 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -6,13 +6,13 @@ */ import expect from '@kbn/expect'; -import { SuperTest } from 'supertest'; -import { EsArchiver } from '@kbn/es-archiver'; import { SavedObject } from '@kbn/core/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; import { CopyResponse } from '@kbn/spaces-plugin/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { getTestDataLoader } from '../lib/test_data_loader'; type TestResponse = Record; @@ -51,11 +51,11 @@ const getDestinationSpace = (originSpaceId?: string) => { return DEFAULT_SPACE_ID; }; -export function resolveCopyToSpaceConflictsSuite( - esArchiver: EsArchiver, - supertestWithAuth: SuperTest, - supertestWithoutAuth: SuperTest -) { +export function resolveCopyToSpaceConflictsSuite(context: FtrProviderContext) { + const testDataLoader = getTestDataLoader(context); + const supertestWithAuth = context.getService('supertest'); + const supertestWithoutAuth = context.getService('supertestWithoutAuth'); + const getVisualizationAtSpace = async (spaceId: string): Promise> => { return supertestWithAuth .get(`${getUrlPrefix(spaceId)}/api/saved_objects/visualization/cts_vis_3_${spaceId}`) @@ -487,16 +487,8 @@ export function resolveCopyToSpaceConflictsSuite( }); describe('single-namespace types', () => { - beforeEach(() => - esArchiver.load( - 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); - afterEach(() => - esArchiver.unload( - 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); + beforeEach(async () => await testDataLoader.beforeEach()); + afterEach(async () => await testDataLoader.afterEach()); const dashboardObject = { type: 'dashboard', id: `cts_dashboard_${spaceId}` }; const visualizationObject = { type: 'visualization', id: `cts_vis_3_${spaceId}` }; @@ -638,16 +630,8 @@ export function resolveCopyToSpaceConflictsSuite( const includeReferences = false; const createNewCopies = false; describe(`multi-namespace types with "overwrite" retry`, () => { - before(() => - esArchiver.load( - 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); - after(() => - esArchiver.unload( - 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); + before(async () => await testDataLoader.beforeEach()); + after(async () => await testDataLoader.afterEach()); const testCases = tests.multiNamespaceTestCases(); testCases.forEach(({ testTitle, objects, retries, statusCode, response }) => { diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts index 1b39cd5d77302..2f1788ae348f9 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts @@ -11,11 +11,7 @@ import { resolveCopyToSpaceConflictsSuite } from '../../common/suites/resolve_co import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function resolveCopyToSpaceConflictsTestSuite({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const supertestWithAuth = getService('supertest'); - const esArchiver = getService('esArchiver'); - +export default function resolveCopyToSpaceConflictsTestSuite(context: FtrProviderContext) { const { resolveCopyToSpaceConflictsTest, createExpectNonOverriddenResponseWithReferences, @@ -27,7 +23,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr createExpectUnauthorizedAtSpaceWithoutReferencesResult, createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, - } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); + } = resolveCopyToSpaceConflictsSuite(context); describe('resolve copy to spaces conflicts', () => { [ diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts index 454c49f9a6ca6..2248c67cd8219 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts @@ -5,15 +5,11 @@ * 2.0. */ -import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveCopyToSpaceConflictsSuite } from '../../common/suites/resolve_copy_to_space_conflicts'; // eslint-disable-next-line import/no-default-export -export default function resolveCopyToSpaceConflictsTestSuite({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const supertestWithAuth = getService('supertest'); - const esArchiver = getService('esArchiver'); - +export default function resolveCopyToSpaceConflictsTestSuite(context: FtrProviderContext) { const { resolveCopyToSpaceConflictsTest, createExpectNonOverriddenResponseWithReferences, @@ -23,7 +19,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, originSpaces, - } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); + } = resolveCopyToSpaceConflictsSuite(context); describe('resolve copy to spaces conflicts', () => { originSpaces.forEach((spaceId) => { diff --git a/yarn.lock b/yarn.lock index d61812baf7a90..4bcdb58b2db99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1537,10 +1537,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@64.0.4": - version "64.0.4" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-64.0.4.tgz#4a6a997a3f43f459c82e3b992ac2e6ab318d5a12" - integrity sha512-4wpZcVJyNvxfZA58kVSwnJxIbWCrFriU6vARr/DoDB6Vvt/5zHeFrHzXFjdo+hqTWZApPoEQlK7aJ7FDZTEbkw== +"@elastic/eui@64.0.5": + version "64.0.5" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-64.0.5.tgz#d68dcebc2acd9ec360a84cb8688919de0d802417" + integrity sha512-6sJpnHYIUErA+IFJBLrXB8a86E+VR6dOBKpWVfQZVL+ZXrt4fy6uWXNompsz0geT8ylvW85oSpfpxRh+cgb0lw== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160"