diff --git a/.buildkite/scripts/steps/artifacts/cloud.sh b/.buildkite/scripts/steps/artifacts/cloud.sh index 8fa04a5d176b0..d9e78c700e972 100644 --- a/.buildkite/scripts/steps/artifacts/cloud.sh +++ b/.buildkite/scripts/steps/artifacts/cloud.sh @@ -43,39 +43,39 @@ jq ' ' .buildkite/scripts/steps/cloud/deploy.json > "$DEPLOYMENT_SPEC" ecctl deployment create --track --output json --file "$DEPLOYMENT_SPEC" &> "$LOGS" -CLOUD_DEPLOYMENT_USERNAME=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.username' "$LOGS") -CLOUD_DEPLOYMENT_PASSWORD=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.password' "$LOGS") +CLOUD_DEPLOYMENT_USERNAME=$(jq -r --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.username' "$LOGS") +CLOUD_DEPLOYMENT_PASSWORD=$(jq -r --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.password' "$LOGS") CLOUD_DEPLOYMENT_ID=$(jq -r --slurp '.[0].id' "$LOGS") CLOUD_DEPLOYMENT_STATUS_MESSAGES=$(jq --slurp '[.[]|select(.resources == null)]' "$LOGS") CLOUD_DEPLOYMENT_KIBANA_URL=$(ecctl deployment show "$CLOUD_DEPLOYMENT_ID" | jq -r '.resources.kibana[0].info.metadata.aliased_url') CLOUD_DEPLOYMENT_ELASTICSEARCH_URL=$(ecctl deployment show "$CLOUD_DEPLOYMENT_ID" | jq -r '.resources.elasticsearch[0].info.metadata.aliased_url') -# NOTE: disabled pending log sanitization -# echo "--- Setup FTR" -# export TEST_KIBANA_PROTOCOL=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').protocol)") -# export TEST_KIBANA_HOSTNAME=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').hostname)") -# export TEST_KIBANA_PORT=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').port)") -# export TEST_KIBANA_USERNAME=$CLOUD_DEPLOYMENT_USERNAME" -# export TEST_KIBANA_PASS=$CLOUD_DEPLOYMENT_PASSWORD" +echo "Kibana: $CLOUD_DEPLOYMENT_KIBANA_URL" +echo "ES: $CLOUD_DEPLOYMENT_ELASTICSEARCH_URL" -# export TEST_ES_PROTOCOL=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').protocol)") -# export TEST_ES_HOSTNAME==$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').hostname)") -# export TEST_ES_PORT=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').port)") -# export TEST_ES_USER="$CLOUD_DEPLOYMENT_USERNAME" -# export TEST_ES_PASS="$CLOUD_DEPLOYMENT_PASSWORD" +function shutdown { + echo "--- Shutdown deployment" + ecctl deployment shutdown "$CLOUD_DEPLOYMENT_ID" --force --track --output json &> "$LOGS" +} +trap "shutdown" EXIT -# export TEST_BROWSER_HEADLESS=1 +export TEST_KIBANA_PROTOCOL=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').protocol.replace(':', ''))") +export TEST_KIBANA_HOSTNAME=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').hostname)") +export TEST_KIBANA_PORT=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_KIBANA_URL').port)") +export TEST_KIBANA_USERNAME="$CLOUD_DEPLOYMENT_USERNAME" +export TEST_KIBANA_PASSWORD="$CLOUD_DEPLOYMENT_PASSWORD" -# Error: attempted to use the "es" service to fetch Elasticsearch version info but the request failed: ConnectionError: self signed certificate in certificate chain -# export NODE_TLS_REJECT_UNAUTHORIZED=0 +export TEST_ES_PROTOCOL=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_ELASTICSEARCH_URL').protocol.replace(':', ''))") +export TEST_ES_HOSTNAME=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_ELASTICSEARCH_URL').hostname)") +export TEST_ES_PORT=$(node -e "console.log(new URL('$CLOUD_DEPLOYMENT_ELASTICSEARCH_URL').port)") +export TEST_ES_USERNAME="$CLOUD_DEPLOYMENT_USERNAME" +export TEST_ES_PASSWORD="$CLOUD_DEPLOYMENT_PASSWORD" -# echo "--- Run default functional tests" -# node --no-warnings scripts/functional_test_runner.js --include-tag=cloud -exclude-tag=skipCloud +export TEST_BROWSER_HEADLESS=1 -# echo "--- Run x-pack functional tests" -# cd x-pack -# node --no-warnings scripts/functional_test_runner.js --include-tag=cloud -exclude-tag=skipCloud +# Error: attempted to use the "es" service to fetch Elasticsearch version info but the request failed: ConnectionError: self signed certificate in certificate chain +export NODE_TLS_REJECT_UNAUTHORIZED=0 -echo "--- Shutdown deployment" -ecctl deployment shutdown "$CLOUD_DEPLOYMENT_ID" --force --track --output json &> "$LOGS" +echo "--- FTR - Reporting" +node --no-warnings scripts/functional_test_runner.js --config x-pack/test/functional/apps/visualize/config.ts --include-tag=smoke --quiet diff --git a/.eslintignore b/.eslintignore index 9b745756b6706..bfa69083b1e09 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,7 +30,7 @@ snapshots.js /x-pack/plugins/reporting/server/export_types/printable_pdf_v2/server/lib/pdf/assets/** # package overrides -/packages/elastic-eslint-config-kibana +/packages/kbn-eslint-config /packages/kbn-plugin-generator/template /packages/kbn-generate/templates /packages/kbn-pm/dist diff --git a/.eslintrc.js b/.eslintrc.js index dfbdd4de96f0a..a921718a97f79 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -230,7 +230,7 @@ const RESTRICTED_IMPORTS = [ module.exports = { root: true, - extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], + extends: ['plugin:@elastic/eui/recommended', '@kbn/eslint-config'], overrides: [ /** @@ -304,7 +304,7 @@ module.exports = { */ { files: [ - 'packages/elastic-eslint-config-kibana/**/*.{js,mjs,ts,tsx}', + 'packages/kbn-eslint-config/**/*.{js,mjs,ts,tsx}', 'packages/kbn-datemath/**/*.{js,mjs,ts,tsx}', ], rules: { diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index df63cc0ecd65f..203492d6aa632 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -119,6 +119,7 @@ The API returns details about the case and its comments. For example: "syncAlerts":false }, "owner": "cases", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc index b7a97fc9cb1b2..73c89937466b3 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -205,6 +205,7 @@ the case identifier, version, and creation time. For example: }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index abd4e186ff706..3e94dd56ffa36 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -125,6 +125,7 @@ The API returns a JSON object listing the retrieved cases. For example: "syncAlerts": true }, "owner": "securitySolution", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", @@ -164,6 +165,7 @@ The API returns a JSON object listing the retrieved cases. For example: "syncAlerts": false }, "owner": "cases", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T11:30:02.658Z", diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 5abb9ecc1903b..42cf0672065e7 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -59,6 +59,7 @@ The API returns a JSON object with the retrieved case. For example: "version": "Wzk4LDFd", "comments": [], "totalComment": 0, + "totalAlerts": 0, "closed_at": null, "closed_by": null, "created_at": "2020-03-29T11:30:02.658Z", @@ -90,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "duration": null, "tags": [ "phishing", "social engineering", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 5b3e4d7c9ef78..16c411104caed 100644 --- a/docs/api/cases/cases-api-push.asciidoc +++ b/docs/api/cases/cases-api-push.asciidoc @@ -68,6 +68,7 @@ The API returns a JSON object representing the pushed case. For example: "syncAlerts": true }, "owner": "securitySolution", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T11:30:02.658Z", diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index 020fe403fa7c5..d00d1eb66ea7c 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -106,18 +106,18 @@ The API returns details about the case and its comments. For example: "comments":[{ "id": "8af6ac20-74f6-11ea-b83a-553aecdb28b6", "version": "WzIwNjM3LDFd", - "comment":"That is nothing - Ethan Hunt answered a targeted social media campaign promoting phishy pension schemes to IMF operatives. Even worse, he likes baked beans.", - "type":"user", - "owner":"cases", - "created_at":"2022-03-24T00:37:10.832Z", + "comment": "That is nothing - Ethan Hunt answered a targeted social media campaign promoting phishy pension schemes to IMF operatives. Even worse, he likes baked beans.", + "type": "user", + "owner": "cases", + "created_at": "2022-03-24T00:37:10.832Z", "created_by": { "email": "moneypenny@hms.gov.uk", "full_name": "Ms Moneypenny", "username": "moneypenny" }, - "pushed_at":null, - "pushed_by":null, - "updated_at":"2022-03-24T01:27:06.210Z", + "pushed_at": null, + "pushed_by": null, + "updated_at": "2022-03-24T01:27:06.210Z", "updated_by": { "email": "jbond@hms.gov.uk", "full_name": "James Bond", @@ -125,16 +125,17 @@ The API returns details about the case and its comments. For example: } } ], - "totalAlerts":0, + "totalAlerts": 0, "id": "293f1bc0-74f6-11ea-b83a-553aecdb28b6", "version": "WzIwNjM2LDFd", "totalComment": 1, "title": "This case will self-destruct in 5 seconds", - "tags": ["phishing","social engineering"], + "tags": ["phishing","social engineering"], "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", "settings": {"syncAlerts":false}, - "owner": "cases"," - closed_at": null, + "owner": "cases", + "duration": null, + "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", "created_by": { diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index 7a63d0e8a6a33..ebad2feaedff4 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -226,6 +226,7 @@ The API returns the updated case with a new `version` value. For example: }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/developer/getting-started/running-kibana-advanced.asciidoc b/docs/developer/getting-started/running-kibana-advanced.asciidoc index 68a4951ea1c21..5b2a58836008c 100644 --- a/docs/developer/getting-started/running-kibana-advanced.asciidoc +++ b/docs/developer/getting-started/running-kibana-advanced.asciidoc @@ -30,7 +30,7 @@ If youโ€™re installing dependencies and seeing an error that looks something like .... -Unsupported URL Type: link:packages/elastic-eslint-config-kibana +Unsupported URL Type: link:packages/kbn-eslint-config .... youโ€™re likely running `npm`. To install dependencies in {kib} you diff --git a/docs/management/connectors/action-types/servicenow-itom.asciidoc b/docs/management/connectors/action-types/servicenow-itom.asciidoc index af231c327f955..3fd3682dc1ad9 100644 --- a/docs/management/connectors/action-types/servicenow-itom.asciidoc +++ b/docs/management/connectors/action-types/servicenow-itom.asciidoc @@ -10,10 +10,114 @@ The {sn} ITOM connector uses the https://docs.servicenow.com/bundle/rome-it-oper [float] [[servicenow-itom-connector-prerequisites]] ==== Prerequisites -Create an integration user in {sn} and assign it the following roles. +* Create a {sn} integration user and assign it the appropriate roles. +If you use open authorization (OAuth), you must also: + +* Create an RSA keypair and add an X.509 Certificate. +* Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map. + +[float] +===== Create a {sn} integration user + +To ensure authenticated communication between Elastic and {sn}, create a {sn} integration user and assign it the appropriate roles. + +. In your {sn} instance, go to *System Security -> Users and Groups -> Users*. +. Click *New*. +. Complete the form, then right-click on the menu bar and click *Save*. +. Go to the *Roles* tab and click *Edit*. +. Assign the integration user the following roles:ย  * `personalize_choices`: Allows the user to retrieve Choice element options, such as Severity. * `evt_mgmt_integration`: Enables integration with external event sources by allowing the user to create events. +. Click *Save*. + +[float] +===== Create an RSA keypair and add an X.509 Certificate + +This step is required to use OAuth for authentication between Elastic and {sn}. + +*Create an RSA keypair:* + +. Use https://www.openssl.org/docs/man1.0.2/man1/genrsa.html[OpenSSL] to generate an RSA private key: ++ +-- +[source,sh] +---- +openssl genrsa -out example-private-key.pem 3072 +openssl genrsa -passout pass:foobar -out example-private-key-with-password.pem 3072 <1> +---- +<1> Use the `passout` option to set a password on your private key. This is optional but remember your password if you set one. +-- + +. Use https://www.openssl.org/docs/man1.0.2/man1/req.html[OpenSSL] to generate the matching public key: ++ +-- +[source,sh] +---- +openssl req -new -x509 -key example-private-key.pem -out example-sn-cert.pem -days 360 +---- +-- + +*Add an X.509 Certificate to ServiceNow:* + +. In your {sn} instance, go to *Certificates* and select *New*. +. Configure the certificate as follows: ++ +-- +* *Name*: Name the certificate. +* *PEM Certificate*: Copy the generated public key into this text field. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-certificate.png[Shows new certificate form in ServiceNow] +-- + +. Click *Submit* to create the certificate. + +[float] +===== Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map + +This step is required to use OAuth for authentication between Elastic and {sn}. + +. In your {sn} instance, go to *Application Registry* and select *New*. +. Select *Create an OAuth JWT API endpoint for external clients* from the list of options. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-jwt-endpoint.png[Shows application type selection] +-- + +. Configure the application as follows: ++ +-- +* *Name*: Name the application. +* *User field*: Select the field to use as the user identifier. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-application.png[Shows new application form in ServiceNow] + +IMPORTANT: Remember the selected user field. You will use this as the *User Identifier Value* when creating the connector. For example, if you selected *Email* for *User field*, you will use the user's email for the *User Identifier Value*. +-- + +. Click *Submit* to create the application. You will be redirected to the list of applications. +. Select the application you just created. +. Find the *Jwt Verifier Maps* tab and click *New*. +. Configure the new record as follows: ++ +-- +* *Name*: Name the JWT Verifier Map. +* *Sys certificate*: Click the search icon and select the name of the certificate created in the previous step. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-jwt-verifier-map.png[Shows new JWT Verifier Map form in ServiceNow] +-- + +. Click *Submit* to create the application. +. Note the *Client ID*, *Client Secret* and *JWT Key ID*. You will need these values to create your {sn} connector. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-oauth-values.png[Shows where to find OAuth values in ServiceNow] +-- [float] [[servicenow-itom-connector-configuration]] @@ -22,9 +126,16 @@ Create an integration user in {sn} and assign it the following roles. {sn} ITOM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** connector listing, and in the connector list when configuring an action. +Is OAuth:: The type of authentication to use. URL:: {sn} instance URL. Username:: Username for HTTP Basic authentication. Password:: Password for HTTP Basic authentication. +User Identifier:: Identifier to use for OAuth type authentication. This identifier should be the *User field* you selected during setup. For example, if the selected *User field* is *Email*, the user identifier should be the user's email address. +Client ID:: The client ID assigned to your OAuth application. +Client Secret:: The client secret assigned to your OAuth application. +JWT Key ID:: The key ID assigned to the JWT verifier map of your OAuth application. +Private Key:: The RSA private key generated during setup. +Private Key Password:: The password for the RSA private key generated during setup, if set. [float] [[servicenow-itom-connector-networking-configuration]] @@ -36,6 +147,7 @@ Use the <> to customize connecto [[Preconfigured-servicenow-itom-configuration]] ==== Preconfigured connector type +Connector using Basic Authentication [source,text] -- my-servicenow-itom: @@ -48,23 +160,51 @@ Use the <> to customize connecto password: passwordkeystorevalue -- +Connector using OAuth +[source,text] +-- + my-servicenow: + name: preconfigured-oauth-servicenow-connector-type + actionTypeId: .servicenow-itom + config: + apiUrl: https://example.service-now.com/ + usesTableApi: false + isOAuth: true + userIdentifierValue: testuser@email.com + clientId: abcdefghijklmnopqrstuvwxyzabcdef + jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba + secrets: + clientSecret: secretsecret + privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- +-- + Config defines information for the connector type. `apiUrl`:: An address that corresponds to *URL*. +`isOAuth`:: A boolean that corresponds to *Is OAuth* and indicates if the connector uses Basic Authentication or OAuth. +`userIdentifierValue`:: A string that corresponds to *User Identifier*. Required if `isOAuth` is set to `true`. +`clientId`:: A string that corresponds to *Client ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. +`jwtKeyId`:: A string that corresponds to *JWT Key ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. Secrets defines sensitive information for the connector type. -`username`:: A string that corresponds to *Username*. -`password`:: A string that corresponds to *Password*. Should be stored in the <>. +`username`:: A string that corresponds to *Username*. Required if `isOAuth` is set to `false`. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `isOAuth` is set to `false`. +`clientSecret`:: A string that corresponds to *Client Secret*. Required if `isOAuth` is set to `true`. +`privateKey`:: A string that corresponds to *Private Key*. Required if `isOAuth` is set to `true`. +`privateKeyPassword`:: A string that corresponds to *Private Key Password*. [float] [[define-servicenow-itom-ui]] ==== Define connector in Stack Management -Define {sn} ITOM connector properties. +Define {sn} ITOM connector properties. Choose whether to use OAuth for authentication. + +[role="screenshot"] +image::management/connectors/images/servicenow-itom-connector-basic.png[ServiceNow ITOM connector using basic auth] [role="screenshot"] -image::management/connectors/images/servicenow-itom-connector.png[ServiceNow ITOM connector] +image::management/connectors/images/servicenow-itom-connector-oauth.png[ServiceNow ITOM connector using OAuth] Test {sn} ITOM action parameters. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 81db72be0fb38..a3618d626d8be 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -16,7 +16,13 @@ After upgrading from {stack} version 7.15.0 or earlier to version 7.16.0 or late * Create a {sn} integration user and assign it the appropriate roles. * Create a Cross-Origin Resource Sharing (CORS) rule. -*Create a {sn} integration user* +If you use open authorization (OAuth), you must also: + +* Create an RSA keypair and add an X.509 Certificate. +* Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map. + +[float] +===== Create a {sn} integration user To ensure authenticated communication between Elastic and {sn}, create a {sn} integration user and assign it the appropriate roles.ย  @@ -32,7 +38,8 @@ To ensure authenticated communication between Elastic and {sn}, create a {sn} in * `x_elas2_sir_int.integration_user` . Click *Save*. -*Create a CORS rule* +[float] +===== Create a CORS rule A CORS rule is required for communication between Elastic and {sn}. To create a CORS rule: @@ -45,6 +52,94 @@ A CORS rule is required for communication between Elastic and {sn}. To create a . Go to the *HTTP methods* tab and select *GET*. . Click *Submit* to create the rule. +[float] +===== Create an RSA keypair and add an X.509 Certificate + +This step is required to use OAuth for authentication between Elastic and {sn}. + +*Create an RSA keypair:* + +. Use https://www.openssl.org/docs/man1.0.2/man1/genrsa.html[OpenSSL] to generate an RSA private key: ++ +-- +[source,sh] +---- +openssl genrsa -out example-private-key.pem 3072 +openssl genrsa -passout pass:foobar -out example-private-key-with-password.pem 3072 <1> +---- +<1> Use the `passout` option to set a password on your private key. This is optional but remember your password if you set one. +-- + +. Use https://www.openssl.org/docs/man1.0.2/man1/req.html[OpenSSL] to generate the matching public key: ++ +-- +[source,sh] +---- +openssl req -new -x509 -key example-private-key.pem -out example-sn-cert.pem -days 360 +---- +-- + +*Add an X.509 Certificate to ServiceNow:* + +. In your {sn} instance, go to *Certificates* and select *New*. +. Configure the certificate as follows: ++ +-- +* *Name*: Name the certificate. +* *PEM Certificate*: Copy the generated public key into this text field. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-certificate.png[Shows new certificate form in ServiceNow] +-- + +. Click *Submit* to create the certificate. + +[float] +===== Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map + +This step is required to use OAuth for authentication between Elastic and {sn}. + +. In your {sn} instance, go to *Application Registry* and select *New*. +. Select *Create an OAuth JWT API endpoint for external clients* from the list of options. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-jwt-endpoint.png[Shows application type selection] +-- + +. Configure the application as follows: ++ +-- +* *Name*: Name the application. +* *User field*: Select the field to use as the user identifier. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-application.png[Shows new application form in ServiceNow] + +IMPORTANT: Remember the selected user field. You will use this as the *User Identifier Value* when creating the connector. For example, if you selected *Email* for *User field*, you will use the user's email for the *User Identifier Value*. +-- + +. Click *Submit* to create the application. You will be redirected to the list of applications. +. Select the application you just created. +. Find the *Jwt Verifier Maps* tab and click *New*. +. Configure the new record as follows: ++ +-- +* *Name*: Name the JWT Verifier Map. +* *Sys certificate*: Click the search icon and select the name of the certificate created in the previous step. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-jwt-verifier-map.png[Shows new JWT Verifier Map form in ServiceNow] +-- + +. Click *Submit* to create the verifier map. +. Note the *Client ID*, *Client Secret* and *JWT Key ID*. You will need these values to create your {sn} connector. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-oauth-values.png[Shows where to find OAuth values in ServiceNow] +-- + [float] [[servicenow-sir-connector-update]] ==== Update a deprecated {sn} SecOps connector @@ -74,9 +169,16 @@ To update a deprecated connector: {sn} SecOps connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +Is OAuth:: The type of authentication to use. URL:: {sn} instance URL. Username:: Username for HTTP Basic authentication. Password:: Password for HTTP Basic authentication. +User Identifier:: Identifier to use for OAuth type authentication. This identifier should be the *User field* you selected during setup. For example, if the selected *User field* is *Email*, the user identifier should be the user's email address. +Client ID:: The client ID assigned to your OAuth application. +Client Secret:: The client secret assigned to your OAuth application. +JWT Key ID:: The key ID assigned to the JWT verifier map of your OAuth application. +Private Key:: The RSA private key generated during setup. +Private Key Password:: The password for the RSA private key generated during setup, if set. [float] [[servicenow-sir-connector-networking-configuration]] @@ -88,6 +190,7 @@ Use the <> to customize connecto [[Preconfigured-servicenow-sir-configuration]] ==== Preconfigured connector type +Connector using Basic Authentication [source,text] -- my-servicenow-sir: @@ -101,6 +204,24 @@ Use the <> to customize connecto password: passwordkeystorevalue -- +Connector using OAuth +[source,text] +-- + my-servicenow: + name: preconfigured-oauth-servicenow-connector-type + actionTypeId: .servicenow-sir + config: + apiUrl: https://example.service-now.com/ + usesTableApi: false + isOAuth: true + userIdentifierValue: testuser@email.com + clientId: abcdefghijklmnopqrstuvwxyzabcdef + jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba + secrets: + clientSecret: secretsecret + privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- +-- + Config defines information for the connector type. `apiUrl`:: An address that corresponds to *URL*. @@ -108,19 +229,30 @@ Config defines information for the connector type. NOTE: If `usesTableApi` is set to false, the Elastic application should be installed in {sn}. +`isOAuth`:: A boolean that corresponds to *Is OAuth* and indicates if the connector uses Basic Authentication or OAuth. +`userIdentifierValue`:: A string that corresponds to *User Identifier*. Required if `isOAuth` is set to `true`. +`clientId`:: A string that corresponds to *Client ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. +`jwtKeyId`:: A string that corresponds to *JWT Key ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. + Secrets defines sensitive information for the connector type. -`username`:: A string that corresponds to *Username*. -`password`:: A string that corresponds to *Password*. Should be stored in the <>. +`username`:: A string that corresponds to *Username*. Required if `isOAuth` is set to `false`. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `isOAuth` is set to `false`. +`clientSecret`:: A string that corresponds to *Client Secret*. Required if `isOAuth` is set to `true`. +`privateKey`:: A string that corresponds to *Private Key*. Required if `isOAuth` is set to `true`. +`privateKeyPassword`:: A string that corresponds to *Private Key Password*. [float] [[define-servicenow-sir-ui]] ==== Define connector in Stack Management -Define {sn} SecOps connector properties. +Define {sn} SecOps connector properties. Choose whether to use OAuth for authentication. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-connector-basic.png[ServiceNow SecOps connector using basic auth] [role="screenshot"] -image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector] +image::management/connectors/images/servicenow-sir-connector-oauth.png[ServiceNow SecOps connector using OAuth] Test {sn} SecOps action parameters. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 333a26c075c49..99ed4f0bec32f 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -16,7 +16,13 @@ After upgrading from {stack} version 7.15.0 or earlier to version 7.16.0 or late * Create a {sn} integration user and assign it the appropriate roles. * Create a Cross-Origin Resource Sharing (CORS) rule. -*Create a {sn} integration user* +If you use open authorization (OAuth), you must also: + +* Create an RSA keypair and add an X.509 Certificate. +* Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map. + +[float] +===== Create a {sn} integration user To ensure authenticated communication between Elastic and {sn}, create a {sn} integration user and assign it the appropriate roles. @@ -31,7 +37,8 @@ To ensure authenticated communication between Elastic and {sn}, create a {sn} in * `x_elas2_inc_int.integration_user` . Click *Save*. -*Create a CORS rule* +[float] +===== Create a CORS rule A CORS rule is required for communication between Elastic and {sn}. To create a CORS rule: @@ -44,6 +51,94 @@ A CORS rule is required for communication between Elastic and {sn}. To create a . Go to the *HTTP methods* tab and select *GET*. . Click *Submit* to create the rule. +[float] +===== Create an RSA keypair and add an X.509 Certificate + +This step is required to use OAuth for authentication between Elastic and {sn}. + +*Create an RSA keypair:* + +. Use https://www.openssl.org/docs/man1.0.2/man1/genrsa.html[OpenSSL] to generate an RSA private key: ++ +-- +[source,sh] +---- +openssl genrsa -out example-private-key.pem 3072 +openssl genrsa -passout pass:foobar -out example-private-key-with-password.pem 3072 <1> +---- +<1> Use the `passout` option to set a password on your private key. This is optional but remember your password if you set one. +-- + +. Use https://www.openssl.org/docs/man1.0.2/man1/req.html[OpenSSL] to generate the matching public key: ++ +-- +[source,sh] +---- +openssl req -new -x509 -key example-private-key.pem -out example-sn-cert.pem -days 360 +---- +-- + +*Add an X.509 Certificate to ServiceNow:* + +. In your {sn} instance, go to *Certificates* and select *New*. +. Configure the certificate as follows: ++ +-- +* *Name*: Name the certificate. +* *PEM Certificate*: Copy the generated public key into this text field. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-certificate.png[Shows new certificate form in ServiceNow] +-- + +. Click *Submit* to create the certificate. + +[float] +===== Create an OAuth JWT API endpoint for external clients with a JWT Verifiers Map + +This step is required to use OAuth for authentication between Elastic and {sn}. + +. In your {sn} instance, go to *Application Registry* and select *New*. +. Select *Create an OAuth JWT API endpoint for external clients* from the list of options. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-jwt-endpoint.png[Shows application type selection] +-- + +. Configure the application as follows: ++ +-- +* *Name*: Name the application. +* *User field*: Select the field to use as the user identifier. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-application.png[Shows new application form in ServiceNow] + +IMPORTANT: Remember the selected user field. You will use this as the *User Identifier Value* when creating the connector. For example, if you selected *Email* for *User field*, you will use the user's email for the *User Identifier Value*. +-- + +. Click *Submit* to create the application. You will be redirected to the list of applications. +. Select the application you just created. +. Find the *Jwt Verifier Maps* tab and click *New*. +. Configure the new record as follows: ++ +-- +* *Name*: Name the JWT Verifier Map. +* *Sys certificate*: Click the search icon and select the name of the certificate created in the previous step. + +[role="screenshot"] +image::management/connectors/images/servicenow-new-jwt-verifier-map.png[Shows new JWT Verifier Map form in ServiceNow] +-- + +. Click *Submit* to create the verifier map. +. Note the *Client ID*, *Client Secret* and *JWT Key ID*. You will need these values to create your {sn} connector. ++ +-- +[role="screenshot"] +image::management/connectors/images/servicenow-oauth-values.png[Shows where to find OAuth values in ServiceNow] +-- + [float] [[servicenow-itsm-connector-update]] ==== Update a deprecated {sn} ITSM connector @@ -73,9 +168,16 @@ To update a deprecated connector: {sn} ITSM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +Is OAuth:: The type of authentication to use. URL:: {sn} instance URL. Username:: Username for HTTP Basic authentication. Password:: Password for HTTP Basic authentication. +User Identifier:: Identifier to use for OAuth type authentication. This identifier should be the *User field* you selected during setup. For example, if the selected *User field* is *Email*, the user identifier should be the user's email address. +Client ID:: The client ID assigned to your OAuth application. +Client Secret:: The client secret assigned to your OAuth application. +JWT Key ID:: The key ID assigned to the JWT Verifier Map of your OAuth application. +Private Key:: The RSA private key generated during setup. +Private Key Password:: The password for the RSA private key generated during setup, if set. [float] [[servicenow-connector-networking-configuration]] @@ -87,6 +189,7 @@ Use the <> to customize connecto [[Preconfigured-servicenow-configuration]] ==== Preconfigured connector type +Connector using Basic Authentication [source,text] -- my-servicenow: @@ -100,6 +203,24 @@ Use the <> to customize connecto password: passwordkeystorevalue -- +Connector using OAuth +[source,text] +-- + my-servicenow: + name: preconfigured-oauth-servicenow-connector-type + actionTypeId: .servicenow + config: + apiUrl: https://example.service-now.com/ + usesTableApi: false + isOAuth: true + userIdentifierValue: testuser@email.com + clientId: abcdefghijklmnopqrstuvwxyzabcdef + jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba + secrets: + clientSecret: secretsecret + privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- +-- + Config defines information for the connector type. `apiUrl`:: An address that corresponds to *URL*. @@ -107,19 +228,30 @@ Config defines information for the connector type. NOTE: If `usesTableApi` is set to false, the Elastic application should be installed in {sn}. +`isOAuth`:: A boolean that corresponds to *Is OAuth* and indicates if the connector uses Basic Authentication or OAuth. +`userIdentifierValue`:: A string that corresponds to *User Identifier*. Required if `isOAuth` is set to `true`. +`clientId`:: A string that corresponds to *Client ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. +`jwtKeyId`:: A string that corresponds to *JWT Key ID*, used for OAuth authentication. Required if `isOAuth` is set to `true`. + Secrets defines sensitive information for the connector type. -`username`:: A string that corresponds to *Username*. -`password`:: A string that corresponds to *Password*. Should be stored in the <>. +`username`:: A string that corresponds to *Username*. Required if `isOAuth` is set to `false`. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `isOAuth` is set to `false`. +`clientSecret`:: A string that corresponds to *Client Secret*. Required if `isOAuth` is set to `true`. +`privateKey`:: A string that corresponds to *Private Key*. Required if `isOAuth` is set to `true`. +`privateKeyPassword`:: A string that corresponds to *Private Key Password*. [float] [[define-servicenow-ui]] ==== Define connector in Stack Management -Define {sn} ITSM connector properties. +Define {sn} ITSM connector properties. Choose whether to use OAuth for authentication. + +[role="screenshot"] +image::management/connectors/images/servicenow-connector-basic.png[ServiceNow connector using basic auth] [role="screenshot"] -image::management/connectors/images/servicenow-connector.png[ServiceNow connector] +image::management/connectors/images/servicenow-connector-oauth.png[ServiceNow connector using OAuth] Test {sn} ITSM action parameters. diff --git a/docs/management/connectors/images/servicenow-connector-basic.png b/docs/management/connectors/images/servicenow-connector-basic.png new file mode 100644 index 0000000000000..e2bf73ad14594 Binary files /dev/null and b/docs/management/connectors/images/servicenow-connector-basic.png differ diff --git a/docs/management/connectors/images/servicenow-connector-oauth.png b/docs/management/connectors/images/servicenow-connector-oauth.png new file mode 100644 index 0000000000000..f0124d39cfde9 Binary files /dev/null and b/docs/management/connectors/images/servicenow-connector-oauth.png differ diff --git a/docs/management/connectors/images/servicenow-connector.png b/docs/management/connectors/images/servicenow-connector.png deleted file mode 100644 index cb74e8abcfba8..0000000000000 Binary files a/docs/management/connectors/images/servicenow-connector.png and /dev/null differ diff --git a/docs/management/connectors/images/servicenow-itom-connector-basic.png b/docs/management/connectors/images/servicenow-itom-connector-basic.png new file mode 100644 index 0000000000000..fb5c73bb82785 Binary files /dev/null and b/docs/management/connectors/images/servicenow-itom-connector-basic.png differ diff --git a/docs/management/connectors/images/servicenow-itom-connector-oauth.png b/docs/management/connectors/images/servicenow-itom-connector-oauth.png new file mode 100644 index 0000000000000..2e5d2c430d1ae Binary files /dev/null and b/docs/management/connectors/images/servicenow-itom-connector-oauth.png differ diff --git a/docs/management/connectors/images/servicenow-itom-connector.png b/docs/management/connectors/images/servicenow-itom-connector.png deleted file mode 100644 index 5b73336d21b47..0000000000000 Binary files a/docs/management/connectors/images/servicenow-itom-connector.png and /dev/null differ diff --git a/docs/management/connectors/images/servicenow-jwt-endpoint.png b/docs/management/connectors/images/servicenow-jwt-endpoint.png new file mode 100644 index 0000000000000..6fcd8b1fd404f Binary files /dev/null and b/docs/management/connectors/images/servicenow-jwt-endpoint.png differ diff --git a/docs/management/connectors/images/servicenow-new-application.png b/docs/management/connectors/images/servicenow-new-application.png new file mode 100644 index 0000000000000..a64b8df91509e Binary files /dev/null and b/docs/management/connectors/images/servicenow-new-application.png differ diff --git a/docs/management/connectors/images/servicenow-new-certificate.png b/docs/management/connectors/images/servicenow-new-certificate.png new file mode 100644 index 0000000000000..111ae0415103a Binary files /dev/null and b/docs/management/connectors/images/servicenow-new-certificate.png differ diff --git a/docs/management/connectors/images/servicenow-new-jwt-verifier-map.png b/docs/management/connectors/images/servicenow-new-jwt-verifier-map.png new file mode 100644 index 0000000000000..4dc089e938234 Binary files /dev/null and b/docs/management/connectors/images/servicenow-new-jwt-verifier-map.png differ diff --git a/docs/management/connectors/images/servicenow-oauth-values.png b/docs/management/connectors/images/servicenow-oauth-values.png new file mode 100644 index 0000000000000..adb6a01e9645b Binary files /dev/null and b/docs/management/connectors/images/servicenow-oauth-values.png differ diff --git a/docs/management/connectors/images/servicenow-sir-connector-basic.png b/docs/management/connectors/images/servicenow-sir-connector-basic.png new file mode 100644 index 0000000000000..95cefce154494 Binary files /dev/null and b/docs/management/connectors/images/servicenow-sir-connector-basic.png differ diff --git a/docs/management/connectors/images/servicenow-sir-connector-oauth.png b/docs/management/connectors/images/servicenow-sir-connector-oauth.png new file mode 100644 index 0000000000000..3b02087b5a49a Binary files /dev/null and b/docs/management/connectors/images/servicenow-sir-connector-oauth.png differ diff --git a/docs/management/connectors/images/servicenow-sir-connector.png b/docs/management/connectors/images/servicenow-sir-connector.png deleted file mode 100644 index 71c7ce5ed05f5..0000000000000 Binary files a/docs/management/connectors/images/servicenow-sir-connector.png and /dev/null differ diff --git a/docs/setup/upgrade/resolving-migration-failures.asciidoc b/docs/setup/upgrade/resolving-migration-failures.asciidoc index 7cbc972b102ab..2c3f66f2354dd 100644 --- a/docs/setup/upgrade/resolving-migration-failures.asciidoc +++ b/docs/setup/upgrade/resolving-migration-failures.asciidoc @@ -187,3 +187,10 @@ PUT /_cluster/settings } } -------------------------------------------- + +[float] +[[cluster-shard-limit-exceeded]] +==== {es} cluster shard limit exceeded +When upgrading, {kib} creates new indices requiring a small number of new shards. If the amount of open {es} shards approaches or exceeds the {es} `cluster.max_shards_per_node` setting, {kib} is unable to complete the upgrade. Ensure that {kib} is able to add at least 10 more shards by removing indices to clear up resources, or by increasing the `cluster.max_shards_per_node` setting. + +For more information, refer to the documentation on {ref}/allocation-total-shards.html[total shards per node]. \ No newline at end of file diff --git a/package.json b/package.json index 74e4b3e211504..e018660238259 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,8 @@ "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", "@dnd-kit/utilities": "^2.0.0", - "@elastic/apm-rum": "^5.11.0", - "@elastic/apm-rum-react": "^1.4.0", + "@elastic/apm-rum": "^5.11.1", + "@elastic/apm-rum-react": "^1.4.1", "@elastic/apm-synthtrace": "link:bazel-bin/packages/elastic-apm-synthtrace", "@elastic/charts": "46.0.1", "@elastic/datemath": "5.0.3", @@ -470,7 +470,6 @@ "@cypress/code-coverage": "^3.9.12", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", @@ -493,6 +492,7 @@ "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", "@kbn/es": "link:bazel-bin/packages/kbn-es", "@kbn/es-archiver": "link:bazel-bin/packages/kbn-es-archiver", + "@kbn/eslint-config": "link:bazel-bin/packages/kbn-eslint-config", "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", "@kbn/expect": "link:bazel-bin/packages/kbn-expect", "@kbn/find-used-node-modules": "link:bazel-bin/packages/kbn-find-used-node-modules", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 5f435f583a36a..5a06233d0e72d 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -15,7 +15,6 @@ filegroup( "//packages/analytics/shippers/elastic_v3/server:build", "//packages/analytics/shippers/fullstory:build", "//packages/elastic-apm-synthtrace:build", - "//packages/elastic-eslint-config-kibana:build", "//packages/elastic-safer-lodash-set:build", "//packages/kbn-ace:build", "//packages/kbn-alerts:build", @@ -43,6 +42,7 @@ filegroup( "//packages/kbn-es-archiver:build", "//packages/kbn-es-query:build", "//packages/kbn-es:build", + "//packages/kbn-eslint-config:build", "//packages/kbn-eslint-plugin-eslint:build", "//packages/kbn-eslint-plugin-imports:build", "//packages/kbn-expect:build", diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts index f117fc879c0e7..1e972b5663113 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -85,6 +85,10 @@ export type ApmFields = Fields & 'span.destination.service.response_time.count': number; 'span.self_time.count': number; 'span.self_time.sum.us': number; + 'span.links': Array<{ + trace: { id: string }; + span: { id: string }; + }>; 'cloud.provider': string; 'cloud.project.name': string; 'cloud.service.name': string; diff --git a/packages/elastic-apm-synthtrace/src/scripts/examples/04_span_links.ts b/packages/elastic-apm-synthtrace/src/scripts/examples/04_span_links.ts new file mode 100644 index 0000000000000..f73a3b4f4fb49 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/scripts/examples/04_span_links.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { compact, shuffle } from 'lodash'; +import { apm, ApmFields, EntityArrayIterable, timerange } from '../..'; +import { generateLongId, generateShortId } from '../../lib/utils/generate_id'; +import { Scenario } from '../scenario'; + +function generateExternalSpanLinks() { + // randomly creates external span links 0 - 10 + return Array(Math.floor(Math.random() * 11)) + .fill(0) + .map(() => ({ span: { id: generateLongId() }, trace: { id: generateShortId() } })); +} + +function getSpanLinksFromEvents(events: ApmFields[]) { + return compact( + events.map((event) => { + const spanId = event['span.id']; + return spanId ? { span: { id: spanId }, trace: { id: event['trace.id']! } } : undefined; + }) + ); +} + +const scenario: Scenario = async () => { + return { + generate: ({ from, to }) => { + const producerInternalOnlyInstance = apm + .service('producer-internal-only', 'production', 'go') + .instance('instance-a'); + const producerInternalOnlyEvents = timerange( + new Date('2022-04-25T19:00:00.000Z'), + new Date('2022-04-25T19:01:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerInternalOnlyInstance + .transaction('Transaction A') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerInternalOnlyInstance + .span('Span A', 'custom') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const producerInternalOnlyApmFields = producerInternalOnlyEvents.toArray(); + const spanASpanLink = getSpanLinksFromEvents(producerInternalOnlyApmFields); + + const producerConsumerInstance = apm + .service('producer-consumer', 'production', 'java') + .instance('instance-b'); + const producerConsumerEvents = timerange(from, to) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerConsumerInstance + .transaction('Transaction B') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerConsumerInstance + .span('Span B', 'external') + .defaults({ + 'span.links': shuffle([...generateExternalSpanLinks(), ...spanASpanLink]), + }) + .timestamp(timestamp + 50) + .duration(900) + .success() + ); + }); + + const producerConsumerApmFields = producerConsumerEvents.toArray(); + const spanBSpanLink = getSpanLinksFromEvents(producerConsumerApmFields); + + const consumerInstance = apm.service('consumer', 'production', 'ruby').instance('instance-c'); + const consumerEvents = timerange(from, to) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return consumerInstance + .transaction('Transaction C') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + consumerInstance + .span('Span C', 'external') + .defaults({ 'span.links': spanBSpanLink }) + .timestamp(timestamp + 50) + .duration(900) + .success() + ); + }); + + return new EntityArrayIterable(producerInternalOnlyApmFields) + .merge(consumerEvents) + .merge(new EntityArrayIterable(producerConsumerApmFields)); + }, + }; +}; + +export default scenario; diff --git a/packages/elastic-eslint-config-kibana/.npmignore b/packages/elastic-eslint-config-kibana/.npmignore deleted file mode 100644 index 2ba159593147d..0000000000000 --- a/packages/elastic-eslint-config-kibana/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -.eslintrc.yaml -tasks diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index 53052809b6b2f..1c5cf2af81f0f 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -9,7 +9,7 @@ module.exports = { /** * Synchronized regex list of files that use `styled-components`. - * Used by `kbn-babel-preset` and `elastic-eslint-config-kibana`. + * Used by `kbn-babel-preset` and `kbn-eslint-config`. */ USES_STYLED_COMPONENTS: [ /packages[\/\\]kbn-ui-shared-deps-(npm|src)[\/\\]/, diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 7b3bc99f916b7..55909e360b0e5 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -444,6 +444,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { guide: `${KIBANA_DOCS}maps.html`, importGeospatialPrivileges: `${KIBANA_DOCS}import-geospatial-data.html#import-geospatial-privileges`, gdalTutorial: `${ELASTIC_WEBSITE_URL}blog/how-to-ingest-geospatial-data-into-elasticsearch-with-gdal`, + termJoinsExample: `${KIBANA_DOCS}terms-join.html#_example_term_join`, }, monitoring: { alertsKibana: `${KIBANA_DOCS}kibana-alerts.html`, @@ -652,6 +653,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { resolveMigrationFailures: `${KIBANA_DOCS}resolve-migrations-failures.html`, repeatedTimeoutRequests: `${KIBANA_DOCS}resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail`, routingAllocationDisabled: `${KIBANA_DOCS}resolve-migrations-failures.html#routing-allocation-disabled`, + clusterShardLimitExceeded: `${KIBANA_DOCS}resolve-migrations-failures.html#cluster-shard-limit-exceeded`, }, }); }; diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index fdbff542456ea..c492509e80511 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -310,6 +310,7 @@ export interface DocLinks { guide: string; importGeospatialPrivileges: string; gdalTutorial: string; + termJoinsExample: string; }>; readonly monitoring: Record; readonly reporting: Readonly<{ @@ -407,5 +408,6 @@ export interface DocLinks { readonly resolveMigrationFailures: string; readonly repeatedTimeoutRequests: string; readonly routingAllocationDisabled: string; + readonly clusterShardLimitExceeded: string; }; } diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/kbn-eslint-config/.eslintrc.js similarity index 100% rename from packages/elastic-eslint-config-kibana/.eslintrc.js rename to packages/kbn-eslint-config/.eslintrc.js diff --git a/packages/elastic-eslint-config-kibana/.gitignore b/packages/kbn-eslint-config/.gitignore similarity index 100% rename from packages/elastic-eslint-config-kibana/.gitignore rename to packages/kbn-eslint-config/.gitignore diff --git a/packages/elastic-eslint-config-kibana/BUILD.bazel b/packages/kbn-eslint-config/BUILD.bazel similarity index 88% rename from packages/elastic-eslint-config-kibana/BUILD.bazel rename to packages/kbn-eslint-config/BUILD.bazel index 9dceec268418b..6eb7ff7c723ac 100644 --- a/packages/elastic-eslint-config-kibana/BUILD.bazel +++ b/packages/kbn-eslint-config/BUILD.bazel @@ -1,8 +1,8 @@ load("@build_bazel_rules_nodejs//:index.bzl", "js_library") load("//src/dev/bazel:index.bzl", "pkg_npm") -PKG_BASE_NAME = "elastic-eslint-config-kibana" -PKG_REQUIRE_NAME = "@elastic/eslint-config-kibana" +PKG_BASE_NAME = "kbn-eslint-config" +PKG_REQUIRE_NAME = "@kbn/eslint-config" SOURCE_FILES = glob([ ".eslintrc.js", @@ -22,7 +22,6 @@ filegroup( NPM_MODULE_EXTRA_FILES = [ "package.json", - "README.md", ] RUNTIME_DEPS = [ diff --git a/packages/elastic-eslint-config-kibana/README.md b/packages/kbn-eslint-config/README.md similarity index 68% rename from packages/elastic-eslint-config-kibana/README.md rename to packages/kbn-eslint-config/README.md index 2049440cd8ff7..cca5551a07aba 100644 --- a/packages/elastic-eslint-config-kibana/README.md +++ b/packages/kbn-eslint-config/README.md @@ -10,7 +10,7 @@ in your `.eslintrc`: ```javascript { extends: [ - '@elastic/eslint-config-kibana' + '@kbn/eslint-config' ] } ``` @@ -18,14 +18,14 @@ in your `.eslintrc`: ## Optional jest config If the project uses the [jest test runner](https://facebook.github.io/jest/), -the `@elastic/eslint-config-kibana/jest` config can be extended as well to use +the `@kbn/eslint-config/jest` config can be extended as well to use `eslint-plugin-jest` and add settings specific to it: ```javascript { extends: [ - '@elastic/eslint-config-kibana', - '@elastic/eslint-config-kibana/jest' + '@kbn/eslint-config', + '@kbn/eslint-config/jest' ] } ``` diff --git a/packages/elastic-eslint-config-kibana/javascript.js b/packages/kbn-eslint-config/javascript.js similarity index 100% rename from packages/elastic-eslint-config-kibana/javascript.js rename to packages/kbn-eslint-config/javascript.js diff --git a/packages/elastic-eslint-config-kibana/jest.js b/packages/kbn-eslint-config/jest.js similarity index 100% rename from packages/elastic-eslint-config-kibana/jest.js rename to packages/kbn-eslint-config/jest.js diff --git a/packages/elastic-eslint-config-kibana/package.json b/packages/kbn-eslint-config/package.json similarity index 78% rename from packages/elastic-eslint-config-kibana/package.json rename to packages/kbn-eslint-config/package.json index a5007de28584c..eb9f7a4b08246 100644 --- a/packages/elastic-eslint-config-kibana/package.json +++ b/packages/kbn-eslint-config/package.json @@ -1,6 +1,6 @@ { - "name": "@elastic/eslint-config-kibana", - "version": "0.15.0", + "name": "@kbn/eslint-config", + "version": "1.0.0", "description": "The eslint config used by the kibana team", "main": ".eslintrc.js", "repository": { @@ -14,7 +14,7 @@ "author": "Spencer Alger ", "license": "Apache-2.0", "bugs": { - "url": "https://github.com/elastic/kibana/tree/main/packages/elastic-eslint-config-kibana" + "url": "https://github.com/elastic/kibana/tree/main/packages/kbn-eslint-config" }, - "homepage": "https://github.com/elastic/kibana/tree/main/packages/elastic-eslint-config-kibana" + "homepage": "https://github.com/elastic/kibana/tree/main/packages/kbn-eslint-config" } \ No newline at end of file diff --git a/packages/elastic-eslint-config-kibana/react.js b/packages/kbn-eslint-config/react.js similarity index 100% rename from packages/elastic-eslint-config-kibana/react.js rename to packages/kbn-eslint-config/react.js diff --git a/packages/elastic-eslint-config-kibana/restricted_globals.js b/packages/kbn-eslint-config/restricted_globals.js similarity index 100% rename from packages/elastic-eslint-config-kibana/restricted_globals.js rename to packages/kbn-eslint-config/restricted_globals.js diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/kbn-eslint-config/typescript.js similarity index 100% rename from packages/elastic-eslint-config-kibana/typescript.js rename to packages/kbn-eslint-config/typescript.js diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c9460f7bab4ea..561007cb33b23 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -58,7 +58,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 104400 + triggersActionsUi: 105800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 @@ -128,5 +128,5 @@ pageLoadAssetSize: eventAnnotation: 19334 screenshotting: 22870 synthetics: 40958 - expressionXY: 30000 + expressionXY: 31000 kibanaUsageCollection: 16463 diff --git a/packages/kbn-plugin-generator/template/.eslintrc.js.ejs b/packages/kbn-plugin-generator/template/.eslintrc.js.ejs index d063fc481b718..5a65d0cfb8e02 100644 --- a/packages/kbn-plugin-generator/template/.eslintrc.js.ejs +++ b/packages/kbn-plugin-generator/template/.eslintrc.js.ejs @@ -1,7 +1,7 @@ module.exports = { root: true, extends: [ - '@elastic/eslint-config-kibana', + '@kbn/eslint-config', 'plugin:@elastic/eui/recommended' ], rules: { diff --git a/packages/kbn-pm/README.md b/packages/kbn-pm/README.md index eb1ac6ffa92aa..33d1fe6590f4b 100644 --- a/packages/kbn-pm/README.md +++ b/packages/kbn-pm/README.md @@ -19,7 +19,7 @@ From a plugin perspective there are two different types of Kibana dependencies: runtime and static dependencies. Runtime dependencies are things that are instantiated at runtime and that are injected into the plugin, for example config and elasticsearch clients. Static dependencies are those dependencies -that we want to `import`. `elastic-eslint-config-kibana` is one example of this, and +that we want to `import`. `kbn-eslint-config` is one example of this, and it's actually needed because eslint requires it to be a separate package. But we also have dependencies like `datemath`, `flot`, `eui` and others that we control, but where we want to `import` them in plugins instead of injecting them diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx index bffa7c2a5269c..3131b6ab2a73c 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx @@ -23,8 +23,8 @@ export interface Props { emptyPromptColor?: EuiEmptyPromptProps['color']; } -const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPage.addDataViewText', { - defaultMessage: 'Create Data View', +const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPrompt.addDataViewText', { + defaultMessage: 'Create data view', }); // Using raw value because it is content dependent @@ -50,39 +50,55 @@ export const NoDataViews = ({ ); + const title = canCreateNewDataView ? ( +

+ +
+ +

+ ) : ( +

+ +

+ ); + + const body = canCreateNewDataView ? ( +

+ +

+ ) : ( +

+ +

+ ); + return ( } - title={ -

- -
- -

- } - body={ -

- -

- } + title={title} + body={body} actions={createNewButton} footer={dataViewsDocLink && } /> diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx index 19fefd87aa889..8d0e6d93275e1 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx @@ -61,6 +61,7 @@ export const NoDataViews = ({ onDataViewCreated }: Props) => { onSave: (dataView) => { onDataViewCreated(dataView); }, + showEmptyPrompt: false, }); if (setDataViewEditorRef) { diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss index 91b96641047e8..c21f5e1bbab99 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss @@ -10,6 +10,9 @@ $euiSideNavEmphasizedBackgroundColor: transparentize($euiColorLightShade, .7); @include euiSideNavEmbellish; @include euiYScroll; + display: flex; + flex-direction: column; + @include euiBreakpoint('m' ,'l', 'xl') { width: 248px; padding: $euiSizeL; diff --git a/packages/kbn-shared-ux-services/src/services/editors.ts b/packages/kbn-shared-ux-services/src/services/editors.ts index 4dc5b7d9bc269..02d5b13d5d512 100644 --- a/packages/kbn-shared-ux-services/src/services/editors.ts +++ b/packages/kbn-shared-ux-services/src/services/editors.ts @@ -24,6 +24,8 @@ type DataView = unknown; interface DataViewEditorOptions { /** Handler to be invoked when the Data View Editor completes a save operation. */ onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; } /** diff --git a/packages/shared-ux/page/analytics_no_data/src/services.tsx b/packages/shared-ux/page/analytics_no_data/src/services.tsx index 70ba29ed2f648..7868014749997 100644 --- a/packages/shared-ux/page/analytics_no_data/src/services.tsx +++ b/packages/shared-ux/page/analytics_no_data/src/services.tsx @@ -27,6 +27,8 @@ type DataView = unknown; interface DataViewEditorOptions { /** Handler to be invoked when the Data View Editor completes a save operation. */ onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; } /** diff --git a/src/core/server/saved_objects/migrations/__snapshots__/migrations_state_action_machine.test.ts.snap b/src/core/server/saved_objects/migrations/__snapshots__/migrations_state_action_machine.test.ts.snap index e6e1fc2cdc21d..971e2d9129d47 100644 --- a/src/core/server/saved_objects/migrations/__snapshots__/migrations_state_action_machine.test.ts.snap +++ b/src/core/server/saved_objects/migrations/__snapshots__/migrations_state_action_machine.test.ts.snap @@ -33,6 +33,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", @@ -204,6 +205,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", @@ -379,6 +381,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", @@ -558,6 +561,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", @@ -763,6 +767,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", @@ -945,6 +950,7 @@ Object { ], "maxBatchSizeBytes": 100000000, "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", diff --git a/src/core/server/saved_objects/migrations/actions/clone_index.ts b/src/core/server/saved_objects/migrations/actions/clone_index.ts index c9496ec6915ca..c64b715468c28 100644 --- a/src/core/server/saved_objects/migrations/actions/clone_index.ts +++ b/src/core/server/saved_objects/migrations/actions/clone_index.ts @@ -23,6 +23,8 @@ import { INDEX_NUMBER_OF_SHARDS, WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, } from './constants'; +import { isClusterShardLimitExceeded } from './es_errors'; +import { ClusterShardLimitExceeded } from './create_index'; export type CloneIndexResponse = AcknowledgeResponse; /** @internal */ @@ -49,11 +51,11 @@ export const cloneIndex = ({ target, timeout = DEFAULT_TIMEOUT, }: CloneIndexParams): TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound | IndexNotYellowTimeout, + RetryableEsClientError | IndexNotFound | IndexNotYellowTimeout | ClusterShardLimitExceeded, CloneIndexResponse > => { const cloneTask: TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, + RetryableEsClientError | IndexNotFound | ClusterShardLimitExceeded, AcknowledgeResponse > = () => { return client.indices @@ -113,6 +115,10 @@ export const cloneIndex = ({ acknowledged: true, shardsAcknowledged: false, }); + } else if (isClusterShardLimitExceeded(error?.body?.error)) { + return Either.left({ + type: 'cluster_shard_limit_exceeded' as const, + }); } else { throw error; } diff --git a/src/core/server/saved_objects/migrations/actions/create_index.ts b/src/core/server/saved_objects/migrations/actions/create_index.ts index 69d47077c0606..3766a470984f5 100644 --- a/src/core/server/saved_objects/migrations/actions/create_index.ts +++ b/src/core/server/saved_objects/migrations/actions/create_index.ts @@ -23,6 +23,7 @@ import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, } from './constants'; import { IndexNotYellowTimeout, waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { isClusterShardLimitExceeded } from './es_errors'; function aliasArrayToRecord(aliases: string[]): Record { const result: Record = {}; @@ -32,6 +33,11 @@ function aliasArrayToRecord(aliases: string[]): Record => { const createIndexTask: TaskEither.TaskEither< - RetryableEsClientError, + RetryableEsClientError | ClusterShardLimitExceeded, AcknowledgeResponse > = () => { const aliasesObject = aliasArrayToRecord(aliases); @@ -120,6 +126,10 @@ export const createIndex = ({ acknowledged: true, shardsAcknowledged: false, }); + } else if (isClusterShardLimitExceeded(error?.body?.error)) { + return Either.left({ + type: 'cluster_shard_limit_exceeded' as const, + }); } else { throw error; } @@ -129,7 +139,11 @@ export const createIndex = ({ return pipe( createIndexTask, - TaskEither.chain((res) => { + TaskEither.chain< + RetryableEsClientError | IndexNotYellowTimeout | ClusterShardLimitExceeded, + AcknowledgeResponse, + 'create_index_succeeded' + >((res) => { if (res.acknowledged && res.shardsAcknowledged) { // If the cluster state was updated and all shards ackd we're done return TaskEither.right('create_index_succeeded'); diff --git a/src/core/server/saved_objects/migrations/actions/es_errors.test.ts b/src/core/server/saved_objects/migrations/actions/es_errors.test.ts index c3a8c7a036a44..b34366b7386d2 100644 --- a/src/core/server/saved_objects/migrations/actions/es_errors.test.ts +++ b/src/core/server/saved_objects/migrations/actions/es_errors.test.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { isIncompatibleMappingException, isWriteBlockException } from './es_errors'; +import { + isClusterShardLimitExceeded, + isIncompatibleMappingException, + isWriteBlockException, +} from './es_errors'; describe('isWriteBlockError', () => { it('returns true for a `index write` cluster_block_exception', () => { @@ -54,3 +58,23 @@ describe('isIncompatibleMappingExceptionError', () => { ).toEqual(true); }); }); + +describe('isClusterShardLimitExceeded', () => { + it('returns true with validation_exception and reason is maximum normal shards open', () => { + expect( + isClusterShardLimitExceeded({ + type: 'validation_exception', + reason: + 'Validation Failed: 1: this action would add [2] shards, but this cluster currently has [3]/[1] maximum normal shards open;', + }) + ).toEqual(true); + }); + it('returns false for validation_exception with another reason', () => { + expect( + isClusterShardLimitExceeded({ + type: 'validation_exception', + reason: 'Validation Failed: 1: this action would do something its not allowed to do', + }) + ).toEqual(false); + }); +}); diff --git a/src/core/server/saved_objects/migrations/actions/es_errors.ts b/src/core/server/saved_objects/migrations/actions/es_errors.ts index 4f560468bcb0c..9f571d38ffd85 100644 --- a/src/core/server/saved_objects/migrations/actions/es_errors.ts +++ b/src/core/server/saved_objects/migrations/actions/es_errors.ts @@ -21,3 +21,12 @@ export const isIncompatibleMappingException = ({ type }: estypes.ErrorCause): bo export const isIndexNotFoundException = ({ type }: estypes.ErrorCause): boolean => { return type === 'index_not_found_exception'; }; + +export const isClusterShardLimitExceeded = ({ type, reason }: estypes.ErrorCause): boolean => { + return ( + type === 'validation_exception' && + reason.match( + /this action would add .* shards, but this cluster currently has .* maximum normal shards open/ + ) !== null + ); +}; diff --git a/src/core/server/saved_objects/migrations/actions/index.ts b/src/core/server/saved_objects/migrations/actions/index.ts index 74d8c57ebf171..3a387d764fa4c 100644 --- a/src/core/server/saved_objects/migrations/actions/index.ts +++ b/src/core/server/saved_objects/migrations/actions/index.ts @@ -88,6 +88,7 @@ export { updateAndPickupMappings } from './update_and_pickup_mappings'; import type { UnknownDocsFound } from './check_for_unknown_docs'; import type { IncompatibleClusterRoutingAllocation } from './initialize_action'; +import { ClusterShardLimitExceeded } from './create_index'; export type { CheckForUnknownDocsParams, @@ -153,6 +154,7 @@ export interface ActionErrorTypeMap { unknown_docs_found: UnknownDocsFound; incompatible_cluster_routing_allocation: IncompatibleClusterRoutingAllocation; index_not_yellow_timeout: IndexNotYellowTimeout; + cluster_shard_limit_exceeded: ClusterShardLimitExceeded; } /** diff --git a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts index 7ac6911ec7e9e..d47d53aa367e7 100644 --- a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts @@ -425,6 +425,10 @@ describe('migration actions', () => { describe('cloneIndex', () => { afterAll(async () => { try { + // Restore the default setting of 1000 shards per node + await client.cluster.putSettings({ + persistent: { cluster: { max_shards_per_node: null } }, + }); await client.indices.delete({ index: 'clone_*' }); } catch (e) { /** ignore */ @@ -577,6 +581,23 @@ describe('migration actions', () => { } `); }); + it('resolves left cluster_shard_limit_exceeded when the action would exceed the maximum normal open shards', async () => { + // Set the max shards per node really low so that any new index that's created would exceed the maximum open shards for this cluster + await client.cluster.putSettings({ persistent: { cluster: { max_shards_per_node: 1 } } }); + const cloneIndexPromise = cloneIndex({ + client, + source: 'existing_index_with_write_block', + target: 'clone_target_4', + })(); + await expect(cloneIndexPromise).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "cluster_shard_limit_exceeded", + }, + } + `); + }); }); // Reindex doesn't return any errors on it's own, so we have to test @@ -1565,6 +1586,10 @@ describe('migration actions', () => { }); describe('createIndex', () => { + afterEach(async () => { + // Restore the default setting of 1000 shards per node + await client.cluster.putSettings({ persistent: { cluster: { max_shards_per_node: null } } }); + }); afterAll(async () => { await client.indices.delete({ index: 'red_then_yellow_index' }); }); @@ -1615,13 +1640,30 @@ describe('migration actions', () => { // Assert that the promise didn't resolve before the index became green expect(indexYellow).toBe(true); expect(res).toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "create_index_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "create_index_succeeded", + } + `); }); }); + it('resolves left cluster_shard_limit_exceeded when the action would exceed the maximum normal open shards', async () => { + // Set the max shards per node really low so that any new index that's created would exceed the maximum open shards for this cluster + await client.cluster.putSettings({ persistent: { cluster: { max_shards_per_node: 1 } } }); + const createIndexPromise = createIndex({ + client, + indexName: 'red_then_yellow_index_1', + mappings: undefined as any, + })(); + await expect(createIndexPromise).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "cluster_shard_limit_exceeded", + }, + } + `); + }); it('rejects when there is an unexpected error creating the index', async () => { // Creating an index with the same name as an existing alias to induce // failure @@ -1646,11 +1688,11 @@ describe('migration actions', () => { }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "bulk_index_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "bulk_index_succeeded", + } + `); }); it('resolves right even if there were some version_conflict_engine_exception', async () => { const existingDocs = ( @@ -1671,11 +1713,11 @@ describe('migration actions', () => { refresh: 'wait_for', }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "bulk_index_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "bulk_index_succeeded", + } + `); }); it('resolves left target_index_had_write_block if there are write_block errors', async () => { const newDocs = [ @@ -1691,13 +1733,13 @@ describe('migration actions', () => { refresh: 'wait_for', })() ).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "type": "target_index_had_write_block", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "type": "target_index_had_write_block", + }, + } + `); }); it('resolves left request_entity_too_large_exception when the payload is too large', async () => { @@ -1713,13 +1755,13 @@ describe('migration actions', () => { transformedDocs: newDocs, }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "type": "request_entity_too_large_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "type": "request_entity_too_large_exception", + }, + } + `); }); }); }); diff --git a/src/core/server/saved_objects/migrations/actions/integration_tests/es_errors.test.ts b/src/core/server/saved_objects/migrations/actions/integration_tests/es_errors.test.ts index a409bb31d39ef..8a051835cad67 100644 --- a/src/core/server/saved_objects/migrations/actions/integration_tests/es_errors.test.ts +++ b/src/core/server/saved_objects/migrations/actions/integration_tests/es_errors.test.ts @@ -10,7 +10,7 @@ import { ElasticsearchClient } from '../../../..'; import { InternalCoreStart } from '../../../../internal_types'; import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; import { Root } from '../../../../root'; -import { isWriteBlockException } from '../es_errors'; +import { isWriteBlockException, isClusterShardLimitExceeded } from '../es_errors'; import { createIndex } from '../create_index'; import { setWriteBlock } from '../set_write_block'; @@ -127,4 +127,36 @@ describe('Elasticsearch Errors', () => { expect(isWriteBlockException(cause)).toEqual(true); }); }); + describe('isClusterShardLimitExceeded', () => { + beforeAll(async () => { + await client.cluster.putSettings({ persistent: { cluster: { max_shards_per_node: 1 } } }); + }); + afterAll(async () => { + await client.cluster.putSettings({ persistent: { cluster: { max_shards_per_node: null } } }); + }); + + it('correctly identify errors from create index operation', async () => { + const res = await client.indices.create( + { + index: 'new_test_index', + }, + { ignore: [400] } + ); + + // @ts-expect-error @elastic/elasticsearch doesn't declare error on response + expect(isClusterShardLimitExceeded(res.error)).toEqual(true); + }); + it('correctly identify errors from clone index operation', async () => { + const res = await client.indices.clone( + { + index: 'existing_index_with_write_block', + target: 'new_test_index_2', + }, + { ignore: [400] } + ); + + // @ts-expect-error @elastic/elasticsearch doesn't declare error on response + expect(isClusterShardLimitExceeded(res.error)).toEqual(true); + }); + }); }); diff --git a/src/core/server/saved_objects/migrations/initial_state.test.ts b/src/core/server/saved_objects/migrations/initial_state.test.ts index 2ad3dc38e6d65..c2a2736541208 100644 --- a/src/core/server/saved_objects/migrations/initial_state.test.ts +++ b/src/core/server/saved_objects/migrations/initial_state.test.ts @@ -42,86 +42,161 @@ describe('createInitialState', () => { typeRegistry, docLinks, }) - ).toEqual({ - batchSize: 1000, - maxBatchSizeBytes: ByteSizeValue.parse('100mb').getValueInBytes(), - controlState: 'INIT', - currentAlias: '.kibana_task_manager', - excludeFromUpgradeFilterHooks: {}, - indexPrefix: '.kibana_task_manager', - kibanaVersion: '8.1.0', - knownTypes: [], - legacyIndex: '.kibana_task_manager', - logs: [], - outdatedDocumentsQuery: { - bool: { - should: [], + ).toMatchInlineSnapshot(` + Object { + "batchSize": 1000, + "controlState": "INIT", + "currentAlias": ".kibana_task_manager", + "excludeFromUpgradeFilterHooks": Object {}, + "indexPrefix": ".kibana_task_manager", + "kibanaVersion": "8.1.0", + "knownTypes": Array [], + "legacyIndex": ".kibana_task_manager", + "logs": Array [], + "maxBatchSizeBytes": 104857600, + "migrationDocLinks": Object { + "clusterShardLimitExceeded": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#cluster-shard-limit-exceeded", + "repeatedTimeoutRequests": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail", + "resolveMigrationFailures": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html", + "routingAllocationDisabled": "https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled", }, - }, - preMigrationScript: { - _tag: 'None', - }, - retryAttempts: 15, - retryCount: 0, - retryDelay: 0, - targetIndexMappings: { - dynamic: 'strict', - properties: { - my_type: { - properties: { - title: { - type: 'text', + "outdatedDocumentsQuery": Object { + "bool": Object { + "should": Array [], + }, + }, + "preMigrationScript": Object { + "_tag": "None", + }, + "retryAttempts": 15, + "retryCount": 0, + "retryDelay": 0, + "targetIndexMappings": Object { + "dynamic": "strict", + "properties": Object { + "my_type": Object { + "properties": Object { + "title": Object { + "type": "text", + }, }, }, }, }, - }, - tempIndex: '.kibana_task_manager_8.1.0_reindex_temp', - tempIndexMappings: { - dynamic: false, - properties: { - migrationVersion: { - dynamic: 'true', - type: 'object', - }, - type: { - type: 'keyword', + "tempIndex": ".kibana_task_manager_8.1.0_reindex_temp", + "tempIndexMappings": Object { + "dynamic": false, + "properties": Object { + "migrationVersion": Object { + "dynamic": "true", + "type": "object", + }, + "type": Object { + "type": "keyword", + }, }, }, - }, - unusedTypesQuery: { - bool: { - must_not: expect.arrayContaining([ - { - bool: { - must: [ - { - match: { - type: 'search-session', + "unusedTypesQuery": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "apm-services-telemetry", + }, + }, + Object { + "term": Object { + "type": "background-session", + }, + }, + Object { + "term": Object { + "type": "cases-sub-case", + }, + }, + Object { + "term": Object { + "type": "file-upload-telemetry", + }, + }, + Object { + "term": Object { + "type": "fleet-agent-actions", + }, + }, + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "fleet-agents", + }, + }, + Object { + "term": Object { + "type": "fleet-enrollment-api-keys", + }, + }, + Object { + "term": Object { + "type": "ml-telemetry", + }, + }, + Object { + "term": Object { + "type": "osquery-usage-metric", + }, + }, + Object { + "term": Object { + "type": "server", + }, + }, + Object { + "term": Object { + "type": "siem-detection-engine-rule-status", + }, + }, + Object { + "term": Object { + "type": "timelion-sheet", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "term": Object { + "type": "ui-counter", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, }, - }, - { - match: { - 'search-session.persisted': false, + Object { + "match": Object { + "search-session.persisted": false, + }, }, - }, - ], + ], + }, }, - }, - ]), + ], + }, }, - }, - versionAlias: '.kibana_task_manager_8.1.0', - versionIndex: '.kibana_task_manager_8.1.0_001', - migrationDocLinks: { - resolveMigrationFailures: - 'https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html', - repeatedTimeoutRequests: - 'https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#_repeated_time_out_requests_that_eventually_fail', - routingAllocationDisabled: - 'https://www.elastic.co/guide/en/kibana/test-branch/resolve-migrations-failures.html#routing-allocation-disabled', - }, - }); + "versionAlias": ".kibana_task_manager_8.1.0", + "versionIndex": ".kibana_task_manager_8.1.0_001", + } + `); }); it('returns state with the correct `knownTypes`', () => { diff --git a/src/core/server/saved_objects/migrations/model/model.test.ts b/src/core/server/saved_objects/migrations/model/model.test.ts index e46024fc729d7..1782ece9b4827 100644 --- a/src/core/server/saved_objects/migrations/model/model.test.ts +++ b/src/core/server/saved_objects/migrations/model/model.test.ts @@ -98,6 +98,7 @@ describe('migrations v2 model', () => { resolveMigrationFailures: 'resolveMigrationFailures', repeatedTimeoutRequests: 'repeatedTimeoutRequests', routingAllocationDisabled: 'routingAllocationDisabled', + clusterShardLimitExceeded: 'clusterShardLimitExceeded', }, }; @@ -599,6 +600,16 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('LEGACY_CREATE_REINDEX_TARGET -> FATAL if action fails with cluster_shard_limit_exceeded', () => { + const res: ResponseType<'LEGACY_CREATE_REINDEX_TARGET'> = Either.left({ + type: 'cluster_shard_limit_exceeded', + }); + const newState = model(legacyCreateReindexTargetState, res); + expect(newState.controlState).toEqual('FATAL'); + expect((newState as FatalState).reason).toMatchInlineSnapshot( + `"[cluster_shard_limit_exceeded] Upgrading Kibana requires adding a small number of new shards. Ensure that Kibana is able to add 10 more shards by increasing the cluster.max_shards_per_node setting, or removing indices to clear up resources. See clusterShardLimitExceeded"` + ); + }); }); describe('LEGACY_REINDEX', () => { @@ -997,6 +1008,16 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('CREATE_REINDEX_TEMP -> FATAL if action fails with cluster_shard_limit_exceeded', () => { + const res: ResponseType<'CREATE_REINDEX_TEMP'> = Either.left({ + type: 'cluster_shard_limit_exceeded', + }); + const newState = model(state, res); + expect(newState.controlState).toEqual('FATAL'); + expect((newState as FatalState).reason).toMatchInlineSnapshot( + `"[cluster_shard_limit_exceeded] Upgrading Kibana requires adding a small number of new shards. Ensure that Kibana is able to add 10 more shards by increasing the cluster.max_shards_per_node setting, or removing indices to clear up resources. See clusterShardLimitExceeded"` + ); + }); }); describe('REINDEX_SOURCE_TO_TEMP_OPEN_PIT', () => { @@ -1325,7 +1346,7 @@ describe('migrations v2 model', () => { } `); }); - it('CREATE_NEW_TARGET -> MARK_VERSION_INDEX_READY resets the retry count and delay', () => { + it('CLONE_TEMP_TO_TARGET -> MARK_VERSION_INDEX_READY resets the retry count and delay', () => { const res: ResponseType<'CLONE_TEMP_TO_TARGET'> = Either.right({ acknowledged: true, shardsAcknowledged: true, @@ -1340,6 +1361,16 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toBe(0); expect(newState.retryDelay).toBe(0); }); + test('CLONE_TEMP_TO_TARGET -> FATAL if action fails with cluster_shard_limit_exceeded', () => { + const res: ResponseType<'CLONE_TEMP_TO_TARGET'> = Either.left({ + type: 'cluster_shard_limit_exceeded', + }); + const newState = model(state, res); + expect(newState.controlState).toEqual('FATAL'); + expect((newState as FatalState).reason).toMatchInlineSnapshot( + `"[cluster_shard_limit_exceeded] Upgrading Kibana requires adding a small number of new shards. Ensure that Kibana is able to add 10 more shards by increasing the cluster.max_shards_per_node setting, or removing indices to clear up resources. See clusterShardLimitExceeded"` + ); + }); }); describe('OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT', () => { @@ -1849,6 +1880,16 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('CREATE_NEW_TARGET -> FATAL if action fails with cluster_shard_limit_exceeded', () => { + const res: ResponseType<'CREATE_NEW_TARGET'> = Either.left({ + type: 'cluster_shard_limit_exceeded', + }); + const newState = model(createNewTargetState, res); + expect(newState.controlState).toEqual('FATAL'); + expect((newState as FatalState).reason).toMatchInlineSnapshot( + `"[cluster_shard_limit_exceeded] Upgrading Kibana requires adding a small number of new shards. Ensure that Kibana is able to add 10 more shards by increasing the cluster.max_shards_per_node setting, or removing indices to clear up resources. See clusterShardLimitExceeded"` + ); + }); }); describe('MARK_VERSION_INDEX_READY', () => { diff --git a/src/core/server/saved_objects/migrations/model/model.ts b/src/core/server/saved_objects/migrations/model/model.ts index cbd1941720183..accff9553c808 100644 --- a/src/core/server/saved_objects/migrations/model/model.ts +++ b/src/core/server/saved_objects/migrations/model/model.ts @@ -38,6 +38,7 @@ import { import { createBatches } from './create_batches'; export const FATAL_REASON_REQUEST_ENTITY_TOO_LARGE = `While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.`; +const CLUSTER_SHARD_LIMIT_EXCEEDED_REASON = `[cluster_shard_limit_exceeded] Upgrading Kibana requires adding a small number of new shards. Ensure that Kibana is able to add 10 more shards by increasing the cluster.max_shards_per_node setting, or removing indices to clear up resources.`; export const model = (currentState: State, resW: ResponseType): State => { // The action response `resW` is weakly typed, the type includes all action @@ -230,6 +231,12 @@ export const model = (currentState: State, resW: ResponseType): // continue to timeout and eventually lead to a failed migration. const retryErrorMessage = `${left.message} Refer to ${stateP.migrationDocLinks.repeatedTimeoutRequests} for information on how to resolve the issue.`; return delayRetryState(stateP, retryErrorMessage, stateP.retryAttempts); + } else if (isLeftTypeof(left, 'cluster_shard_limit_exceeded')) { + return { + ...stateP, + controlState: 'FATAL', + reason: `${CLUSTER_SHARD_LIMIT_EXCEEDED_REASON} See ${stateP.migrationDocLinks.clusterShardLimitExceeded}`, + }; } else { return throwBadResponse(stateP, left); } @@ -447,6 +454,12 @@ export const model = (currentState: State, resW: ResponseType): // continue to timeout and eventually lead to a failed migration. const retryErrorMessage = `${left.message} Refer to ${stateP.migrationDocLinks.repeatedTimeoutRequests} for information on how to resolve the issue.`; return delayRetryState(stateP, retryErrorMessage, stateP.retryAttempts); + } else if (isLeftTypeof(left, 'cluster_shard_limit_exceeded')) { + return { + ...stateP, + controlState: 'FATAL', + reason: `${CLUSTER_SHARD_LIMIT_EXCEEDED_REASON} See ${stateP.migrationDocLinks.clusterShardLimitExceeded}`, + }; } else { return throwBadResponse(stateP, left); } @@ -682,6 +695,12 @@ export const model = (currentState: State, resW: ResponseType): // continue to timeout and eventually lead to a failed migration. const retryErrorMessage = `${left.message} Refer to ${stateP.migrationDocLinks.repeatedTimeoutRequests} for information on how to resolve the issue.`; return delayRetryState(stateP, retryErrorMessage, stateP.retryAttempts); + } else if (isLeftTypeof(left, 'cluster_shard_limit_exceeded')) { + return { + ...stateP, + controlState: 'FATAL', + reason: `${CLUSTER_SHARD_LIMIT_EXCEEDED_REASON} See ${stateP.migrationDocLinks.clusterShardLimitExceeded}`, + }; } else { throwBadResponse(stateP, left); } @@ -937,6 +956,12 @@ export const model = (currentState: State, resW: ResponseType): // continue to timeout and eventually lead to a failed migration. const retryErrorMessage = `${left.message} Refer to ${stateP.migrationDocLinks.repeatedTimeoutRequests} for information on how to resolve the issue.`; return delayRetryState(stateP, retryErrorMessage, stateP.retryAttempts); + } else if (isLeftTypeof(left, 'cluster_shard_limit_exceeded')) { + return { + ...stateP, + controlState: 'FATAL', + reason: `${CLUSTER_SHARD_LIMIT_EXCEEDED_REASON} See ${stateP.migrationDocLinks.clusterShardLimitExceeded}`, + }; } else { return throwBadResponse(stateP, left); } diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index 3a4a1fdb813fc..b8969fd599765 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -56,7 +56,6 @@ export const sampleLayer: DataLayerConfig = { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: createSampleDatatableWithRows([]), @@ -108,6 +107,8 @@ export const createArgsWithLayers = ( type: 'axisExtentConfig', }, layers: Array.isArray(layers) ? layers : [layers], + yLeftScale: 'linear', + yRightScale: 'linear', }); export function sampleArgs() { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 4f3ad589f1eea..3a33797bc0cbf 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if minTimeBarInterval applied for not time bar chart 1`] = `"\`minTimeBarInterval\` argument is applicable only for time bar charts."`; + +exports[`xyVis it should throw error if minTimeBarInterval is invalid 1`] = `"Provided x-axis interval is invalid. The interval should include quantity and unit names. Examples: 1d, 24h, 1w."`; + exports[`xyVis it should throw error if splitColumnAccessor is pointing to the absent column 1`] = `"Provided column name or index is invalid: absent-accessor"`; exports[`xyVis it should throw error if splitRowAccessor is pointing to the absent column 1`] = `"Provided column name or index is invalid: absent-accessor"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index 8c728f1c8e99e..0c9085cce7664 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -7,7 +7,7 @@ */ import { ArgumentType } from '@kbn/expressions-plugin/common'; -import { SeriesTypes, XScaleTypes, YScaleTypes, Y_CONFIG } from '../constants'; +import { SeriesTypes, XScaleTypes, Y_CONFIG } from '../constants'; import { strings } from '../i18n'; import { DataLayerArgs, ExtendedDataLayerArgs } from '../types'; @@ -44,12 +44,6 @@ export const commonDataLayerArgs: Omit< default: false, help: strings.getIsHistogramHelp(), }, - yScaleType: { - options: [...Object.values(YScaleTypes)], - help: strings.getYScaleTypeHelp(), - default: YScaleTypes.LINEAR, - strict: true, - }, yConfig: { types: [Y_CONFIG], help: strings.getYConfigHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index f80d814571076..a09212d59cce3 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -17,6 +17,7 @@ import { TICK_LABELS_CONFIG, ValueLabelModes, XYCurveTypes, + YScaleTypes, } from '../constants'; import { strings } from '../i18n'; import { LayeredXyVisFn, XyVisFn } from '../types'; @@ -46,6 +47,18 @@ export const commonXYArgs: CommonXYFn['args'] = { help: strings.getYRightExtentHelp(), default: `{${AXIS_EXTENT_CONFIG}}`, }, + yLeftScale: { + options: [...Object.values(YScaleTypes)], + help: strings.getYLeftScaleTypeHelp(), + default: YScaleTypes.LINEAR, + strict: true, + }, + yRightScale: { + options: [...Object.values(YScaleTypes)], + help: strings.getYRightScaleTypeHelp(), + default: YScaleTypes.LINEAR, + strict: true, + }, legend: { types: [LEGEND_CONFIG], help: strings.getLegendHelp(), @@ -115,4 +128,8 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + minTimeBarInterval: { + types: ['string'], + help: strings.getMinTimeBarIntervalHelp(), + }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index c4d714f11ddd9..47e62f9ccae4a 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -9,7 +9,7 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; -import { getAccessors } from '../helpers'; +import { getAccessors, normalizeTable } from '../helpers'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -19,11 +19,13 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, validateAccessor(accessors.splitAccessor, table.columns); accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + const normalizedTable = normalizeTable(table, accessors.xAccessor); + return { type: EXTENDED_DATA_LAYER, ...args, layerType: LayerTypes.DATA, ...accessors, - table, + table: normalizedTable, }; }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index 4b7de0eba3166..c4e2decb3279d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -7,15 +7,20 @@ */ import { XY_VIS_RENDERER } from '../constants'; -import { appendLayerIds } from '../helpers'; +import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; +import { validateMinTimeBarInterval, hasBarLayer } from './validate'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); logDatatables(layers, handlers); + const dataLayers = getDataLayers(layers); + const hasBar = hasBarLayer(dataLayers); + validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); + return { type: 'render', as: XY_VIS_RENDERER, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 55d7cb12382c0..2d1ecb2840c0a 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -7,13 +7,16 @@ */ import { i18n } from '@kbn/i18n'; +import { isValidInterval } from '@kbn/data-plugin/common'; import { AxisExtentModes, ValueLabelModes } from '../constants'; import { AxisExtentConfigResult, DataLayerConfigResult, + CommonXYDataLayerConfigResult, ValueLabelMode, CommonXYDataLayerConfig, } from '../types'; +import { isTimeChart } from '../helpers'; const errors = { extendBoundsAreInvalidError: () => @@ -37,6 +40,18 @@ const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.dataBoundsForNotLineChartError', { defaultMessage: 'Only line charts can be fit to the data bounds', }), + isInvalidIntervalError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.isInvalidIntervalError', { + defaultMessage: + 'Provided x-axis interval is invalid. The interval should include quantity and unit names. Examples: 1d, 24h, 1w.', + }), + minTimeBarIntervalNotForTimeBarChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.minTimeBarIntervalNotForTimeBarChartError', + { + defaultMessage: '`minTimeBarInterval` argument is applicable only for time bar charts.', + } + ), }; export const hasBarLayer = (layers: Array) => @@ -101,3 +116,19 @@ export const validateValueLabels = ( throw new Error(errors.valueLabelsForNotBarsOrHistogramBarsChartsError()); } }; + +export const validateMinTimeBarInterval = ( + dataLayers: CommonXYDataLayerConfigResult[], + hasBar: boolean, + minTimeBarInterval?: string +) => { + if (minTimeBarInterval) { + if (!isValidInterval(minTimeBarInterval)) { + throw new Error(errors.isInvalidIntervalError()); + } + + if (!hasBar || !isTimeChart(dataLayers)) { + throw new Error(errors.minTimeBarIntervalNotForTimeBarChartError()); + } + } +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index cb6527eb1c393..9348e489ab391 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -7,6 +7,7 @@ */ import { xyVisFunction } from '.'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import { sampleArgs, sampleLayer } from '../__mocks__'; import { XY_VIS } from '../constants'; @@ -14,10 +15,25 @@ import { XY_VIS } from '../constants'; describe('xyVis', () => { test('it renders with the specified data and args', async () => { const { data, args } = sampleArgs(); + const newData = { + ...data, + type: 'datatable', + + columns: data.columns.map((c) => + c.id !== 'c' + ? c + : { + ...c, + meta: { + type: 'string', + }, + } + ), + } as Datatable; const { layers, ...rest } = args; const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( - data, + newData, { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, createMockExecutionContext() ); @@ -28,12 +44,50 @@ describe('xyVis', () => { value: { args: { ...rest, - layers: [{ layerType, table: data, layerId: 'dataLayers-0', type, ...restLayerArgs }], + layers: [{ layerType, table: newData, layerId: 'dataLayers-0', type, ...restLayerArgs }], }, }, }); }); + test('it should throw error if minTimeBarInterval is invalid', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + minTimeBarInterval: '1q', + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if minTimeBarInterval applied for not time bar chart', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + minTimeBarInterval: '1h', + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + test('it should throw error if splitRowAccessor is pointing to the absent column', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 8b850f39afbbe..292e69988c37e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -14,7 +14,7 @@ import { import type { Datatable } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; -import { appendLayerIds, getAccessors } from '../helpers'; +import { appendLayerIds, getAccessors, normalizeTable } from '../helpers'; import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; import { getLayerDimensions } from '../utils'; import { @@ -24,22 +24,26 @@ import { validateExtent, validateFillOpacity, validateValueLabels, + validateMinTimeBarInterval, } from './validate'; -const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => ({ - type: DATA_LAYER, - seriesType: args.seriesType, - hide: args.hide, - columnToLabel: args.columnToLabel, - yScaleType: args.yScaleType, - xScaleType: args.xScaleType, - isHistogram: args.isHistogram, - palette: args.palette, - yConfig: args.yConfig, - layerType: LayerTypes.DATA, - table, - ...getAccessors(args, table), -}); +const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { + const accessors = getAccessors(args, table); + const normalizedTable = normalizeTable(table, accessors.xAccessor); + return { + type: DATA_LAYER, + seriesType: args.seriesType, + hide: args.hide, + columnToLabel: args.columnToLabel, + xScaleType: args.xScaleType, + isHistogram: args.isHistogram, + palette: args.palette, + yConfig: args.yConfig, + layerType: LayerTypes.DATA, + table: normalizedTable, + ...accessors, + }; +}; export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(args.splitRowAccessor, data.columns); @@ -55,7 +59,6 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { hide, splitAccessor, columnToLabel, - yScaleType, xScaleType, isHistogram, yConfig, @@ -97,6 +100,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateExtent(args.yLeftExtent, hasBar || hasArea, dataLayers); validateExtent(args.yRightExtent, hasBar || hasArea, dataLayers); validateFillOpacity(args.fillOpacity, hasArea); + validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts index 56416261316aa..584fbd2886726 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts @@ -6,4 +6,6 @@ * Side Public License, v 1. */ -export { appendLayerIds, getAccessors } from './layers'; +export { appendLayerIds, getDataLayers, getAccessors } from './layers'; +export { isTimeChart } from './visualization'; +export { normalizeTable } from './table'; diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts index ac44ef18fc505..a3eea973fbf91 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { generateLayerId, appendLayerIds } from './layers'; +import { XYExtendedLayerConfigResult } from '../types'; +import { generateLayerId, appendLayerIds, getDataLayers } from './layers'; describe('#generateLayerId', () => { it('should return the combination of keyword and index', () => { @@ -47,3 +48,28 @@ describe('#appendLayerIds', () => { expect(layersWithIds).toStrictEqual(expectedLayerIds); }); }); + +describe('#getDataLayers', () => { + it('should return only data layers', () => { + const layers: XYExtendedLayerConfigResult[] = [ + { + type: 'extendedDataLayer', + layerType: 'data', + accessors: ['y'], + seriesType: 'bar', + xScaleType: 'time', + isHistogram: false, + table: { rows: [], columns: [], type: 'datatable' }, + palette: { type: 'system_palette', name: 'system' }, + }, + { + type: 'extendedReferenceLineLayer', + layerType: 'referenceLine', + accessors: ['y'], + table: { rows: [], columns: [], type: 'datatable' }, + }, + ]; + + expect(getDataLayers(layers)).toStrictEqual([layers[0]]); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts index 667b2697e480f..23aa8bd3218d2 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts @@ -7,7 +7,8 @@ */ import { Datatable, PointSeriesColumnNames } from '@kbn/expressions-plugin/common'; -import { WithLayerId } from '../types'; +import { WithLayerId, ExtendedDataLayerConfig, XYExtendedLayerConfigResult } from '../types'; +import { LayerTypes } from '../constants'; function isWithLayerId(layer: T): layer is T & WithLayerId { return (layer as T & WithLayerId).layerId ? true : false; @@ -27,6 +28,13 @@ export function appendLayerIds( })); } +export function getDataLayers(layers: XYExtendedLayerConfigResult[]) { + return layers.filter( + (layer): layer is ExtendedDataLayerConfig => + layer.layerType === LayerTypes.DATA || !layer.layerType + ); +} + export function getAccessors( args: U, table: Datatable diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/table.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/table.test.ts new file mode 100644 index 0000000000000..947dfcf86be7b --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/table.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { Datatable } from '@kbn/expressions-plugin'; +import { normalizeTable } from './table'; +import { createSampleDatatableWithRows } from '../__mocks__'; + +describe('#normalizeTable', () => { + it('should convert row values from date string to number if xAccessor related to `date` column', () => { + const xAccessor = 'c'; + const data = createSampleDatatableWithRows([ + { a: 1, b: 2, c: '2022-05-07T06:25:00.000', d: 'Foo' }, + { a: 1, b: 2, c: '2022-05-08T06:25:00.000', d: 'Foo' }, + { a: 1, b: 2, c: '2022-05-09T06:25:00.000', d: 'Foo' }, + ]); + const newData = { + ...data, + type: 'datatable', + + columns: data.columns.map((c) => + c.id !== 'c' + ? c + : { + ...c, + meta: { + type: 'date', + }, + } + ), + } as Datatable; + const expectedData = { + ...newData, + rows: newData.rows.map((row) => ({ + ...row, + [xAccessor as string]: moment(row[xAccessor as string]).valueOf(), + })), + }; + const normalizedTable = normalizeTable(newData, xAccessor); + expect(normalizedTable).toEqual(expectedData); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/table.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/table.ts new file mode 100644 index 0000000000000..a8248226b51c8 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/table.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; +import type { Datatable } from '@kbn/expressions-plugin/common'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; + +export function normalizeTable(data: Datatable, xAccessor?: string | ExpressionValueVisDimension) { + const xColumn = xAccessor && getColumnByAccessor(xAccessor, data.columns); + if (xColumn && xColumn?.meta.type === 'date') { + const xColumnId = xColumn.id; + const rows = data.rows.reduce((normalizedRows, row) => { + return [ + ...normalizedRows, + { + ...row, + [xColumnId]: + typeof row[xColumnId] === 'string' ? moment(row[xColumnId]).valueOf() : row[xColumnId], + }, + ]; + }, []); + return { ...data, rows }; + } + + return data; +} diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.test.ts new file mode 100644 index 0000000000000..678c342e38b49 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CommonXYDataLayerConfigResult } from '../types'; +import { isTimeChart } from './visualization'; + +describe('#isTimeChart', () => { + it('should return true if all data layers have xAccessor collumn type `date` and scaleType is `time`', () => { + const layers: CommonXYDataLayerConfigResult[] = [ + { + type: 'extendedDataLayer', + layerType: 'data', + xAccessor: 'x', + accessors: ['y'], + seriesType: 'bar', + xScaleType: 'time', + isHistogram: false, + table: { + rows: [], + columns: [ + { + id: 'x', + name: 'x', + meta: { + type: 'date', + }, + }, + ], + type: 'datatable', + }, + palette: { type: 'system_palette', name: 'system' }, + }, + ]; + + expect(isTimeChart(layers)).toBeTruthy(); + + layers[0].xScaleType = 'linear'; + + expect(isTimeChart(layers)).toBeFalsy(); + + layers[0].xScaleType = 'time'; + layers[0].table.columns[0].meta.type = 'number'; + + expect(isTimeChart(layers)).toBeFalsy(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts new file mode 100644 index 0000000000000..8ddbc4bc97f10 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { XScaleTypes } from '../constants'; +import { CommonXYDataLayerConfigResult } from '../types'; + +export function isTimeChart(layers: CommonXYDataLayerConfigResult[]) { + return layers.every( + (l): l is CommonXYDataLayerConfigResult => + l.table.columns.find((col) => col.id === l.xAccessor)?.meta.type === 'date' && + l.xScaleType === XScaleTypes.TIME + ); +} diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index 5b5906ac71582..21230643fe078 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -49,6 +49,14 @@ export const strings = { i18n.translate('expressionXY.xyVis.yRightExtent.help', { defaultMessage: 'Y right axis extents', }), + getYLeftScaleTypeHelp: () => + i18n.translate('expressionXY.xyVis.yLeftScaleType.help', { + defaultMessage: 'The scale type of the left y axis', + }), + getYRightScaleTypeHelp: () => + i18n.translate('expressionXY.xyVis.yRightScaleType.help', { + defaultMessage: 'The scale type of the right y axis', + }), getLegendHelp: () => i18n.translate('expressionXY.xyVis.legend.help', { defaultMessage: 'Configure the chart legend.', @@ -113,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getMinTimeBarIntervalHelp: () => + i18n.translate('expressionXY.xyVis.xAxisInterval.help', { + defaultMessage: 'Specifies the min interval for time bar chart', + }), getSplitColumnAccessorHelp: () => i18n.translate('expressionXY.xyVis.splitColumnAccessor.help', { defaultMessage: 'Specifies split column of the xy chart', @@ -149,10 +161,6 @@ export const strings = { i18n.translate('expressionXY.dataLayer.isHistogram.help', { defaultMessage: 'Whether to layout the chart as a histogram', }), - getYScaleTypeHelp: () => - i18n.translate('expressionXY.dataLayer.yScaleType.help', { - defaultMessage: 'The scale type of the y axes', - }), getSplitAccessorHelp: () => i18n.translate('expressionXY.dataLayer.splitAccessor.help', { defaultMessage: 'The column to split by', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 5cca9a0431ad0..a9910032699e0 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -101,7 +101,6 @@ export interface DataLayerArgs { hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; columnToLabel?: string; // Actually a JSON key-value pair - yScaleType: YScaleType; xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; @@ -120,7 +119,6 @@ export interface ExtendedDataLayerArgs { hide?: boolean; splitAccessor?: string; columnToLabel?: string; // Actually a JSON key-value pair - yScaleType: YScaleType; xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; @@ -187,6 +185,8 @@ export interface XYArgs extends DataLayerArgs { yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; endValue?: EndValue; emphasizeFitting?: boolean; @@ -203,6 +203,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; } @@ -213,6 +214,8 @@ export interface LayeredXYArgs { yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; endValue?: EndValue; emphasizeFitting?: boolean; @@ -228,6 +231,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + minTimeBarInterval?: string; } export interface XYProps { @@ -236,6 +240,8 @@ export interface XYProps { yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; endValue?: EndValue; emphasizeFitting?: boolean; @@ -251,6 +257,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; } diff --git a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx index e84d8c001fb82..77ce5ee76ebbf 100644 --- a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx @@ -164,7 +164,6 @@ export const dateHistogramLayer: DataLayerConfig = { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'time', isHistogram: true, splitAccessor: 'splitAccessorId', diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 3eeeee402205a..0bc41100012de 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -766,7 +766,6 @@ exports[`XYChart component it renders area 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -807,6 +806,7 @@ exports[`XYChart component it renders area 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -1310,7 +1310,6 @@ exports[`XYChart component it renders bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -1351,6 +1350,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -1854,7 +1854,6 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -1895,6 +1894,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "groupId": "left", "position": "bottom", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -2398,7 +2398,6 @@ exports[`XYChart component it renders line 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -2439,6 +2438,7 @@ exports[`XYChart component it renders line 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -2942,7 +2942,6 @@ exports[`XYChart component it renders stacked area 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -2983,6 +2982,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -3486,7 +3486,6 @@ exports[`XYChart component it renders stacked bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -3527,6 +3526,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -4030,7 +4030,6 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -4071,6 +4070,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "groupId": "left", "position": "bottom", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -4829,7 +4829,6 @@ exports[`XYChart component split chart should render split chart if both, splitR "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -4870,6 +4869,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -5627,7 +5627,6 @@ exports[`XYChart component split chart should render split chart if splitColumnA "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -5668,6 +5667,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -6425,7 +6425,6 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -6466,6 +6465,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx index 78ac1ed8d10cf..8289d605aa913 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -162,7 +162,6 @@ const sampleLayer: DataLayerConfig = { splitAccessor: 'splitAccessorId', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx b/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx index 10b2140eae6a1..9f6237f965842 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx @@ -68,7 +68,6 @@ export const getXDomain = ( .filter((v) => !isUndefined(v)) .sort() ); - const [firstXValue] = xValues; const lastXValue = xValues[xValues.length - 1]; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 7f6dcb7a73925..62f23ba86a166 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -157,7 +157,6 @@ describe('XYChart component', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'time', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: { @@ -217,7 +216,9 @@ describe('XYChart component', () => { }); test('it uses passed in minInterval', () => { - const table1 = createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]); + const table1 = createSampleDatatableWithRows([ + { a: 1, b: 2, c: '2019-01-02T05:00:00.000Z', d: 'Foo' }, + ]); const table2 = createSampleDatatableWithRows([]); const component = shallow( @@ -254,7 +255,6 @@ describe('XYChart component', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'time', - yScaleType: 'linear', isHistogram: true, palette: mockPaletteOutput, table: data, @@ -440,7 +440,7 @@ describe('XYChart component', () => { test('should pass enabled histogram mode and min interval to endzones component', () => { const component = shallow( - + ); expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( @@ -464,7 +464,8 @@ describe('XYChart component', () => { seriesType: 'bar', xScaleType: 'time', isHistogram: true, - }, + table: newData, + } as DataLayerConfig, ], }} /> @@ -847,7 +848,6 @@ describe('XYChart component', () => { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar_stacked', @@ -923,7 +923,6 @@ describe('XYChart component', () => { isHistogram: true, seriesType: 'bar_stacked', xAccessor: 'b', - yScaleType: 'linear', xScaleType: 'time', splitAccessor: 'b', accessors: ['d'], @@ -1045,7 +1044,6 @@ describe('XYChart component', () => { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar_stacked', @@ -1125,7 +1123,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -1184,7 +1181,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: newData, @@ -1215,7 +1211,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -1898,7 +1893,7 @@ describe('XYChart component', () => { {...defaultProps} args={{ ...args, - layers: [{ ...(args.layers[0] as DataLayerConfig), yScaleType: 'sqrt' }], + yLeftScale: 'sqrt', }} /> ); @@ -2107,6 +2102,8 @@ describe('XYChart component', () => { xTitle: '', yTitle: '', yRightTitle: '', + yLeftScale: 'linear', + yRightScale: 'linear', legend: { type: 'legendConfig', isVisible: false, position: Position.Top }, valueLabels: 'hide', tickLabelsVisibilitySettings: { @@ -2146,7 +2143,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data1, @@ -2161,7 +2157,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data2, @@ -2224,6 +2219,8 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + yLeftScale: 'linear', + yRightScale: 'linear', layers: [ { layerId: 'first', @@ -2235,7 +2232,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -2296,6 +2292,8 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + yLeftScale: 'linear', + yRightScale: 'linear', layers: [ { layerId: 'first', @@ -2307,7 +2305,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -2557,7 +2554,6 @@ describe('XYChart component', () => { xAccessor: 'c', accessors: ['a', 'b'], xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index cce61447f16a1..7b31112c4b9ed 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -157,6 +157,8 @@ export function XYChart({ yLeftExtent, yRightExtent, valuesInLegend, + yLeftScale, + yRightScale, splitColumnAccessor, splitRowAccessor, } = args; @@ -210,7 +212,13 @@ export function XYChart({ filteredLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor); const shouldRotate = isHorizontalChart(dataLayers); - const yAxesConfiguration = getAxesConfiguration(dataLayers, shouldRotate, formatFactory); + const yAxesConfiguration = getAxesConfiguration( + dataLayers, + shouldRotate, + formatFactory, + yLeftScale, + yRightScale + ); const xTitle = args.xTitle || (xAxisColumn && xAxisColumn.name); const axisTitlesVisibilitySettings = args.axisTitlesVisibilitySettings || { diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.test.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.test.ts index f3abf76b2d05a..7f1f8b62b75a7 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.test.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.test.ts @@ -230,7 +230,6 @@ describe('axes_configuration', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: { type: 'palette', name: 'default' }, table: tables.first, diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts index 5b6f35c1f8c6e..89dc87ae5383b 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts @@ -9,7 +9,13 @@ import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; import { FormatFactory } from '../types'; -import { AxisExtentConfig, CommonXYDataLayerConfig, ExtendedYConfig, YConfig } from '../../common'; +import { + AxisExtentConfig, + CommonXYDataLayerConfig, + ExtendedYConfig, + YConfig, + YScaleType, +} from '../../common'; import { isDataLayer } from './visualization'; import { getFormat } from './format'; @@ -27,6 +33,7 @@ export type GroupsConfiguration = Array<{ position: 'left' | 'right' | 'bottom' | 'top'; formatter?: IFieldFormat; series: Series[]; + scale?: YScaleType; }>; export function isFormatterCompatible( @@ -110,7 +117,9 @@ export function groupAxesByType(layers: CommonXYDataLayerConfig[]) { export function getAxesConfiguration( layers: CommonXYDataLayerConfig[], shouldRotate: boolean, - formatFactory?: FormatFactory + formatFactory?: FormatFactory, + yLeftScale?: YScaleType, + yRightScale?: YScaleType ): GroupsConfiguration { const series = groupAxesByType(layers); @@ -122,6 +131,7 @@ export function getAxesConfiguration( position: shouldRotate ? 'bottom' : 'left', formatter: formatFactory?.(series.left[0].fieldFormat), series: series.left.map(({ fieldFormat, ...currentSeries }) => currentSeries), + scale: yLeftScale, }); } @@ -131,6 +141,7 @@ export function getAxesConfiguration( position: shouldRotate ? 'top' : 'right', formatter: formatFactory?.(series.right[0].fieldFormat), series: series.right.map(({ fieldFormat, ...currentSeries }) => currentSeries), + scale: yRightScale, }); } diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts index 8b1bdeeadb834..836d7209a6a5b 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts @@ -52,7 +52,6 @@ describe('color_assignment', () => { { layerId: 'first', type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar', @@ -65,7 +64,6 @@ describe('color_assignment', () => { { layerId: 'second', type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar', diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 85b184d74029b..c2a7c847e150b 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -329,9 +329,9 @@ export const getSeriesProps: GetSeriesPropsFn = ({ data: rows, xScaleType: xColumnId ? layer.xScaleType : 'ordinal', yScaleType: - formatter?.id === 'bytes' && layer.yScaleType === ScaleType.Linear + formatter?.id === 'bytes' && yAxis?.scale === ScaleType.Linear ? ScaleType.LinearBinary - : layer.yScaleType, + : yAxis?.scale || ScaleType.Linear, color: (series) => getColor(series, { layer, diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.test.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.test.ts index 6721c293dbe57..43f9cb750b151 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.test.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.test.ts @@ -86,4 +86,18 @@ describe('calculateMinInterval', () => { const result = await calculateMinInterval(xyProps); expect(result).toEqual(undefined); }); + + it('should return specified interval if user provided it as `xAxisInterval`', async () => { + layer.table.columns[2].meta.source = 'esaggs'; + layer.table.columns[2].meta.sourceParams = { + type: 'date_histogram', + params: { + used_interval: '5m', + }, + }; + xyProps.args.layers[0] = layer; + xyProps.args.minTimeBarInterval = '1h'; + const result = await calculateMinInterval(xyProps); + expect(result).toEqual(60 * 60 * 1000); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts index 015cab5431e9e..a9f68ffc0a29b 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts @@ -12,7 +12,7 @@ import { XYChartProps } from '../../common'; import { getFilteredLayers } from './layers'; import { isDataLayer } from './visualization'; -export function calculateMinInterval({ args: { layers } }: XYChartProps) { +export function calculateMinInterval({ args: { layers, minTimeBarInterval } }: XYChartProps) { const filteredLayers = getFilteredLayers(layers); if (filteredLayers.length === 0) return; const isTimeViz = filteredLayers.every((l) => isDataLayer(l) && l.xScaleType === 'time'); @@ -22,6 +22,9 @@ export function calculateMinInterval({ args: { layers } }: XYChartProps) { getColumnByAccessor(filteredLayers[0].xAccessor, filteredLayers[0].table.columns); if (!xColumn) return; + if (minTimeBarInterval) { + return search.aggs.parseInterval(minTimeBarInterval)?.as('milliseconds'); + } if (!isTimeViz) { const histogramInterval = search.aggs.getNumberHistogramIntervalByDatatableColumn(xColumn); if (typeof histogramInterval === 'number') { diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index 71ab14001c2c2..3e6fdf2c08ccc 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -169,7 +169,7 @@ export function DevToolsSettingsModal(props: Props) { // It only makes sense to show polling options if the user needs to fetch any data. const pollingFields = - fields || indices || templates ? ( + fields || indices || templates || dataStreams ? ( '', - docLinks: { links: { discover: { documentExplorer: '' } } }, - capabilities: { advancedSettings: { save: true } }, + ...discoverServiceMock, + capabilities: { ...discoverServiceMock.capabilities, advancedSettings: { save: true } }, storage: new LocalStorageMock({ [CALLOUT_STATE_KEY]: false }), } as unknown as DiscoverServices; @@ -57,4 +58,18 @@ describe('Document Explorer Update callout', () => { expect(result.find('.dscDocumentExplorerCallout').exists()).toBeFalsy(); }); + + it('should start a tour when the button is clicked', () => { + const result = mountWithIntl( + + + + + + ); + + expect(result.find({ isStepOpen: true })).toHaveLength(0); + findTestSubject(result, 'discoverTakeTourButton').simulate('click'); + expect(result.find({ isStepOpen: true })).toHaveLength(1); + }); }); diff --git a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_update_callout.tsx b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_update_callout.tsx index 5a9c6a68d6bb3..2fe073946627a 100644 --- a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_update_callout.tsx +++ b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_update_callout.tsx @@ -6,22 +6,21 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import './document_explorer_callout.scss'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, + EuiButtonEmpty, EuiButtonIcon, EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiLink, - useEuiTheme, } from '@elastic/eui'; -import { css } from '@emotion/react'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; +import { useDiscoverTourContext } from '../../../../components/discover_tour'; export const CALLOUT_STATE_KEY = 'discover:docExplorerUpdateCalloutClosed'; @@ -37,16 +36,9 @@ const updateStoredCalloutState = (newState: boolean, storage: Storage) => { * The callout that's displayed when Document explorer is enabled */ export const DocumentExplorerUpdateCallout = () => { - const { euiTheme } = useEuiTheme(); - const { storage, capabilities, docLinks } = useDiscoverServices(); + const { storage, capabilities } = useDiscoverServices(); const [calloutClosed, setCalloutClosed] = useState(getStoredCalloutState(storage)); - - const semiBoldStyle = useMemo( - () => css` - font-weight: ${euiTheme.font.weight.semiBold}; - `, - [euiTheme.font.weight.semiBold] - ); + const { onStartTour } = useDiscoverTourContext(); const onCloseCallout = useCallback(() => { updateStoredCalloutState(true, storage); @@ -67,44 +59,37 @@ export const DocumentExplorerUpdateCallout = () => { >

- - - - - ), - documentExplorer: ( - - - - - - ), - }} + id="discover.docExplorerUpdateCallout.description" + defaultMessage="Add relevant fields, reorder and sort columns, resize rows, and more in the document table." />

- - - + + + + + + + + + + + ); }; @@ -114,8 +99,8 @@ function CalloutTitle({ onCloseCallout }: { onCloseCallout: () => void }) { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index 7b715bb56a74c..1a84516fbdd8d 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -35,6 +35,7 @@ import { SortPairArr } from '../../../../components/doc_table/lib/get_sort'; import { ElasticSearchHit } from '../../../../types'; import { DocumentExplorerCallout } from '../document_explorer_callout'; import { DocumentExplorerUpdateCallout } from '../document_explorer_callout/document_explorer_update_callout'; +import { DiscoverTourProvider } from '../../../../components/discover_tour'; const DocTableInfiniteMemoized = React.memo(DocTableInfinite); const DataGridMemoized = React.memo(DiscoverGrid); @@ -157,7 +158,9 @@ function DiscoverDocumentsComponent({ )} {!isLegacy && ( <> - + + +
- +
setIsFlyoutVisible(true)} > diff --git a/src/plugins/discover/public/assets/discover_tour/add_fields.gif b/src/plugins/discover/public/assets/discover_tour/add_fields.gif new file mode 100644 index 0000000000000..c955a9aa99e08 Binary files /dev/null and b/src/plugins/discover/public/assets/discover_tour/add_fields.gif differ diff --git a/src/plugins/discover/public/assets/discover_tour/expand.gif b/src/plugins/discover/public/assets/discover_tour/expand.gif new file mode 100644 index 0000000000000..7131fed839478 Binary files /dev/null and b/src/plugins/discover/public/assets/discover_tour/expand.gif differ diff --git a/src/plugins/discover/public/assets/discover_tour/reorder_columns.gif b/src/plugins/discover/public/assets/discover_tour/reorder_columns.gif new file mode 100644 index 0000000000000..d3aeedb513c1e Binary files /dev/null and b/src/plugins/discover/public/assets/discover_tour/reorder_columns.gif differ diff --git a/src/plugins/discover/public/assets/discover_tour/rows_per_line.gif b/src/plugins/discover/public/assets/discover_tour/rows_per_line.gif new file mode 100644 index 0000000000000..66033d03d8fd2 Binary files /dev/null and b/src/plugins/discover/public/assets/discover_tour/rows_per_line.gif differ diff --git a/src/plugins/discover/public/assets/discover_tour/sort.gif b/src/plugins/discover/public/assets/discover_tour/sort.gif new file mode 100644 index 0000000000000..6d22b947a206f Binary files /dev/null and b/src/plugins/discover/public/assets/discover_tour/sort.gif differ diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.tsx index 6765a8d24f91a..a64d8521f503e 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.tsx @@ -12,6 +12,8 @@ import { euiLightVars as themeLight, euiDarkVars as themeDark } from '@kbn/ui-th import { i18n } from '@kbn/i18n'; import { DiscoverGridContext } from './discover_grid_context'; import { EsHitRecord } from '../../application/types'; +import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../discover_tour'; + /** * Button to expand a given row */ @@ -42,6 +44,7 @@ export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle return ( { + const mountComponent = (innerContent?: JSX.Element) => { + return mountWithIntl( + + {innerContent} + + ); + }; + + it('should start successfully', () => { + const buttonSubjToTestStart = 'discoverTourButtonTestStart'; + const InnerComponent = () => { + const tourContext = useDiscoverTourContext(); + + return ( + + {'Start the tour'} + + ); + }; + + const component = mountComponent(); + // all steps are hidden by default + expect(component.find(EuiTourStep)).toHaveLength(0); + + // one step should become visible after the tour is triggered + component.find(`[data-test-subj="${buttonSubjToTestStart}"]`).at(0).simulate('click'); + + expect(component.find(EuiTourStep)).toHaveLength(5); + expect( + component.find({ anchor: DISCOVER_TOUR_STEP_ANCHORS.addFields, isStepOpen: true }) + ).toHaveLength(1); + expect( + component.find({ anchor: DISCOVER_TOUR_STEP_ANCHORS.reorderColumns, isStepOpen: false }) + ).toHaveLength(1); + expect( + component.find({ anchor: DISCOVER_TOUR_STEP_ANCHORS.sort, isStepOpen: false }) + ).toHaveLength(1); + expect( + component.find({ anchor: DISCOVER_TOUR_STEP_ANCHORS.changeRowHeight, isStepOpen: false }) + ).toHaveLength(1); + expect( + component.find({ anchor: DISCOVER_TOUR_STEP_ANCHORS.expandDocument, isStepOpen: false }) + ).toHaveLength(1); + }); +}); diff --git a/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx b/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx new file mode 100644 index 0000000000000..030876291aeea --- /dev/null +++ b/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { htmlIdGenerator } from '@elastic/eui'; + +export const DISCOVER_TOUR_STEP_ANCHOR_IDS = { + addFields: htmlIdGenerator('dsc-tour-step-add-fields')(), + expandDocument: htmlIdGenerator('dsc-tour-step-expand')(), +}; + +export const DISCOVER_TOUR_STEP_ANCHORS = { + addFields: `#${DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}`, + reorderColumns: '[data-test-subj="dataGridColumnSelectorButton"]', + sort: '[data-test-subj="dataGridColumnSortingButton"]', + changeRowHeight: '[data-test-subj="dataGridDisplaySelectorButton"]', + expandDocument: `#${DISCOVER_TOUR_STEP_ANCHOR_IDS.expandDocument}`, +}; diff --git a/src/plugins/discover/public/components/discover_tour/discover_tour_context.ts b/src/plugins/discover/public/components/discover_tour/discover_tour_context.ts new file mode 100644 index 0000000000000..94bbfaf6555d2 --- /dev/null +++ b/src/plugins/discover/public/components/discover_tour/discover_tour_context.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createContext, useContext } from 'react'; + +export interface DiscoverTourContextProps { + onStartTour: () => void; + onNextTourStep: () => void; + onFinishTour: () => void; +} + +export const DiscoverTourContext = createContext({ + onStartTour: () => {}, + onNextTourStep: () => {}, + onFinishTour: () => {}, +}); + +export const useDiscoverTourContext = () => { + return useContext(DiscoverTourContext); +}; diff --git a/src/plugins/discover/public/components/discover_tour/discover_tour_provider.tsx b/src/plugins/discover/public/components/discover_tour/discover_tour_provider.tsx new file mode 100644 index 0000000000000..610fc0907ea5a --- /dev/null +++ b/src/plugins/discover/public/components/discover_tour/discover_tour_provider.tsx @@ -0,0 +1,306 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + useEuiTour, + EuiTourState, + EuiTourStep, + EuiTourStepProps, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiButtonProps, + EuiImage, + EuiSpacer, + EuiI18n, + EuiIcon, + EuiText, +} from '@elastic/eui'; +import { PLUGIN_ID } from '../../../common'; +import { useDiscoverServices } from '../../utils/use_discover_services'; +import { DiscoverTourContext, DiscoverTourContextProps } from './discover_tour_context'; +import { DISCOVER_TOUR_STEP_ANCHORS } from './discover_tour_anchors'; + +const MAX_WIDTH = 350; + +interface TourStepDefinition { + anchor: EuiTourStepProps['anchor']; + title: EuiTourStepProps['title']; + content: EuiTourStepProps['content']; + imageName: string; + imageAltText: string; +} + +const tourStepDefinitions: TourStepDefinition[] = [ + { + anchor: DISCOVER_TOUR_STEP_ANCHORS.addFields, + title: i18n.translate('discover.dscTour.stepAddFields.title', { + defaultMessage: 'Add fields to the table', + }), + content: ( + , + }} + /> + ), + imageName: 'add_fields.gif', + imageAltText: i18n.translate('discover.dscTour.stepAddFields.imageAltText', { + defaultMessage: + 'In the Available fields list, click the plus icon to toggle a field into the document table.', + }), + }, + { + anchor: DISCOVER_TOUR_STEP_ANCHORS.reorderColumns, + title: i18n.translate('discover.dscTour.stepReorderColumns.title', { + defaultMessage: 'Order the table columns', + }), + content: ( + + ), + imageName: 'reorder_columns.gif', + imageAltText: i18n.translate('discover.dscTour.stepReorderColumns.imageAltText', { + defaultMessage: 'Use the Columns popover to drag the columns to the order you prefer.', + }), + }, + { + anchor: DISCOVER_TOUR_STEP_ANCHORS.sort, + title: i18n.translate('discover.dscTour.stepSort.title', { + defaultMessage: 'Sort on one or more fields', + }), + content: ( + + ), + imageName: 'sort.gif', + imageAltText: i18n.translate('discover.dscTour.stepSort.imageAltText', { + defaultMessage: + 'Click a column header and select the desired sort order. Adjust a multi-field sort using the fields sorted popover.', + }), + }, + { + anchor: DISCOVER_TOUR_STEP_ANCHORS.changeRowHeight, + title: i18n.translate('discover.dscTour.stepChangeRowHeight.title', { + defaultMessage: 'Change the row height', + }), + content: ( + + ), + imageName: 'rows_per_line.gif', + imageAltText: i18n.translate('discover.dscTour.stepChangeRowHeight.imageAltText', { + defaultMessage: + 'Click the display options icon to adjust the row height to fit the contents.', + }), + }, + { + anchor: DISCOVER_TOUR_STEP_ANCHORS.expandDocument, + title: i18n.translate('discover.dscTour.stepExpand.title', { + defaultMessage: 'Expand documents', + }), + content: ( + + ), + }} + /> + ), + imageName: 'expand.gif', + imageAltText: i18n.translate('discover.dscTour.stepExpand.imageAltText', { + defaultMessage: + 'Click the expand icon to inspect and filter the fields in the document and view the document in context.', + }), + }, +]; + +const FIRST_STEP = 1; + +const prepareTourSteps = ( + stepDefinitions: TourStepDefinition[], + getAssetPath: (imageName: string) => string +): EuiTourStepProps[] => + stepDefinitions.map((stepDefinition, index) => ({ + step: index + 1, + anchor: stepDefinition.anchor, + title: stepDefinition.title, + maxWidth: MAX_WIDTH, + content: ( + <> + +

{stepDefinition.content}

+
+ {stepDefinition.imageName && ( + <> + + + + )} + + ), + })) as EuiTourStepProps[]; + +const findNextAvailableStep = ( + steps: EuiTourStepProps[], + currentTourStep: number +): number | null => { + const nextStep = steps.find( + (step) => + step.step > currentTourStep && + typeof step.anchor === 'string' && + document.querySelector(step.anchor) + ); + + return nextStep?.step ?? null; +}; + +const tourConfig: EuiTourState = { + currentTourStep: FIRST_STEP, + isTourActive: false, + tourPopoverWidth: MAX_WIDTH, + tourSubtitle: '', +}; + +export const DiscoverTourProvider: React.FC = ({ children }) => { + const services = useDiscoverServices(); + const prependToBasePath = services.core.http.basePath.prepend; + const getAssetPath = useCallback( + (imageName: string) => { + return prependToBasePath(`/plugins/${PLUGIN_ID}/assets/discover_tour/${imageName}`); + }, + [prependToBasePath] + ); + const tourSteps = useMemo( + () => prepareTourSteps(tourStepDefinitions, getAssetPath), + [getAssetPath] + ); + const [steps, actions, reducerState] = useEuiTour(tourSteps, tourConfig); + const currentTourStep = reducerState.currentTourStep; + const isTourActive = reducerState.isTourActive; + + const onStartTour = useCallback(() => { + actions.resetTour(); + actions.goToStep(FIRST_STEP, true); + }, [actions]); + + const onNextTourStep = useCallback(() => { + const nextAvailableStep = findNextAvailableStep(steps, currentTourStep); + if (nextAvailableStep) { + actions.goToStep(nextAvailableStep); + } else { + actions.finishTour(); + } + }, [actions, steps, currentTourStep]); + + const onFinishTour = useCallback(() => { + actions.finishTour(); + }, [actions]); + + const contextValue: DiscoverTourContextProps = useMemo( + () => ({ + onStartTour, + onNextTourStep, + onFinishTour, + }), + [onStartTour, onNextTourStep, onFinishTour] + ); + + return ( + + {isTourActive && + steps.map((step) => ( + + } + /> + ))} + {children} + + ); +}; + +export const DiscoverTourStepFooterAction: React.FC<{ + isLastStep: boolean; + onNextTourStep: DiscoverTourContextProps['onNextTourStep']; + onFinishTour: DiscoverTourContextProps['onFinishTour']; +}> = ({ isLastStep, onNextTourStep, onFinishTour }) => { + const actionButtonProps: Partial = { + size: 's', + color: 'success', + }; + + return ( + + {!isLastStep && ( + + + {EuiI18n({ token: 'core.euiTourStep.skipTour', default: 'Skip tour' })} + + + )} + + {isLastStep ? ( + + {EuiI18n({ token: 'core.euiTourStep.endTour', default: 'End tour' })} + + ) : ( + + {EuiI18n({ token: 'core.euiTourStep.nextStep', default: 'Next' })} + + )} + + + ); +}; diff --git a/src/plugins/discover/public/components/discover_tour/index.ts b/src/plugins/discover/public/components/discover_tour/index.ts new file mode 100644 index 0000000000000..b60c79a80f54d --- /dev/null +++ b/src/plugins/discover/public/components/discover_tour/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DiscoverTourProvider } from './discover_tour_provider'; +export { useDiscoverTourContext } from './discover_tour_context'; +export { DISCOVER_TOUR_STEP_ANCHOR_IDS, DISCOVER_TOUR_STEP_ANCHORS } from './discover_tour_anchors'; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 8330751cf13f0..b4a33119c23b2 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -35,6 +35,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import { PLUGIN_ID } from '../common'; import { DocViewInput, DocViewInputFn } from './services/doc_views/doc_views_types'; import { DocViewsRegistry } from './services/doc_views/doc_views_registry'; import { @@ -257,7 +258,7 @@ export class DiscoverPlugin }; core.application.register({ - id: 'discover', + id: PLUGIN_ID, title: 'Discover', updater$: this.appStateUpdater.asObservable(), order: 1000, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0c0cafad6bec6..adabf8ea3d854 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9610,6 +9610,142 @@ } } }, + "usage_collector_stats": { + "properties": { + "total_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration to grab usage stats for all collectors in milliseconds" + } + }, + "total_is_ready_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration of the isReady function for all collectors in milliseconds" + } + }, + "total_fetch_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration of the fetch function for all ready collectors in milliseconds" + } + }, + "is_ready_duration_breakdown": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword", + "_meta": { + "description": "The name of the collector" + } + }, + "duration": { + "type": "long", + "_meta": { + "description": "The execution duration of the isReady function for the collector in milliseconds" + } + } + } + } + }, + "fetch_duration_breakdown": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword", + "_meta": { + "description": "The name of the collector" + } + }, + "duration": { + "type": "long", + "_meta": { + "description": "The execution duration of the fetch function for the collector in milliseconds" + } + } + } + } + }, + "not_ready": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that returned false from the isReady function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that returned false from the isReady function" + } + } + } + } + }, + "not_ready_timeout": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that timedout during the isReady function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of collectors that timedout during the isReady function" + } + } + } + } + }, + "succeeded": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that returned true from the fetch function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that returned true from the fetch function" + } + } + } + } + }, + "failed": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that threw an error from the fetch function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that threw an error from the fetch function" + } + } + } + } + } + } + }, "vis_type_table": { "properties": { "total": { diff --git a/src/plugins/telemetry/schema/oss_root.json b/src/plugins/telemetry/schema/oss_root.json index cf9b881facef2..e526dc6413916 100644 --- a/src/plugins/telemetry/schema/oss_root.json +++ b/src/plugins/telemetry/schema/oss_root.json @@ -194,62 +194,6 @@ "properties": { "kibana_config_usage": { "type": "pass_through" - }, - "usage_collector_stats": { - "properties": { - "not_ready": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "not_ready_timeout": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "succeeded": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "failed": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - } - } } } } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx index cf8ef5dbabb20..c0fe3dc497025 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx @@ -14,6 +14,7 @@ import { withKibana } from '@kbn/kibana-react-plugin/public'; import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box'; import { PhraseSuggestorUI, PhraseSuggestorProps } from './phrase_suggestor'; import { ValueInputType } from './value_input_type'; +import { getFieldValidityAndErrorMessage } from '../../utils/helpers'; interface Props extends PhraseSuggestorProps { value?: string; @@ -24,6 +25,11 @@ interface Props extends PhraseSuggestorProps { class PhraseValueInputUI extends PhraseSuggestorUI { public render() { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage( + this.props.field, + this.props.value + ); + return ( { id: 'unifiedSearch.filter.filterEditor.valueInputLabel', defaultMessage: 'Value', })} + isInvalid={isInvalid} + error={errorMessage} > {this.isSuggestingValues() ? ( this.renderWithSuggestions() @@ -44,6 +52,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI { value={this.props.value} onChange={this.props.onChange} field={this.props.field} + isInvalid={isInvalid} /> )} diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx index 7d4b597129847..1e50e92cec7bb 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx @@ -23,6 +23,7 @@ interface Props { controlOnly?: boolean; className?: string; fullWidth?: boolean; + isInvalid?: boolean; } class ValueInputTypeUI extends Component { @@ -33,6 +34,7 @@ class ValueInputTypeUI extends Component { } return value; }; + public render() { const value = this.props.value; const type = this.props.field.type; @@ -73,7 +75,7 @@ class ValueInputTypeUI extends Component { value={value} onChange={this.onChange} onBlur={this.onBlur} - isInvalid={!isEmpty(value) && !validateParams(value, this.props.field)} + isInvalid={this.props.isInvalid} controlOnly={this.props.controlOnly} className={this.props.className} /> diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 8bdffa5e989d0..387b5e751ff44 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -8,7 +8,7 @@ import './filter_item.scss'; -import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui'; +import { EuiContextMenu, EuiContextMenuPanel, EuiPopover, EuiPopoverProps } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n-react'; import { Filter, @@ -67,8 +67,15 @@ export const FILTER_EDITOR_WIDTH = 800; export function FilterItem(props: FilterItemProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [indexPatternExists, setIndexPatternExists] = useState(undefined); + const [renderedComponent, setRenderedComponent] = useState('menu'); const { id, filter, indexPatterns, hiddenPanelOptions } = props; + useEffect(() => { + if (isPopoverOpen) { + setRenderedComponent('menu'); + } + }, [isPopoverOpen]); + useEffect(() => { const index = props.filter.meta.index; let isSubscribed = true; @@ -194,8 +201,10 @@ export function FilterItem(props: FilterItemProps) { defaultMessage: 'Edit filter', }), icon: 'pencil', - panel: 1, 'data-test-subj': 'editFilter', + onClick: () => { + setRenderedComponent('editFilter'); + }, }, { name: negate @@ -255,23 +264,6 @@ export function FilterItem(props: FilterItemProps) { id: 0, items: mainPanelItems, }, - { - id: 1, - width: FILTER_EDITOR_WIDTH, - content: ( -
- { - setIsPopoverOpen(false); - }} - timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} - /> -
- ), - }, ]; } @@ -401,7 +393,25 @@ export function FilterItem(props: FilterItemProps) { return ( - + {renderedComponent === 'menu' ? ( + + ) : ( + + { + setIsPopoverOpen(false); + }} + timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} + /> +
, + ]} + /> + )} ); } diff --git a/src/plugins/unified_search/public/utils/helpers.test.ts b/src/plugins/unified_search/public/utils/helpers.test.ts new file mode 100644 index 0000000000000..4659e35602228 --- /dev/null +++ b/src/plugins/unified_search/public/utils/helpers.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getFieldValidityAndErrorMessage } from './helpers'; +import { IFieldType } from '@kbn/data-views-plugin/common'; + +const mockField = { + type: 'date', +} as IFieldType; + +describe('Check field validity and error message', () => { + it('should return a message that the entered date is not incorrect', () => { + const output = getFieldValidityAndErrorMessage(mockField, Date()); + + expect(output).toEqual({ + isInvalid: false, + }); + }); + + it('should show error', () => { + const output = getFieldValidityAndErrorMessage(mockField, 'Date'); + + expect(output).toEqual({ + isInvalid: true, + errorMessage: 'Invalid date format provided', + }); + }); +}); diff --git a/src/plugins/unified_search/public/utils/helpers.ts b/src/plugins/unified_search/public/utils/helpers.ts new file mode 100644 index 0000000000000..1c056636c67b8 --- /dev/null +++ b/src/plugins/unified_search/public/utils/helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IFieldType } from '@kbn/data-views-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; +import { isEmpty } from 'lodash'; +import { validateParams } from '../filter_bar/filter_editor/lib/filter_editor_utils'; + +export const getFieldValidityAndErrorMessage = ( + field: IFieldType, + value?: string | undefined +): { isInvalid: boolean; errorMessage?: string } => { + const type = field.type; + switch (type) { + case KBN_FIELD_TYPES.DATE: + case KBN_FIELD_TYPES.DATE_RANGE: + if (!isEmpty(value) && !validateParams(value, field)) { + return invalidFormatError(); + } + break; + default: + break; + } + return noError(); +}; + +const noError = (): { isInvalid: boolean } => { + return { isInvalid: false }; +}; + +const invalidFormatError = (): { isInvalid: boolean; errorMessage?: string } => { + return { + isInvalid: true, + errorMessage: i18n.translate( + 'unifiedSearch.filter.filterBar.invalidDateFormatProvidedErrorMessage', + { + defaultMessage: 'Invalid date format provided', + } + ), + }; +}; diff --git a/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap b/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap new file mode 100644 index 0000000000000..a466fbcb02841 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollectorSet bulkFetch skips collectors that are not ready 1`] = ` +Array [ + Object { + "result": Object {}, + "type": "ready_col", + }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + Object { + "duration": 0, + "name": "not_ready_col", + }, + ], + "not_ready": Object { + "count": 1, + "names": Array [ + "not_ready_col", + ], + }, + "not_ready_timeout": Object { + "count": 0, + "names": Array [], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + "total_duration": 0, + "total_fetch_duration": 0, + "total_is_ready_duration": 0, + }, + "type": "usage_collector_stats", + }, +] +`; + +exports[`CollectorSet bulkFetch skips collectors that have timed out 1`] = ` +Array [ + Object { + "result": Object {}, + "type": "ready_col", + }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": Any, + "name": "ready_col", + }, + Object { + "duration": Any, + "name": "timeout_col", + }, + ], + "not_ready": Object { + "count": 0, + "names": Array [], + }, + "not_ready_timeout": Object { + "count": 1, + "names": Array [ + "timeout_col", + ], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + "total_duration": Any, + "total_fetch_duration": 0, + "total_is_ready_duration": Any, + }, + "type": "usage_collector_stats", + }, +] +`; diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index a3e8d2de19e60..1987055e6faec 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -102,6 +102,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -132,6 +137,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 0, names: [] }, failed: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -161,6 +171,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -189,6 +204,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -354,39 +374,52 @@ describe('CollectorSet', () => { expect(mockIsNotReady).toBeCalledTimes(1); expect(mockNonReadyFetch).toBeCalledTimes(0); - expect(results).toMatchInlineSnapshot(` - Array [ - Object { - "result": Object {}, - "type": "ready_col", - }, - Object { - "result": Object { - "failed": Object { - "count": 0, - "names": Array [], - }, - "not_ready": Object { - "count": 1, - "names": Array [ - "not_ready_col", - ], + expect(results).toMatchSnapshot([ + { + result: {}, + type: 'ready_col', + }, + { + result: { + failed: { + count: 0, + names: [], + }, + fetch_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "not_ready_timeout": Object { - "count": 0, - "names": Array [], + ], + is_ready_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "succeeded": Object { - "count": 1, - "names": Array [ - "ready_col", - ], + { + name: 'not_ready_col', + duration: 0, }, + ], + not_ready: { + count: 1, + names: ['not_ready_col'], + }, + not_ready_timeout: { + count: 0, + names: [], + }, + succeeded: { + count: 1, + names: ['ready_col'], }, - "type": "usage_collector_stats", + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, - ] - `); + type: 'usage_collector_stats', + }, + ]); }); it('skips collectors that have timed out', async () => { @@ -428,39 +461,52 @@ describe('CollectorSet', () => { expect(mockTimedOutReady).toBeCalledTimes(1); expect(mockNonReadyFetch).toBeCalledTimes(0); - expect(results).toMatchInlineSnapshot(` - Array [ - Object { - "result": Object {}, - "type": "ready_col", - }, - Object { - "result": Object { - "failed": Object { - "count": 0, - "names": Array [], - }, - "not_ready": Object { - "count": 0, - "names": Array [], + expect(results).toMatchSnapshot([ + { + result: {}, + type: 'ready_col', + }, + { + result: { + failed: { + count: 0, + names: [], + }, + fetch_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "not_ready_timeout": Object { - "count": 1, - "names": Array [ - "timeout_col", - ], + ], + is_ready_duration_breakdown: [ + { + name: 'ready_col', + duration: expect.any(Number), }, - "succeeded": Object { - "count": 1, - "names": Array [ - "ready_col", - ], + { + name: 'timeout_col', + duration: expect.any(Number), }, + ], + not_ready: { + count: 0, + names: [], + }, + not_ready_timeout: { + count: 1, + names: ['timeout_col'], + }, + succeeded: { + count: 1, + names: ['ready_col'], }, - "type": "usage_collector_stats", + total_duration: expect.any(Number), + total_fetch_duration: 0, + total_is_ready_duration: expect.any(Number), }, - ] - `); + type: 'usage_collector_stats', + }, + ]); }); it('passes context to fetch', async () => { diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 90cf7e6450ea4..8251b95a1beb8 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -7,6 +7,7 @@ */ import { withTimeout } from '@kbn/std'; import { snakeCase } from 'lodash'; + import type { Logger, ElasticsearchClient, @@ -15,10 +16,13 @@ import type { ExecutionContextSetup, } from '@kbn/core/server'; import { Collector } from './collector'; -import type { ICollector, CollectorOptions } from './types'; +import type { ICollector, CollectorOptions, CollectorFetchContext } from './types'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../../common/constants'; +import { createPerformanceObsHook, perfTimerify } from './measure_duration'; +import { usageCollectorsStatsCollector } from './collector_stats'; +const SECOND_IN_MS = 1000; // Needed for the general array containing all the collectors. We don't really care about their types here // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyCollector = ICollector; @@ -34,14 +38,6 @@ export interface CollectorSetConfig { collectors?: AnyCollector[]; } -// Schema manually added in src/plugins/telemetry/schema/oss_root.json under `stack_stats.kibana.plugins.usage_collector_stats` -interface CollectorStats { - not_ready: { count: number; names: string[] }; - not_ready_timeout: { count: number; names: string[] }; - succeeded: { count: number; names: string[] }; - failed: { count: number; names: string[] }; -} - export class CollectorSet { private readonly logger: Logger; private readonly executionContext: ExecutionContextSetup; @@ -115,22 +111,37 @@ export class CollectorSet { ); } - const secondInMs = 1000; + const timeoutMs = this.maximumWaitTimeForAllCollectorsInS * SECOND_IN_MS; const collectorsWithStatus: CollectorWithStatus[] = await Promise.all( [...collectors.values()].map(async (collector) => { - const isReadyWithTimeout = await withTimeout({ - promise: (async (): Promise => { + const wrappedPromise = perfTimerify( + `is_ready_${collector.type}`, + async (): Promise => { try { return await collector.isReady(); } catch (err) { this.logger.debug(`Collector ${collector.type} failed to get ready. ${err}`); return false; } - })(), - timeoutMs: this.maximumWaitTimeForAllCollectorsInS * secondInMs, + } + ); + + const isReadyWithTimeout = await withTimeout({ + promise: wrappedPromise(), + timeoutMs, }); - return { isReadyWithTimeout, collector }; + if (isReadyWithTimeout.timedout) { + return { isReadyWithTimeout, collector }; + } + + return { + isReadyWithTimeout: { + value: isReadyWithTimeout.value, + timedout: isReadyWithTimeout.timedout, + }, + collector, + }; }) ); @@ -176,55 +187,113 @@ export class CollectorSet { }; }; + private fetchCollector = async ( + collector: AnyCollector, + context: CollectorFetchContext + ): Promise<{ + result?: unknown; + status: 'failed' | 'success'; + type: string; + }> => { + const { type } = collector; + this.logger.debug(`Fetching data from ${type} collector`); + const executionContext: KibanaExecutionContext = { + type: 'usage_collection', + name: 'collector.fetch', + id: type, + description: `Fetch method in the Collector "${type}"`, + }; + + try { + const result = await this.executionContext.withContext(executionContext, () => + collector.fetch(context) + ); + return { type, result, status: 'success' as const }; + } catch (err) { + this.logger.warn(err); + this.logger.warn(`Unable to fetch data from ${type} collector`); + return { type, status: 'failed' as const }; + } + }; + public bulkFetch = async ( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, collectors: Map = this.collectors ) => { this.logger.debug(`Getting ready collectors`); + const getMarks = createPerformanceObsHook(); const { readyCollectors, nonReadyCollectorTypes, timedOutCollectorsTypes } = await this.getReadyCollectors(collectors); - const collectorStats: CollectorStats = { - not_ready: { count: nonReadyCollectorTypes.length, names: nonReadyCollectorTypes }, - not_ready_timeout: { count: timedOutCollectorsTypes.length, names: timedOutCollectorsTypes }, - succeeded: { count: 0, names: [] }, - failed: { count: 0, names: [] }, - }; + // freeze object to prevent collectors from mutating it. + const context = Object.freeze({ esClient, soClient }); - const responses = await Promise.all( + const fetchExecutions = await Promise.all( readyCollectors.map(async (collector) => { - this.logger.debug(`Fetching data from ${collector.type} collector`); - try { - const context = { esClient, soClient }; - const executionContext: KibanaExecutionContext = { - type: 'usage_collection', - name: 'collector.fetch', - id: collector.type, - description: `Fetch method in the Collector "${collector.type}"`, - }; - const result = await this.executionContext.withContext(executionContext, () => - collector.fetch(context) - ); - collectorStats.succeeded.names.push(collector.type); - return { type: collector.type, result }; - } catch (err) { - this.logger.warn(err); - this.logger.warn(`Unable to fetch data from ${collector.type} collector`); - collectorStats.failed.names.push(collector.type); - } + const wrappedPromise = perfTimerify( + `fetch_${collector.type}`, + async () => await this.fetchCollector(collector, context) + ); + + return await wrappedPromise(); }) ); + const durationMarks = getMarks(); + + const isReadyExecutionDurationByType = [ + ...readyCollectors.map(({ type }) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`is_ready_${type}`] || 0; + return { duration, type }; + }), + ...nonReadyCollectorTypes.map((type) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`is_ready_${type}`] || 0; + return { duration, type }; + }), + ...timedOutCollectorsTypes.map((type) => { + const timeoutMs = this.maximumWaitTimeForAllCollectorsInS * SECOND_IN_MS; + // if undefined default to timeoutMs since the collector timedout + const duration = durationMarks[`is_ready_${type}`] || timeoutMs; + return { duration, type }; + }), + ]; + + const fetchExecutionDurationByType = fetchExecutions.map(({ type, status }) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`fetch_${type}`] || 0; + return { duration, type, status }; + }); - collectorStats.succeeded.count = collectorStats.succeeded.names.length; - collectorStats.failed.count = collectorStats.failed.names.length; - - // Treat it as just another "collector" - responses.push({ type: 'usage_collector_stats', result: collectorStats }); - - return responses.filter( - (response): response is { type: string; result: unknown } => typeof response !== 'undefined' + const usageCollectorStats = usageCollectorsStatsCollector( + // pass `this` as `usageCollection` to the collector to mimic + // registering a collector via usageCollection.SetupContract + this, + { + // isReady stats + nonReadyCollectorTypes, + timedOutCollectorsTypes, + isReadyExecutionDurationByType, + + // fetch stats + fetchExecutionDurationByType, + } ); + + return [ + ...fetchExecutions + // pluck type and result from collector object + .map(({ type, result }) => ({ type, result })) + // only keep data of collectors thar returned a result + .filter( + (response): response is { type: string; result: unknown } => + typeof response?.result !== 'undefined' + ), + + // Treat collector stats as just another "collector" + { type: usageCollectorStats.type, result: usageCollectorStats.fetch(context) }, + ]; }; /* diff --git a/src/plugins/usage_collection/server/collector/collector_stats/README.md b/src/plugins/usage_collection/server/collector/collector_stats/README.md new file mode 100644 index 0000000000000..dcd65329c63dc --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/README.md @@ -0,0 +1,70 @@ +## Collector Stats Collector + +The `usage_collector_stats` collector adds telemetry around the execution duration grabbing usage and the status of the collectors: +- Total number and names of collectors that return `true` from `isReady` +- Total number and names of collectors that return `false` from from `isReady` +- Total number and names of collectors that timeout from from `isReady` +- Total number and names of ready collectors that successfully return data from `fetch` +- Total number and names of ready collectors that fail to return data from `fetch` +- Total execution duration to grab all collectors +- Total execution duration to get the `isReady` state of each collector +- Total execution duration to get the `fetch` objects from each collector +- Breakdown per collector type with details on the execution duration for `fetch` and `isReady` + +The overall durations show the overall health of the collection mechanism, while the breakdown objects help diagnose specific collectors and improve upon them. + +## Why is this in telemetry and not in CI? +Adding limits and checks in CI is a good idea for catching early issues. Collecting these metrics via telemetry will also help us identify bottlenecks against real-world use cases from Kibanas in the wild. + +## What does the usage collector stats look like? + +The collector can be found under `stack_stats.kibana.plugins.usage_collector_stats` and looks like this: + +```json +"usage_collector_stats": { + "not_ready": { + "count": 1, + "names": [ + "cloud_provider" + ] + }, + "not_ready_timeout": { + "count": 0, + "names": [] + }, + "succeeded": { + "count": 54, + "names": [ + "task_manager", + "ui_counters", + "usage_counters", + "kibana_stats", + "kibana", + ... + ] + }, + "failed": { + "count": 0, + "names": [] + }, + "total_is_ready_duration": 0.07500024700000003, + "total_fetch_duration": 0.35939233100000006, + "total_duration": 0.4343925780000001, + "is_ready_duration_breakdown": { + { "name": "task_manager", "duration": 0.001828041 }, + { "name": "ui_counters", "duration": 0.001790625 }, + { "name": "usage_counters", "duration": 0.001778125 }, + { "name": "kibana_stats", "duration": 0.001764709 }, + { "name": "kibana", "duration": 0.001748917 }, + ... + }, + "fetch_duration_breakdown": { + { "name": "task_manager", "duration": 0.011157708 }, + { "name": "ui_counters", "duration": 0.011002625 }, + { "name": "usage_counters", "duration": 0.009945833 }, + { "name": "kibana_stats", "duration": 0.009424458 }, + { "name": "kibana", "duration": 0.009406416 }, + ... + } +} +``` \ No newline at end of file diff --git a/src/plugins/usage_collection/server/collector/collector_stats/index.ts b/src/plugins/usage_collection/server/collector/collector_stats/index.ts new file mode 100644 index 0000000000000..374170ad99a6a --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { usageCollectorsStatsCollector } from './usage_collector_stats_collector'; diff --git a/src/plugins/usage_collection/server/collector/collector_stats/schema.ts b/src/plugins/usage_collection/server/collector/collector_stats/schema.ts new file mode 100644 index 0000000000000..c95301f79a296 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/schema.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { MakeSchemaFrom } from '../types'; +import type { CollectorsStats } from './usage_collector_stats_collector'; + +export const collectorsStatsSchema: MakeSchemaFrom = { + total_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration to grab usage stats for all collectors in milliseconds', + }, + }, + total_is_ready_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration of the isReady function for all collectors in milliseconds', + }, + }, + total_fetch_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration of the fetch function for all ready collectors in milliseconds', + }, + }, + is_ready_duration_breakdown: { + type: 'array', + items: { + name: { + type: 'keyword', + _meta: { + description: 'The name of the collector', + }, + }, + duration: { + type: 'long', + _meta: { + description: + 'The execution duration of the isReady function for the collector in milliseconds', + }, + }, + }, + }, + fetch_duration_breakdown: { + type: 'array', + items: { + name: { + type: 'keyword', + _meta: { + description: 'The name of the collector', + }, + }, + duration: { + type: 'long', + _meta: { + description: + 'The execution duration of the fetch function for the collector in milliseconds', + }, + }, + }, + }, + not_ready: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that returned false from the isReady function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'The name of the of collectors that returned false from the isReady function', + }, + }, + }, + }, + not_ready_timeout: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that timedout during the isReady function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of collectors that timedout during the isReady function', + }, + }, + }, + }, + succeeded: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that returned true from the fetch function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of the of collectors that returned true from the fetch function', + }, + }, + }, + }, + failed: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that threw an error from the fetch function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of the of collectors that threw an error from the fetch function', + }, + }, + }, + }, +}; diff --git a/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts new file mode 100644 index 0000000000000..6df345343be0c --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + usageCollectorsStatsCollector, + CollectorsStatsCollectorParams, +} from './usage_collector_stats_collector'; +import { UsageCollector } from '../usage_collector'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createCollectorFetchContextMock } from '../../mocks'; + +describe('usageCollectorsStatsCollector', () => { + const logger = loggingSystemMock.createLogger(); + const mockFetchContext = createCollectorFetchContextMock(); + const mockMakeUsageCollector = jest.fn().mockImplementation((args) => { + return new UsageCollector(logger, args); + }); + const mockCollectorSet = { makeUsageCollector: mockMakeUsageCollector }; + + const createCollectorStats = ( + params?: Partial + ): CollectorsStatsCollectorParams => ({ + fetchExecutionDurationByType: [], + isReadyExecutionDurationByType: [], + nonReadyCollectorTypes: [], + timedOutCollectorsTypes: [], + ...params, + }); + + it('calls makeUsageCollector to create a collector', () => { + const collectorStats = createCollectorStats(); + const collector = usageCollectorsStatsCollector(mockCollectorSet, collectorStats); + expect(mockMakeUsageCollector).toBeCalledTimes(1); + expect(collector.type).toMatchInlineSnapshot(`"usage_collector_stats"`); + expect(typeof collector.fetch).toBe('function'); + expect(collector).toBeInstanceOf(UsageCollector); + }); + + it('returns collector stats totals and breakdowns from fetch', async () => { + const collectorStats = createCollectorStats({ + fetchExecutionDurationByType: [ + { duration: 1.2, status: 'success', type: 'SUCCESS_COLLECTOR' }, + { duration: 8, status: 'success', type: 'SUCCESS_COLLECTOR_2' }, + { duration: 2.2, status: 'failed', type: 'FAILED_COLLECTOR' }, + ], + isReadyExecutionDurationByType: [ + { duration: 10.2, type: 'SUCCESS_COLLECTOR' }, + { duration: 4.2, type: 'SUCCESS_COLLECTOR_2' }, + { duration: 12, type: 'FAILED_COLLECTOR' }, + ], + nonReadyCollectorTypes: ['NON_READY_COLLECTOR'], + timedOutCollectorsTypes: ['TIMED_OUT_READY_COLLECTOR'], + }); + const collector = usageCollectorsStatsCollector(mockCollectorSet, collectorStats); + const result = await collector.fetch(mockFetchContext); + expect(result).toMatchInlineSnapshot(` + Object { + "failed": Object { + "count": 1, + "names": Array [ + "FAILED_COLLECTOR", + ], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 1.2, + "name": "SUCCESS_COLLECTOR", + }, + Object { + "duration": 8, + "name": "SUCCESS_COLLECTOR_2", + }, + Object { + "duration": 2.2, + "name": "FAILED_COLLECTOR", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": 10.2, + "name": "SUCCESS_COLLECTOR", + }, + Object { + "duration": 4.2, + "name": "SUCCESS_COLLECTOR_2", + }, + Object { + "duration": 12, + "name": "FAILED_COLLECTOR", + }, + ], + "not_ready": Object { + "count": 1, + "names": Array [ + "NON_READY_COLLECTOR", + ], + }, + "not_ready_timeout": Object { + "count": 1, + "names": Array [ + "TIMED_OUT_READY_COLLECTOR", + ], + }, + "succeeded": Object { + "count": 2, + "names": Array [ + "SUCCESS_COLLECTOR", + "SUCCESS_COLLECTOR_2", + ], + }, + "total_duration": 37.8, + "total_fetch_duration": 11.399999999999999, + "total_is_ready_duration": 26.4, + } + `); + }); +}); diff --git a/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts new file mode 100644 index 0000000000000..4bf7754e07018 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { sumBy } from 'lodash'; +import { collectorsStatsSchema } from './schema'; +import type { CollectorSet } from '../collector_set'; + +export interface CollectorsStats { + not_ready: { count: number; names: string[] }; + not_ready_timeout: { count: number; names: string[] }; + succeeded: { count: number; names: string[] }; + failed: { count: number; names: string[] }; + + total_duration: number; + total_is_ready_duration: number; + total_fetch_duration: number; + is_ready_duration_breakdown: Array<{ name: string; duration: number }>; + fetch_duration_breakdown: Array<{ name: string; duration: number }>; +} + +export interface CollectorsStatsCollectorParams { + nonReadyCollectorTypes: string[]; + timedOutCollectorsTypes: string[]; + isReadyExecutionDurationByType: Array<{ duration: number; type: string }>; + fetchExecutionDurationByType: Array<{ + duration: number; + type: string; + status: 'failed' | 'success'; + }>; +} + +export const usageCollectorsStatsCollector = ( + usageCollection: Pick, + { + nonReadyCollectorTypes, + timedOutCollectorsTypes, + isReadyExecutionDurationByType, + fetchExecutionDurationByType, + }: CollectorsStatsCollectorParams +) => { + return usageCollection.makeUsageCollector({ + type: 'usage_collector_stats', + isReady: () => true, + schema: collectorsStatsSchema, + fetch: () => { + const totalIsReadyDuration = sumBy(isReadyExecutionDurationByType, 'duration'); + const totalFetchDuration = sumBy(fetchExecutionDurationByType, 'duration'); + + const succeededCollectorTypes = fetchExecutionDurationByType + .filter(({ status }) => status === 'success') + .map(({ type }) => type); + const failedCollectorTypes = fetchExecutionDurationByType + .filter(({ status }) => status === 'failed') + .map(({ type }) => type); + + const collectorsStats: CollectorsStats = { + // isReady and fetch stats + not_ready: { count: nonReadyCollectorTypes.length, names: nonReadyCollectorTypes }, + not_ready_timeout: { + count: timedOutCollectorsTypes.length, + names: timedOutCollectorsTypes, + }, + succeeded: { count: succeededCollectorTypes.length, names: succeededCollectorTypes }, + failed: { count: failedCollectorTypes.length, names: failedCollectorTypes }, + + // total durations + total_is_ready_duration: totalIsReadyDuration, + total_fetch_duration: totalFetchDuration, + total_duration: totalIsReadyDuration + totalFetchDuration, + + // durations breakdown + is_ready_duration_breakdown: isReadyExecutionDurationByType.map( + ({ type: name, duration }) => ({ name, duration }) + ), + fetch_duration_breakdown: fetchExecutionDurationByType.map(({ type: name, duration }) => ({ + name, + duration, + })), + }; + + return collectorsStats; + }, + }); +}; diff --git a/src/plugins/usage_collection/server/collector/measure_duration.ts b/src/plugins/usage_collection/server/collector/measure_duration.ts new file mode 100644 index 0000000000000..aa17a39d6bf3b --- /dev/null +++ b/src/plugins/usage_collection/server/collector/measure_duration.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { PerformanceObserver, performance } from 'perf_hooks'; + +export const createPerformanceObsHook = () => { + const marks: Record = {}; + const obs = new PerformanceObserver((items) => { + for (const { duration, name } of items.getEntries()) { + marks[name] = duration; + } + + performance.clearMarks(); + }); + + obs.observe({ entryTypes: ['function'] }); + + // teardown function returns the marked measurements. + // returning the data after teardown ensures that we proprely teardown + // the observer. + return () => { + obs.disconnect(); + return marks; + }; +}; + +/** + * A wrapper around performance.timerify which defined the name of the returned + * wrapped function to help identify observed function types inside the `PerformanceObserver`. + * + * @param name name of the function used to track the performance of the function execution + * @param fn the function to be wrapped by the performance.timerify method. + * @returns + */ +export const perfTimerify = unknown>(name: string, fn: T) => { + return performance.timerify(Object.defineProperty(fn, 'name', { value: name })); +}; diff --git a/src/plugins/vis_default_editor/public/components/sidebar/state/actions.ts b/src/plugins/vis_default_editor/public/components/sidebar/state/actions.ts index e53890de3196e..b5370b3781d11 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/state/actions.ts +++ b/src/plugins/vis_default_editor/public/components/sidebar/state/actions.ts @@ -20,7 +20,7 @@ type AggId = IAggConfig['id']; type AggParams = IAggConfig['params']; type AddNewAgg = ActionType; -type DiscardChanges = ActionType; +type DiscardChanges = ActionType; type ChangeAggType = ActionType< EditorStateActionTypes.CHANGE_AGG_TYPE, { aggId: AggId; value: IAggConfig['type'] } @@ -90,7 +90,7 @@ const addNewAgg: EditorActions['addNewAgg'] = (schema) => ({ const discardChanges: EditorActions['discardChanges'] = (vis) => ({ type: EditorStateActionTypes.DISCARD_CHANGES, - payload: vis, + payload: { vis }, }); const changeAggType: EditorActions['changeAggType'] = (aggId, value) => ({ diff --git a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index 8afac862ffb44..4eb26cfc6176f 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -48,7 +48,7 @@ const createEditorStateReducer = } case EditorStateActionTypes.DISCARD_CHANGES: { - return initEditorState(action.payload); + return initEditorState(action.payload.vis); } case EditorStateActionTypes.CHANGE_AGG_TYPE: { diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index cc7ff066f1274..fdfcf5bd7d7cc 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -62,7 +62,6 @@ function DefaultEditor({ if (!visRef.current) { return; } - embeddableHandler.render(visRef.current).then(() => { setTimeout(async () => { eventEmitter.emit('embeddableRendered'); diff --git a/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx index 1516e35a9c169..214a71616f427 100644 --- a/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx +++ b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx @@ -77,6 +77,7 @@ export const getGaugeVisTypeDefinition = ( }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: getGaugeOptions(props), schemas: [ { diff --git a/src/plugins/vis_types/gauge/public/vis_type/goal.tsx b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx index d0b4095a05130..0d356a5f2c721 100644 --- a/src/plugins/vis_types/gauge/public/vis_type/goal.tsx +++ b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx @@ -69,6 +69,7 @@ export const getGoalVisTypeDefinition = ( }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: getGaugeOptions(props), schemas: [ { diff --git a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx index c10f75129c47c..6e97a3c1fe79e 100644 --- a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx @@ -36,6 +36,7 @@ describe('PalettePicker', function () { vis: { type: { editorConfig: { + enableDataViewChange: true, collections: { legendPositions: [ { diff --git a/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts index a7e9f53e703ec..04eac5c64de29 100644 --- a/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts @@ -60,6 +60,7 @@ export const sampleAreaVis = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs: [ { name: 'advanced', diff --git a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx index a9c2d242a2335..4c62af96c9b40 100644 --- a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx @@ -63,6 +63,7 @@ export const getHeatmapVisTypeDefinition = ({ }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: getHeatmapOptions({ showElasticChartsOptions, palettes, diff --git a/src/plugins/vis_types/metric/public/metric_vis_type.ts b/src/plugins/vis_types/metric/public/metric_vis_type.ts index b4abdd180a6e8..7c141131fa8e5 100644 --- a/src/plugins/vis_types/metric/public/metric_vis_type.ts +++ b/src/plugins/vis_types/metric/public/metric_vis_type.ts @@ -48,6 +48,7 @@ export const createMetricVisTypeDefinition = (): VisTypeDefinition => }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: MetricVisOptions, schemas: [ { diff --git a/src/plugins/vis_types/pie/public/editor/components/pie.test.tsx b/src/plugins/vis_types/pie/public/editor/components/pie.test.tsx index c69bfe3409dcc..b57b59bb6caa8 100644 --- a/src/plugins/vis_types/pie/public/editor/components/pie.test.tsx +++ b/src/plugins/vis_types/pie/public/editor/components/pie.test.tsx @@ -25,6 +25,7 @@ describe('PalettePicker', function () { vis: { type: { editorConfig: { + enableDataViewChange: true, collections: { legendPositions: [ { diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index 4c638689ca310..e71bb7250dd1a 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -46,6 +46,7 @@ export const samplePieVis = { }, }, editorConfig: { + enableDataViewChange: true, collections: { legendPositions: [ { diff --git a/src/plugins/vis_types/pie/public/vis_type/pie.ts b/src/plugins/vis_types/pie/public/vis_type/pie.ts index d3b0cde93a741..b23f1b3ac4688 100644 --- a/src/plugins/vis_types/pie/public/vis_type/pie.ts +++ b/src/plugins/vis_types/pie/public/vis_type/pie.ts @@ -63,6 +63,7 @@ export const getPieVisTypeDefinition = ({ }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: getPieOptions({ showElasticChartsOptions, palettes, diff --git a/src/plugins/vis_types/table/public/table_vis_type.ts b/src/plugins/vis_types/table/public/table_vis_type.ts index 8662f45c02e7e..80c9d7bb9f00d 100644 --- a/src/plugins/vis_types/table/public/table_vis_type.ts +++ b/src/plugins/vis_types/table/public/table_vis_type.ts @@ -39,6 +39,7 @@ export const tableVisTypeDefinition: VisTypeDefinition = { }, }, editorConfig: { + enableDataViewChange: true, optionsTemplate: TableOptions, schemas: [ { diff --git a/src/plugins/vis_types/tagcloud/public/tag_cloud_type.ts b/src/plugins/vis_types/tagcloud/public/tag_cloud_type.ts index b29ffac33121c..c278c47dd9022 100644 --- a/src/plugins/vis_types/tagcloud/public/tag_cloud_type.ts +++ b/src/plugins/vis_types/tagcloud/public/tag_cloud_type.ts @@ -40,6 +40,7 @@ export const getTagCloudVisTypeDefinition = ({ palettes }: TagCloudVisDependenci }, toExpressionAst, editorConfig: { + enableDataViewChange: true, optionsTemplate: getTagCloudOptions({ palettes, }), diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts index 0d82ebf01e46d..0f6f403d16ac8 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts @@ -440,6 +440,7 @@ export const getVis = (bucketType: string) => { }, }, editorConfig: { + enableDataViewChange: true, collections: { legendPositions: [ { diff --git a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts index 3c1d87d2efc3c..d1e4af6b4b6c8 100644 --- a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts @@ -108,6 +108,7 @@ export const sampleAreaVis = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs: [ { name: 'advanced', diff --git a/src/plugins/vis_types/xy/public/vis_types/area.ts b/src/plugins/vis_types/xy/public/vis_types/area.ts index 8c83c8ac4dcd5..01668680ac24c 100644 --- a/src/plugins/vis_types/xy/public/vis_types/area.ts +++ b/src/plugins/vis_types/xy/public/vis_types/area.ts @@ -127,6 +127,7 @@ export const areaVisTypeDefinition = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs, schemas: [ { diff --git a/src/plugins/vis_types/xy/public/vis_types/histogram.ts b/src/plugins/vis_types/xy/public/vis_types/histogram.ts index d0a4307af198c..5405ac31eba42 100644 --- a/src/plugins/vis_types/xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_types/xy/public/vis_types/histogram.ts @@ -130,6 +130,7 @@ export const histogramVisTypeDefinition = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs, schemas: [ { diff --git a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts index 2e6708667ded2..aaf4ef2e2d51b 100644 --- a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts +++ b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts @@ -129,6 +129,7 @@ export const horizontalBarVisTypeDefinition = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs, schemas: [ { diff --git a/src/plugins/vis_types/xy/public/vis_types/line.ts b/src/plugins/vis_types/xy/public/vis_types/line.ts index 536a71eb6e540..bd528b6565ab2 100644 --- a/src/plugins/vis_types/xy/public/vis_types/line.ts +++ b/src/plugins/vis_types/xy/public/vis_types/line.ts @@ -127,6 +127,7 @@ export const lineVisTypeDefinition = { }, }, editorConfig: { + enableDataViewChange: true, optionTabs, schemas: [ { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index 8cff4ccdeb4e9..1ef9eb1153d9e 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -34,6 +34,7 @@ import { ExpressionAstExpression, } from '@kbn/expressions-plugin/public'; import type { RenderMode } from '@kbn/expressions-plugin'; +import VisualizationError from '../components/visualization_error'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import { Vis, SerializedVis } from '../vis'; import { getExecutionContext, getExpressions, getTheme, getUiActions } from '../services'; @@ -381,6 +382,12 @@ export class VisualizeEmbeddable this.subscriptions.push(this.handler.loading$.subscribe(this.onContainerLoading)); this.subscriptions.push(this.handler.render$.subscribe(this.onContainerRender)); + this.subscriptions.push( + this.getOutput$().subscribe( + ({ error }) => error && render(, this.domNode) + ) + ); + await this.updateHandler(); } diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 383a238621e1e..60853afe37e63 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -56,6 +56,7 @@ interface DefaultEditorConfig { [key: string]: Array<{ text: string; value: string }> | Array<{ id: string; label: string }>; }; enableAutoApply?: boolean; + enableDataViewChange?: boolean; defaultSize?: string; optionsTemplate?: DefaultEditorOptionsComponent; optionTabs?: Array<{ diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx index a85e8d0cd6561..b9ff8d98f2ced 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx @@ -18,6 +18,7 @@ import { useVisualizeAppState, useEditorUpdates, useLinkedSearchUpdates, + useDataViewUpdates, } from '../utils'; import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; @@ -84,6 +85,7 @@ export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { visEditorController ); useLinkedSearchUpdates(services, eventEmitter, appState, byValueVisInstance); + useDataViewUpdates(services, eventEmitter, appState, byValueVisInstance); useEffect(() => { // clean up all registered listeners if any is left diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx index fc505d1d74e00..480f0c3d36ee1 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx @@ -18,6 +18,7 @@ import { useVisualizeAppState, useEditorUpdates, useLinkedSearchUpdates, + useDataViewUpdates, } from '../utils'; import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; @@ -63,6 +64,7 @@ export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { visEditorController ); useLinkedSearchUpdates(services, eventEmitter, appState, savedVisInstance); + useDataViewUpdates(services, eventEmitter, appState, savedVisInstance); useEffect(() => { const { stateTransferService, data } = services; diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx index bc2d20611d3fc..a8d231bd61416 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; + import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers'; import { VisualizeEditorCommon } from './visualize_editor_common'; import { VisualizeEditorVisInstance } from '../types'; diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index e42ee1d0cd6c0..b634f84365627 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { +import type { VisualizeServices, VisualizeAppState, VisualizeAppStateContainer, @@ -151,9 +151,7 @@ const TopNav = ({ hideLensBadge, hideTryInLensBadge, ]); - const [indexPatterns, setIndexPatterns] = useState( - vis.data.indexPattern ? [vis.data.indexPattern] : [] - ); + const [indexPatterns, setIndexPatterns] = useState([]); const showDatePicker = () => { // tsvb loads without an indexPattern initially (TODO investigate). // hide timefilter only if timeFieldName is explicitly undefined. @@ -213,7 +211,9 @@ const TopNav = ({ const asyncSetIndexPattern = async () => { let indexes: DataView[] | undefined; - if (vis.type.getUsedIndexPattern) { + if (vis.data.indexPattern) { + indexes = [vis.data.indexPattern]; + } else if (vis.type.getUsedIndexPattern) { indexes = await vis.type.getUsedIndexPattern(vis.params); } if (!indexes || !indexes.length) { @@ -227,10 +227,28 @@ const TopNav = ({ } }; - if (!vis.data.indexPattern) { - asyncSetIndexPattern(); - } - }, [vis.params, vis.type, vis.data.indexPattern, services.dataViews]); + asyncSetIndexPattern(); + }, [services.dataViews, vis.data.indexPattern, vis.params, vis.type]); + + /** Synchronizing dataView with state **/ + useEffect(() => { + const stateContainerSubscription = stateContainer.state$.subscribe(async ({ dataView }) => { + if ( + dataView && + visInstance.vis.data.indexPattern && + dataView !== visInstance.vis.data.indexPattern.id + ) { + const dataViewFromState = await services.dataViews.get(dataView); + + if (dataViewFromState) { + setIndexPatterns([dataViewFromState]); + } + } + }); + return () => { + stateContainerSubscription.unsubscribe(); + }; + }, [services.dataViews, stateContainer.state$, visInstance.vis.data.indexPattern]); useEffect(() => { const autoRefreshFetchSub = services.data.query.timefilter.timefilter @@ -247,6 +265,22 @@ const TopNav = ({ }; }, [services.data.query.timefilter.timefilter, doReload]); + const shouldShowDataViewPicker = Boolean( + vis.type.editorConfig?.enableDataViewChange && + !vis.data.savedSearchId && + vis.data.indexPattern && + indexPatterns.length + ); + + const onChangeDataView = useCallback( + async (selectedDataViewId: string) => { + if (selectedDataViewId) { + stateContainer.transitions.updateDataView(selectedDataViewId); + } + }, + [stateContainer.transitions] + ); + return isChromeVisible ? ( /** * Most visualizations have all search bar components enabled. @@ -255,6 +289,7 @@ const TopNav = ({ * All visualizations also have the timepicker\autorefresh component, * it is enabled by default in the TopNavMenu component. */ + diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index 8eb57495d95ab..caa39d8bf9308 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -51,6 +51,7 @@ import type { createVisEmbeddableFromObject } from '../embeddable'; import type { VisEditorsRegistry } from '../vis_editors_registry'; export interface VisualizeAppState { + dataView?: string; filters: Filter[]; uiState: SerializableRecord; vis: SavedVisState; @@ -72,6 +73,7 @@ export interface VisualizeAppStateTransitions { ) => ({ query, parentFilters }: { query?: Query; parentFilters?: Filter[] }) => VisualizeAppState; updateVisState: (state: VisualizeAppState) => (vis: SavedVisState) => VisualizeAppState; updateSavedQuery: (state: VisualizeAppState) => (savedQueryId?: string) => VisualizeAppState; + updateDataView: (state: VisualizeAppState) => (dataViewId?: string) => VisualizeAppState; } export type VisualizeAppStateContainer = ReduxLikeStateContainer< diff --git a/src/plugins/visualizations/public/visualize_app/utils/create_visualize_app_state.ts b/src/plugins/visualizations/public/visualize_app/utils/create_visualize_app_state.ts index ab3e631663f40..e9ecfc6fd7af0 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/create_visualize_app_state.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/create_visualize_app_state.ts @@ -48,6 +48,7 @@ const pureTransitions = { filters: union(state.filters, parentFilters), linked: false, }), + updateDataView: (state) => (dataViewId) => ({ ...state, dataView: dataViewId }), updateVisState: (state) => (newVisState) => ({ ...state, vis: toObject(newVisState) }), updateSavedQuery: (state) => (savedQueryId) => { const updatedState = { diff --git a/src/plugins/visualizations/public/visualize_app/utils/use/index.ts b/src/plugins/visualizations/public/visualize_app/utils/use/index.ts index ca2bb3717ff15..6663f2a98958b 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/use/index.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/use/index.ts @@ -12,3 +12,4 @@ export { useSavedVisInstance } from './use_saved_vis_instance'; export { useVisualizeAppState } from './use_visualize_app_state'; export { useLinkedSearchUpdates } from './use_linked_search_updates'; export { useVisByValue } from './use_vis_byvalue'; +export { useDataViewUpdates } from './use_data_view_updates'; diff --git a/src/plugins/visualizations/public/visualize_app/utils/use/use_data_view_updates.ts b/src/plugins/visualizations/public/visualize_app/utils/use/use_data_view_updates.ts new file mode 100644 index 0000000000000..b7471457a8d7f --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/utils/use/use_data_view_updates.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEffect } from 'react'; +import type { Subscription } from 'rxjs'; +import type { EventEmitter } from 'events'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { + VisualizeServices, + VisualizeAppStateContainer, + VisualizeEditorVisInstance, +} from '../../types'; +import { VisualizeAppState } from '../../types'; + +export const updateDataView = (visInstance: VisualizeEditorVisInstance, dataView: DataView) => { + visInstance.vis.data.indexPattern = dataView; + visInstance.vis.data.searchSource?.setField('index', dataView); +}; + +export const useDataViewUpdates = ( + services: VisualizeServices, + eventEmitter: EventEmitter, + appState: VisualizeAppStateContainer | null, + visInstance: VisualizeEditorVisInstance | undefined +) => { + useEffect(() => { + let stateUpdatesSubscription: Subscription; + + if (appState && visInstance) { + const syncDataView = async ({ dataView }: VisualizeAppState) => { + if ( + dataView && + visInstance.vis.data.indexPattern && + dataView !== visInstance.vis.data.indexPattern.id + ) { + const selectedDataView = await services.dataViews.get(dataView); + if (selectedDataView) { + updateDataView(visInstance, selectedDataView); + visInstance.embeddableHandler.reload(); + eventEmitter.emit('updateEditor'); + } + } + }; + + syncDataView(appState.getState()); + stateUpdatesSubscription = appState.state$.subscribe(syncDataView); + } + return () => { + stateUpdatesSubscription?.unsubscribe(); + }; + }, [appState, eventEmitter, services.dataViews, visInstance]); +}; diff --git a/src/plugins/visualizations/public/visualize_app/utils/use/use_visualize_app_state.tsx b/src/plugins/visualizations/public/visualize_app/utils/use/use_visualize_app_state.tsx index 70f3413da0419..f56afa234d65e 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/use/use_visualize_app_state.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/use/use_visualize_app_state.tsx @@ -100,6 +100,7 @@ export const useVisualizeAppState = ( const { aggs, ...visState } = currentAppState.vis; const query = currentAppState.query; const filter = currentAppState.filters; + const visSearchSource = instance.vis.data.searchSource?.getSerializedFields() || {}; instance.vis .setState({ diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 5a3e881b86471..8ce28d38e3b56 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -15,6 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); const TEST_COLUMN_NAMES = ['dayOfWeek', 'DestWeather']; + const toasts = getService('toasts'); + const browser = getService('browser'); describe('Discover a11y tests', () => { before(async () => { @@ -100,5 +102,92 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await savedQueryManagementComponent.deleteSavedQuery('test'); await a11y.testAppSnapshot(); }); + + // adding a11y tests for the new data grid + it('a11y test on single document view', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + await PageObjects.discover.clickDocViewerTab(0); + await a11y.testAppSnapshot(); + }); + + it('a11y test on JSON view of the document', async () => { + await PageObjects.discover.clickDocViewerTab(1); + await a11y.testAppSnapshot(); + }); + + it('a11y test for actions on a field', async () => { + await PageObjects.discover.clickDocViewerTab(0); + await testSubjects.click('openFieldActionsButton-Cancelled'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data-grid table with columns', async () => { + await testSubjects.click('toggleColumnButton-Cancelled'); + await testSubjects.click('openFieldActionsButton-Carrier'); + await testSubjects.click('toggleColumnButton-Carrier'); + await testSubjects.click('euiFlyoutCloseButton'); + await toasts.dismissAllToasts(); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data-grid actions on columns', async () => { + await testSubjects.click('dataGridHeaderCellActionButton-Carrier'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for chart options panel', async () => { + await testSubjects.click('discoverChartOptionsToggle'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data grid with hidden chart', async () => { + await testSubjects.click('discoverChartToggle'); + await a11y.testAppSnapshot(); + await testSubjects.click('discoverChartOptionsToggle'); + await testSubjects.click('discoverChartToggle'); + }); + + it('a11y test for time interval panel', async () => { + await testSubjects.click('discoverChartOptionsToggle'); + await testSubjects.click('discoverTimeIntervalPanel'); + await a11y.testAppSnapshot(); + await testSubjects.click('contextMenuPanelTitleButton'); + await testSubjects.click('discoverChartOptionsToggle'); + }); + + // https://github.com/elastic/eui/issues/5900 + it.skip('a11y test for data grid sort panel', async () => { + await testSubjects.click('dataGridColumnSortingButton'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for setting row height for display panel', async () => { + await testSubjects.click('dataGridDisplaySelectorPopover'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for data grid in full screen', async () => { + await testSubjects.click('dataGridFullScreenButton'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for field statistics data grid view', async () => { + await PageObjects.discover.clickViewModeFieldStatsButton(); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data grid with collapsed side bar', async () => { + await PageObjects.discover.closeSidebar(); + await a11y.testAppSnapshot(); + await PageObjects.discover.toggleSidebarCollapse(); + }); + + it('a11y test for adding a field from side bar', async () => { + await testSubjects.click('indexPattern-add-field_btn'); + await a11y.testAppSnapshot(); + }); }); } diff --git a/test/functional/page_objects/login_page.ts b/test/functional/page_objects/login_page.ts index 74e85e60d1a69..51e6c4f7063e6 100644 --- a/test/functional/page_objects/login_page.ts +++ b/test/functional/page_objects/login_page.ts @@ -45,6 +45,9 @@ export class LoginPageObject extends FtrService { } private async regularLogin(user: string, pwd: string) { + if (await this.testSubjects.exists('loginCard-basic/cloud-basic')) { + await this.testSubjects.click('loginCard-basic/cloud-basic'); + } await this.testSubjects.setValue('loginUsername', user); await this.testSubjects.setValue('loginPassword', pwd); await this.testSubjects.click('loginSubmit'); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 1416221fc2fe5..b212d4bee63ab 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -203,6 +203,12 @@ exports[`Error SPAN_DURATION 1`] = `undefined`; exports[`Error SPAN_ID 1`] = `undefined`; +exports[`Error SPAN_LINKS 1`] = `undefined`; + +exports[`Error SPAN_LINKS_SPAN_ID 1`] = `undefined`; + +exports[`Error SPAN_LINKS_TRACE_ID 1`] = `undefined`; + exports[`Error SPAN_NAME 1`] = `undefined`; exports[`Error SPAN_SELF_TIME_SUM 1`] = `undefined`; @@ -446,6 +452,12 @@ exports[`Span SPAN_DURATION 1`] = `1337`; exports[`Span SPAN_ID 1`] = `"span id"`; +exports[`Span SPAN_LINKS 1`] = `undefined`; + +exports[`Span SPAN_LINKS_SPAN_ID 1`] = `undefined`; + +exports[`Span SPAN_LINKS_TRACE_ID 1`] = `undefined`; + exports[`Span SPAN_NAME 1`] = `"span name"`; exports[`Span SPAN_SELF_TIME_SUM 1`] = `undefined`; @@ -703,6 +715,12 @@ exports[`Transaction SPAN_DURATION 1`] = `undefined`; exports[`Transaction SPAN_ID 1`] = `undefined`; +exports[`Transaction SPAN_LINKS 1`] = `undefined`; + +exports[`Transaction SPAN_LINKS_SPAN_ID 1`] = `undefined`; + +exports[`Transaction SPAN_LINKS_TRACE_ID 1`] = `undefined`; + exports[`Transaction SPAN_NAME 1`] = `undefined`; exports[`Transaction SPAN_SELF_TIME_SUM 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index aaf864b3bf75b..e4970ea2ac067 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -74,6 +74,10 @@ export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT = export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM = 'span.destination.service.response_time.sum.us'; +export const SPAN_LINKS = 'span.links'; +export const SPAN_LINKS_TRACE_ID = 'span.links.trace.id'; +export const SPAN_LINKS_SPAN_ID = 'span.links.span.id'; + // Parent ID for a transaction or span export const PARENT_ID = 'parent.id'; diff --git a/x-pack/plugins/apm/common/span_links.ts b/x-pack/plugins/apm/common/span_links.ts new file mode 100644 index 0000000000000..cd5ce48e6802a --- /dev/null +++ b/x-pack/plugins/apm/common/span_links.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; +import { Environment } from './environment_rt'; + +export interface SpanLinkDetails { + traceId: string; + spanId: string; + details?: { + agentName: AgentName; + serviceName: string; + duration: number; + environment: Environment; + transactionId?: string; + spanName?: string; + spanSubtype?: string; + spanType?: string; + }; +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0_empty/mappings.json b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0_empty/mappings.json index 2d05717fa5725..8e9d447af8966 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0_empty/mappings.json +++ b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0_empty/mappings.json @@ -1,1139 +1,3 @@ -{ - "type": "index", - "value": { - "aliases": { - ".ml-anomalies-.write-apm-environment_not_defined-337d-high_mean_transaction_duration": { - "is_hidden": true - }, - ".ml-anomalies-.write-apm-production-6117-high_mean_transaction_duration": { - "is_hidden": true - }, - ".ml-anomalies-.write-apm-testing-41e5-high_mean_transaction_duration": { - "is_hidden": true - }, - ".ml-anomalies-apm-environment_not_defined-337d-high_mean_transaction_duration": { - "filter": { - "term": { - "job_id": { - "boost": 1, - "value": "apm-environment_not_defined-337d-high_mean_transaction_duration" - } - } - }, - "is_hidden": true - }, - ".ml-anomalies-apm-production-6117-high_mean_transaction_duration": { - "filter": { - "term": { - "job_id": { - "boost": 1, - "value": "apm-production-6117-high_mean_transaction_duration" - } - } - }, - "is_hidden": true - }, - ".ml-anomalies-apm-testing-41e5-high_mean_transaction_duration": { - "filter": { - "term": { - "job_id": { - "boost": 1, - "value": "apm-testing-41e5-high_mean_transaction_duration" - } - } - }, - "is_hidden": true - } - }, - "index": ".ml-anomalies-shared", - "mappings": { - "_meta": { - "version": "7.14.0" - }, - "dynamic_templates": [ - { - "strings_as_keywords": { - "mapping": { - "type": "keyword" - }, - "match": "*" - } - } - ], - "properties": { - "actual": { - "type": "double" - }, - "all_field_values": { - "analyzer": "whitespace", - "type": "text" - }, - "anomaly_score": { - "type": "double" - }, - "assignment_memory_basis": { - "type": "keyword" - }, - "average_bucket_processing_time_ms": { - "type": "double" - }, - "bucket_allocation_failures_count": { - "type": "long" - }, - "bucket_count": { - "type": "long" - }, - "bucket_influencers": { - "properties": { - "anomaly_score": { - "type": "double" - }, - "bucket_span": { - "type": "long" - }, - "influencer_field_name": { - "type": "keyword" - }, - "initial_anomaly_score": { - "type": "double" - }, - "is_interim": { - "type": "boolean" - }, - "job_id": { - "type": "keyword" - }, - "probability": { - "type": "double" - }, - "raw_anomaly_score": { - "type": "double" - }, - "result_type": { - "type": "keyword" - }, - "timestamp": { - "type": "date" - } - }, - "type": "nested" - }, - "bucket_span": { - "type": "long" - }, - "by_field_name": { - "type": "keyword" - }, - "by_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "categorization_status": { - "type": "keyword" - }, - "categorized_doc_count": { - "type": "keyword" - }, - "category_id": { - "type": "long" - }, - "causes": { - "properties": { - "actual": { - "type": "double" - }, - "by_field_name": { - "type": "keyword" - }, - "by_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "correlated_by_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "field_name": { - "type": "keyword" - }, - "function": { - "type": "keyword" - }, - "function_description": { - "type": "keyword" - }, - "geo_results": { - "properties": { - "actual_point": { - "type": "geo_point" - }, - "typical_point": { - "type": "geo_point" - } - } - }, - "over_field_name": { - "type": "keyword" - }, - "over_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "partition_field_name": { - "type": "keyword" - }, - "partition_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "probability": { - "type": "double" - }, - "typical": { - "type": "double" - } - }, - "type": "nested" - }, - "dead_category_count": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "detector_index": { - "type": "integer" - }, - "earliest_record_timestamp": { - "type": "date" - }, - "empty_bucket_count": { - "type": "long" - }, - "event_count": { - "type": "long" - }, - "examples": { - "type": "text" - }, - "exponential_average_bucket_processing_time_ms": { - "type": "double" - }, - "exponential_average_calculation_context": { - "properties": { - "incremental_metric_value_ms": { - "type": "double" - }, - "latest_timestamp": { - "type": "date" - }, - "previous_exponential_average_ms": { - "type": "double" - } - } - }, - "failed_category_count": { - "type": "keyword" - }, - "field_name": { - "type": "keyword" - }, - "forecast_create_timestamp": { - "type": "date" - }, - "forecast_end_timestamp": { - "type": "date" - }, - "forecast_expiry_timestamp": { - "type": "date" - }, - "forecast_id": { - "type": "keyword" - }, - "forecast_lower": { - "type": "double" - }, - "forecast_memory_bytes": { - "type": "long" - }, - "forecast_messages": { - "type": "keyword" - }, - "forecast_prediction": { - "type": "double" - }, - "forecast_progress": { - "type": "double" - }, - "forecast_start_timestamp": { - "type": "date" - }, - "forecast_status": { - "type": "keyword" - }, - "forecast_upper": { - "type": "double" - }, - "frequent_category_count": { - "type": "keyword" - }, - "function": { - "type": "keyword" - }, - "function_description": { - "type": "keyword" - }, - "geo_results": { - "properties": { - "actual_point": { - "type": "geo_point" - }, - "typical_point": { - "type": "geo_point" - } - } - }, - "influencer_field_name": { - "type": "keyword" - }, - "influencer_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "influencer_score": { - "type": "double" - }, - "influencers": { - "properties": { - "influencer_field_name": { - "type": "keyword" - }, - "influencer_field_values": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - } - }, - "type": "nested" - }, - "initial_anomaly_score": { - "type": "double" - }, - "initial_influencer_score": { - "type": "double" - }, - "initial_record_score": { - "type": "double" - }, - "input_bytes": { - "type": "long" - }, - "input_field_count": { - "type": "long" - }, - "input_record_count": { - "type": "long" - }, - "invalid_date_count": { - "type": "long" - }, - "is_interim": { - "type": "boolean" - }, - "job_id": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "last_data_time": { - "type": "date" - }, - "latest_empty_bucket_timestamp": { - "type": "date" - }, - "latest_record_time_stamp": { - "type": "date" - }, - "latest_record_timestamp": { - "type": "date" - }, - "latest_result_time_stamp": { - "type": "date" - }, - "latest_sparse_bucket_timestamp": { - "type": "date" - }, - "log_time": { - "type": "date" - }, - "max_matching_length": { - "type": "long" - }, - "maximum_bucket_processing_time_ms": { - "type": "double" - }, - "memory_status": { - "type": "keyword" - }, - "min_version": { - "type": "keyword" - }, - "minimum_bucket_processing_time_ms": { - "type": "double" - }, - "missing_field_count": { - "type": "long" - }, - "mlcategory": { - "type": "keyword" - }, - "model_bytes": { - "type": "long" - }, - "model_bytes_exceeded": { - "type": "keyword" - }, - "model_bytes_memory_limit": { - "type": "keyword" - }, - "model_feature": { - "type": "keyword" - }, - "model_lower": { - "type": "double" - }, - "model_median": { - "type": "double" - }, - "model_size_stats": { - "properties": { - "assignment_memory_basis": { - "type": "keyword" - }, - "bucket_allocation_failures_count": { - "type": "long" - }, - "categorization_status": { - "type": "keyword" - }, - "categorized_doc_count": { - "type": "keyword" - }, - "dead_category_count": { - "type": "keyword" - }, - "failed_category_count": { - "type": "keyword" - }, - "frequent_category_count": { - "type": "keyword" - }, - "job_id": { - "type": "keyword" - }, - "log_time": { - "type": "date" - }, - "memory_status": { - "type": "keyword" - }, - "model_bytes": { - "type": "long" - }, - "model_bytes_exceeded": { - "type": "keyword" - }, - "model_bytes_memory_limit": { - "type": "keyword" - }, - "peak_model_bytes": { - "type": "long" - }, - "rare_category_count": { - "type": "keyword" - }, - "result_type": { - "type": "keyword" - }, - "timestamp": { - "type": "date" - }, - "total_by_field_count": { - "type": "long" - }, - "total_category_count": { - "type": "keyword" - }, - "total_over_field_count": { - "type": "long" - }, - "total_partition_field_count": { - "type": "long" - } - } - }, - "model_upper": { - "type": "double" - }, - "multi_bucket_impact": { - "type": "double" - }, - "num_matches": { - "type": "long" - }, - "out_of_order_timestamp_count": { - "type": "long" - }, - "over_field_name": { - "type": "keyword" - }, - "over_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "partition_field_name": { - "type": "keyword" - }, - "partition_field_value": { - "copy_to": [ - "all_field_values" - ], - "type": "keyword" - }, - "peak_model_bytes": { - "type": "keyword" - }, - "preferred_to_categories": { - "type": "long" - }, - "probability": { - "type": "double" - }, - "processed_field_count": { - "type": "long" - }, - "processed_record_count": { - "type": "long" - }, - "processing_time_ms": { - "type": "long" - }, - "quantiles": { - "enabled": false, - "type": "object" - }, - "rare_category_count": { - "type": "keyword" - }, - "raw_anomaly_score": { - "type": "double" - }, - "record_score": { - "type": "double" - }, - "regex": { - "type": "keyword" - }, - "result_type": { - "type": "keyword" - }, - "retain": { - "type": "boolean" - }, - "scheduled_events": { - "type": "keyword" - }, - "search_count": { - "type": "long" - }, - "service": { - "properties": { - "name": { - "type": "keyword" - } - } - }, - "snapshot_doc_count": { - "type": "integer" - }, - "snapshot_id": { - "type": "keyword" - }, - "sparse_bucket_count": { - "type": "long" - }, - "terms": { - "type": "text" - }, - "timestamp": { - "type": "date" - }, - "total_by_field_count": { - "type": "long" - }, - "total_category_count": { - "type": "keyword" - }, - "total_over_field_count": { - "type": "long" - }, - "total_partition_field_count": { - "type": "long" - }, - "total_search_time_ms": { - "type": "double" - }, - "transaction": { - "properties": { - "type": { - "type": "keyword" - } - } - }, - "typical": { - "type": "double" - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "hidden": "true", - "number_of_replicas": "1", - "number_of_shards": "1", - "translog": { - "durability": "async" - } - } - } - } -} - -{ - "type": "index", - "value": { - "aliases": { - }, - "index": ".ml-config", - "mappings": { - "_meta": { - "version": "7.14.0" - }, - "dynamic_templates": [ - { - "strings_as_keywords": { - "mapping": { - "type": "keyword" - }, - "match": "*" - } - } - ], - "properties": { - "aggregations": { - "enabled": false, - "type": "object" - }, - "allow_lazy_open": { - "type": "keyword" - }, - "allow_lazy_start": { - "type": "keyword" - }, - "analysis": { - "properties": { - "classification": { - "properties": { - "alpha": { - "type": "double" - }, - "class_assignment_objective": { - "type": "keyword" - }, - "dependent_variable": { - "type": "keyword" - }, - "downsample_factor": { - "type": "double" - }, - "early_stopping_enabled": { - "type": "boolean" - }, - "eta": { - "type": "double" - }, - "eta_growth_rate_per_tree": { - "type": "double" - }, - "feature_bag_fraction": { - "type": "double" - }, - "feature_processors": { - "enabled": false, - "type": "object" - }, - "gamma": { - "type": "double" - }, - "lambda": { - "type": "double" - }, - "max_optimization_rounds_per_hyperparameter": { - "type": "integer" - }, - "max_trees": { - "type": "integer" - }, - "num_top_classes": { - "type": "integer" - }, - "num_top_feature_importance_values": { - "type": "integer" - }, - "prediction_field_name": { - "type": "keyword" - }, - "randomize_seed": { - "type": "keyword" - }, - "soft_tree_depth_limit": { - "type": "double" - }, - "soft_tree_depth_tolerance": { - "type": "double" - }, - "training_percent": { - "type": "double" - } - } - }, - "outlier_detection": { - "properties": { - "compute_feature_influence": { - "type": "keyword" - }, - "feature_influence_threshold": { - "type": "double" - }, - "method": { - "type": "keyword" - }, - "n_neighbors": { - "type": "integer" - }, - "outlier_fraction": { - "type": "keyword" - }, - "standardization_enabled": { - "type": "keyword" - } - } - }, - "regression": { - "properties": { - "alpha": { - "type": "double" - }, - "dependent_variable": { - "type": "keyword" - }, - "downsample_factor": { - "type": "double" - }, - "early_stopping_enabled": { - "type": "boolean" - }, - "eta": { - "type": "double" - }, - "eta_growth_rate_per_tree": { - "type": "double" - }, - "feature_bag_fraction": { - "type": "double" - }, - "feature_processors": { - "enabled": false, - "type": "object" - }, - "gamma": { - "type": "double" - }, - "lambda": { - "type": "double" - }, - "loss_function": { - "type": "keyword" - }, - "loss_function_parameter": { - "type": "double" - }, - "max_optimization_rounds_per_hyperparameter": { - "type": "integer" - }, - "max_trees": { - "type": "integer" - }, - "num_top_feature_importance_values": { - "type": "integer" - }, - "prediction_field_name": { - "type": "keyword" - }, - "randomize_seed": { - "type": "keyword" - }, - "soft_tree_depth_limit": { - "type": "double" - }, - "soft_tree_depth_tolerance": { - "type": "double" - }, - "training_percent": { - "type": "double" - } - } - } - } - }, - "analysis_config": { - "properties": { - "bucket_span": { - "type": "keyword" - }, - "categorization_analyzer": { - "enabled": false, - "type": "object" - }, - "categorization_field_name": { - "type": "keyword" - }, - "categorization_filters": { - "type": "keyword" - }, - "detectors": { - "properties": { - "by_field_name": { - "type": "keyword" - }, - "custom_rules": { - "properties": { - "actions": { - "type": "keyword" - }, - "conditions": { - "properties": { - "applies_to": { - "type": "keyword" - }, - "operator": { - "type": "keyword" - }, - "value": { - "type": "double" - } - }, - "type": "nested" - }, - "scope": { - "enabled": false, - "type": "object" - } - }, - "type": "nested" - }, - "detector_description": { - "type": "text" - }, - "detector_index": { - "type": "integer" - }, - "exclude_frequent": { - "type": "keyword" - }, - "field_name": { - "type": "keyword" - }, - "function": { - "type": "keyword" - }, - "over_field_name": { - "type": "keyword" - }, - "partition_field_name": { - "type": "keyword" - }, - "use_null": { - "type": "boolean" - } - } - }, - "influencers": { - "type": "keyword" - }, - "latency": { - "type": "keyword" - }, - "multivariate_by_fields": { - "type": "boolean" - }, - "per_partition_categorization": { - "properties": { - "enabled": { - "type": "boolean" - }, - "stop_on_warn": { - "type": "boolean" - } - } - }, - "summary_count_field_name": { - "type": "keyword" - } - } - }, - "analysis_limits": { - "properties": { - "categorization_examples_limit": { - "type": "long" - }, - "model_memory_limit": { - "type": "keyword" - } - } - }, - "analyzed_fields": { - "enabled": false, - "type": "object" - }, - "background_persist_interval": { - "type": "keyword" - }, - "blocked": { - "properties": { - "reason": { - "type": "keyword" - }, - "task_id": { - "type": "keyword" - } - } - }, - "chunking_config": { - "properties": { - "mode": { - "type": "keyword" - }, - "time_span": { - "type": "keyword" - } - } - }, - "config_type": { - "type": "keyword" - }, - "create_time": { - "type": "date" - }, - "custom_settings": { - "enabled": false, - "type": "object" - }, - "daily_model_snapshot_retention_after_days": { - "type": "long" - }, - "data_description": { - "properties": { - "field_delimiter": { - "type": "keyword" - }, - "format": { - "type": "keyword" - }, - "quote_character": { - "type": "keyword" - }, - "time_field": { - "type": "keyword" - }, - "time_format": { - "type": "keyword" - } - } - }, - "datafeed_id": { - "type": "keyword" - }, - "delayed_data_check_config": { - "properties": { - "check_window": { - "type": "keyword" - }, - "enabled": { - "type": "boolean" - } - } - }, - "deleting": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "dest": { - "properties": { - "index": { - "type": "keyword" - }, - "results_field": { - "type": "keyword" - } - } - }, - "finished_time": { - "type": "date" - }, - "frequency": { - "type": "keyword" - }, - "groups": { - "type": "keyword" - }, - "headers": { - "enabled": false, - "type": "object" - }, - "id": { - "type": "keyword" - }, - "indices": { - "type": "keyword" - }, - "indices_options": { - "enabled": false, - "type": "object" - }, - "job_id": { - "type": "keyword" - }, - "job_type": { - "type": "keyword" - }, - "job_version": { - "type": "keyword" - }, - "max_empty_searches": { - "type": "keyword" - }, - "max_num_threads": { - "type": "integer" - }, - "model_memory_limit": { - "type": "keyword" - }, - "model_plot_config": { - "properties": { - "annotations_enabled": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "terms": { - "type": "keyword" - } - } - }, - "model_snapshot_id": { - "type": "keyword" - }, - "model_snapshot_min_version": { - "type": "keyword" - }, - "model_snapshot_retention_days": { - "type": "long" - }, - "query": { - "enabled": false, - "type": "object" - }, - "query_delay": { - "type": "keyword" - }, - "renormalization_window_days": { - "type": "long" - }, - "results_index_name": { - "type": "keyword" - }, - "results_retention_days": { - "type": "long" - }, - "runtime_mappings": { - "enabled": false, - "type": "object" - }, - "script_fields": { - "enabled": false, - "type": "object" - }, - "scroll_size": { - "type": "long" - }, - "source": { - "properties": { - "_source": { - "enabled": false, - "type": "object" - }, - "index": { - "type": "keyword" - }, - "query": { - "enabled": false, - "type": "object" - }, - "runtime_mappings": { - "enabled": false, - "type": "object" - } - } - }, - "version": { - "type": "keyword" - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "blocks": { - "read_only_allow_delete": "false" - }, - "max_result_window": "10000", - "number_of_replicas": "1", - "number_of_shards": "1" - } - } - } -} - { "type": "index", "value": { @@ -16155,6 +15019,30 @@ } } }, + "links": { + "dynamic": "false", + "type": "nested", + "properties": { + "trace": { + "dynamic": "false", + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "span": { + "dynamic": "false", + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, "transaction": { "dynamic": "false", "properties": { diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json index 2d05717fa5725..3167ad3f5a6a0 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json +++ b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json @@ -628,8 +628,7 @@ { "type": "index", "value": { - "aliases": { - }, + "aliases": {}, "index": ".ml-config", "mappings": { "_meta": { @@ -15510,6 +15509,26 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "links": { + "properties": { + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } } } }, @@ -20620,6 +20639,26 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "links": { + "properties": { + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } } } }, diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/generate_span_links_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/generate_span_links_data.ts new file mode 100644 index 0000000000000..3905cf324c44a --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/generate_span_links_data.ts @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, EntityArrayIterable, timerange } from '@elastic/apm-synthtrace'; +import { synthtrace } from '../../../../synthtrace'; +import { SpanLink } from '../../../../../typings/es_schemas/raw/fields/span_links'; + +function getProducerInternalOnly() { + const producerInternalOnlyInstance = apm + .service('producer-internal-only', 'production', 'go') + .instance('instance a'); + + const events = timerange( + new Date('2022-01-01T00:00:00.000Z'), + new Date('2022-01-01T00:01:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerInternalOnlyInstance + .transaction(`Transaction A`) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerInternalOnlyInstance + .span(`Span A`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionA = apmFields.find( + (item) => item['processor.event'] === 'transaction' + ); + const spanA = apmFields.find((item) => item['processor.event'] === 'span'); + + const ids = + spanA && transactionA + ? { + transactionAId: transactionA['transaction.id']!, + traceId: spanA['trace.id']!, + spanAId: spanA['span.id']!, + } + : {}; + const spanASpanLink = spanA + ? { trace: { id: spanA['trace.id']! }, span: { id: spanA['span.id']! } } + : undefined; + + return { + ids, + spanASpanLink, + apmFields, + }; +} + +function getProducerExternalOnly() { + const producerExternalOnlyInstance = apm + .service('producer-external-only', 'production', 'java') + .instance('instance b'); + + const events = timerange( + new Date('2022-01-01T00:02:00.000Z'), + new Date('2022-01-01T00:03:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerExternalOnlyInstance + .transaction(`Transaction B`) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerExternalOnlyInstance + .span(`Span B`, 'external', 'http') + .defaults({ + 'span.links': [ + { trace: { id: 'trace#1' }, span: { id: 'span#1' } }, + ], + }) + .timestamp(timestamp + 50) + .duration(100) + .success(), + producerExternalOnlyInstance + .span(`Span B.1`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionB = apmFields.find( + (item) => item['processor.event'] === 'transaction' + ); + const spanB = apmFields.find( + (item) => + item['processor.event'] === 'span' && item['span.name'] === 'Span B' + ); + const ids = + spanB && transactionB + ? { + traceId: spanB['trace.id']!, + transactionBId: transactionB['transaction.id']!, + spanBId: spanB['span.id']!, + } + : {}; + + const spanBSpanLink = spanB + ? { + trace: { id: spanB['trace.id']! }, + span: { id: spanB['span.id']! }, + } + : undefined; + + return { + ids, + spanBSpanLink, + apmFields, + }; +} + +function getProducerConsumer({ + producerInternalOnlySpanASpanLink, +}: { + producerInternalOnlySpanASpanLink?: SpanLink; +}) { + const producerConsumerInstance = apm + .service('producer-consumer', 'production', 'ruby') + .instance('instance c'); + + const events = timerange( + new Date('2022-01-01T00:04:00.000Z'), + new Date('2022-01-01T00:05:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerConsumerInstance + .transaction(`Transaction C`) + .defaults({ + 'span.links': producerInternalOnlySpanASpanLink + ? [producerInternalOnlySpanASpanLink] + : [], + }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerConsumerInstance + .span(`Span C`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionC = apmFields.find( + (item) => item['processor.event'] === 'transaction' + ); + const transactionCSpanLink = transactionC + ? { + trace: { id: transactionC['trace.id']! }, + span: { id: transactionC['transaction.id']! }, + } + : undefined; + const spanC = apmFields.find( + (item) => + item['processor.event'] === 'span' || item['span.name'] === 'Span C' + ); + const spanCSpanLink = spanC + ? { + trace: { id: spanC['trace.id']! }, + span: { id: spanC['span.id']! }, + } + : undefined; + const ids = + spanC && transactionC + ? { + traceId: transactionC['trace.id']!, + transactionCId: transactionC['transaction.id']!, + spanCId: spanC['span.id']!, + } + : {}; + return { + transactionCSpanLink, + spanCSpanLink, + ids, + apmFields, + }; +} + +function getConsumerMultiple({ + producerInternalOnlySpanASpanLink, + producerExternalOnlySpanBSpanLink, + producerConsumerSpanCSpanLink, + producerConsumerTransactionCSpanLink, +}: { + producerInternalOnlySpanASpanLink?: SpanLink; + producerExternalOnlySpanBSpanLink?: SpanLink; + producerConsumerSpanCSpanLink?: SpanLink; + producerConsumerTransactionCSpanLink?: SpanLink; +}) { + const consumerMultipleInstance = apm + .service('consumer-multiple', 'production', 'nodejs') + .instance('instance d'); + + const events = timerange( + new Date('2022-01-01T00:06:00.000Z'), + new Date('2022-01-01T00:07:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return consumerMultipleInstance + .transaction(`Transaction D`) + .defaults({ + 'span.links': + producerInternalOnlySpanASpanLink && producerConsumerSpanCSpanLink + ? [ + producerInternalOnlySpanASpanLink, + producerConsumerSpanCSpanLink, + ] + : [], + }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + consumerMultipleInstance + .span(`Span E`, 'external', 'http') + .defaults({ + 'span.links': + producerExternalOnlySpanBSpanLink && + producerConsumerTransactionCSpanLink + ? [ + producerExternalOnlySpanBSpanLink, + producerConsumerTransactionCSpanLink, + ] + : [], + }) + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + const apmFields = events.toArray(); + const transactionD = apmFields.find( + (item) => item['processor.event'] === 'transaction' + ); + const spanE = apmFields.find((item) => item['processor.event'] === 'span'); + + const ids = + transactionD && spanE + ? { + traceId: transactionD['trace.id']!, + transactionDId: transactionD['transaction.id']!, + spanEId: spanE['span.id']!, + } + : {}; + + return { + ids, + apmFields, + }; +} + +/** + * Data ingestion summary: + * + * producer-internal-only (go) + * --Transaction A + * ----Span A + * + * producer-external-only (java) + * --Transaction B + * ----Span B + * ------span.links=external link + * ----Span B1 + * + * producer-consumer (ruby) + * --Transaction C + * ------span.links=producer-internal-only / Span A + * ----Span C + * + * consumer-multiple (nodejs) + * --Transaction D + * ------span.links= producer-consumer / Span C | producer-internal-only / Span A + * ----Span E + * ------span.links= producer-external-only / Span B | producer-consumer / Transaction C + */ +export async function generateSpanLinksData() { + const producerInternalOnly = getProducerInternalOnly(); + const producerExternalOnly = getProducerExternalOnly(); + const producerConsumer = getProducerConsumer({ + producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink, + }); + const producerMultiple = getConsumerMultiple({ + producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink, + producerConsumerSpanCSpanLink: producerConsumer.spanCSpanLink, + producerConsumerTransactionCSpanLink: producerConsumer.transactionCSpanLink, + producerExternalOnlySpanBSpanLink: producerExternalOnly.spanBSpanLink, + }); + + await synthtrace.index( + new EntityArrayIterable(producerInternalOnly.apmFields).merge( + new EntityArrayIterable(producerExternalOnly.apmFields), + new EntityArrayIterable(producerConsumer.apmFields), + new EntityArrayIterable(producerMultiple.apmFields) + ) + ); + + return { + producerInternalOnlyIds: producerInternalOnly.ids, + producerExternalOnlyIds: producerExternalOnly.ids, + producerConsumerIds: producerConsumer.ids, + producerMultipleIds: producerMultiple.ids, + }; +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/span_links.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/span_links.spec.ts new file mode 100644 index 0000000000000..99efb7b6a2b6b --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transaction_details/span_links.spec.ts @@ -0,0 +1,301 @@ +/* + * 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 url from 'url'; +import { synthtrace } from '../../../../synthtrace'; +import { generateSpanLinksData } from './generate_span_links_data'; + +const start = '2022-01-01T00:00:00.000Z'; +const end = '2022-01-01T00:15:00.000Z'; + +function getServiceInventoryUrl({ serviceName }: { serviceName: string }) { + return url.format({ + pathname: `/app/apm/services/${serviceName}`, + query: { + rangeFrom: start, + rangeTo: end, + environment: 'ENVIRONMENT_ALL', + kuery: '', + serviceGroup: '', + transactionType: 'request', + comparisonEnabled: true, + offset: '1d', + }, + }); +} + +describe('Span links', () => { + beforeEach(() => { + cy.loginAsReadOnlyUser(); + }); + + describe('when data is loaded', () => { + let ids: Awaited>; + before(async () => { + ids = await generateSpanLinksData(); + }); + + after(async () => { + await synthtrace.clean(); + }); + + describe('span links count on trace waterfall', () => { + it('Shows two children and no parents on producer-internal-only Span A', () => { + cy.visit( + getServiceInventoryUrl({ serviceName: 'producer-internal-only' }) + ); + cy.contains('Transaction A').click(); + cy.contains('2 Span links'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerInternalOnlyIds.spanAId}"]` + ).realHover(); + cy.contains('2 Span links found'); + cy.contains('2 incoming'); + cy.contains('0 outgoing'); + }); + + it('Shows one parent and one children on producer-external-only Span B', () => { + cy.visit( + getServiceInventoryUrl({ serviceName: 'producer-external-only' }) + ); + cy.contains('Transaction B').click(); + cy.contains('2 Span links'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerExternalOnlyIds.spanBId}"]` + ).realHover(); + cy.contains('2 Span links found'); + cy.contains('1 incoming'); + cy.contains('1 outgoing'); + }); + + it('Shows one parent and one children on producer-consumer Transaction C', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' })); + cy.contains('Transaction C').click(); + cy.contains('2 Span links'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerConsumerIds.transactionCId}"]` + ).realHover(); + cy.contains('2 Span links found'); + cy.contains('1 incoming'); + cy.contains('1 outgoing'); + }); + + it('Shows no parent and one children on producer-consumer Span C', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' })); + cy.contains('Transaction C').click(); + cy.contains('1 Span link'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerConsumerIds.spanCId}"]` + ).realHover(); + cy.contains('1 Span link found'); + cy.contains('1 incoming'); + cy.contains('0 outgoing'); + }); + + it('Shows two parents and one children on consumer-multiple Transaction D', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' })); + cy.contains('Transaction D').click(); + cy.contains('2 Span links'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerMultipleIds.transactionDId}"]` + ).realHover(); + cy.contains('2 Span links found'); + cy.contains('0 incoming'); + cy.contains('2 outgoing'); + }); + + it('Shows two parents and one children on consumer-multiple Span E', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' })); + cy.contains('Transaction D').click(); + cy.contains('2 Span links'); + cy.get( + `[data-test-subj="spanLinksBadge_${ids.producerMultipleIds.spanEId}"]` + ).realHover(); + cy.contains('2 Span links found'); + cy.contains('0 incoming'); + cy.contains('2 outgoing'); + }); + }); + + describe('span link flyout', () => { + it('Shows children details on producer-internal-only Span A', () => { + cy.visit( + getServiceInventoryUrl({ serviceName: 'producer-internal-only' }) + ); + cy.contains('Transaction A').click(); + cy.contains('Span A').click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + cy.contains('producer-consumer') + .should('have.attr', 'href') + .and('include', '/services/producer-consumer/overview'); + cy.contains('Transaction C') + .should('have.attr', 'href') + .and( + 'include', + `/link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.transactionCId}` + ); + cy.contains('consumer-multiple') + .should('have.attr', 'href') + .and('include', '/services/consumer-multiple/overview'); + cy.contains('Transaction D') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.transactionDId}` + ); + cy.get('[data-test-subj="spanLinkTypeSelect"]').should( + 'contain.text', + 'Outgoing links (0)' + ); + }); + + it('Shows children and parents details on producer-external-only Span B', () => { + cy.visit( + getServiceInventoryUrl({ serviceName: 'producer-external-only' }) + ); + cy.contains('Transaction B').click(); + cy.contains('Span B').click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + + cy.contains('consumer-multiple') + .should('have.attr', 'href') + .and('include', '/services/consumer-multiple/overview'); + cy.contains('Span E') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.spanEId}` + ); + cy.get('[data-test-subj="spanLinkTypeSelect"]').select( + 'Outgoing links (1)' + ); + cy.contains('Unknown'); + cy.contains('trace#1-span#1'); + }); + + it('Shows children and parents details on producer-consumer Transaction C', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' })); + cy.contains('Transaction C').click(); + cy.get( + `[aria-controls="${ids.producerConsumerIds.transactionCId}"]` + ).click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + + cy.contains('consumer-multiple') + .should('have.attr', 'href') + .and('include', '/services/consumer-multiple/overview'); + cy.contains('Span E') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.spanEId}` + ); + + cy.get('[data-test-subj="spanLinkTypeSelect"]').select( + 'Outgoing links (1)' + ); + cy.contains('producer-internal-only') + .should('have.attr', 'href') + .and('include', '/services/producer-internal-only/overview'); + cy.contains('Span A') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerInternalOnlyIds.transactionAId}?waterfallItemId=${ids.producerInternalOnlyIds.spanAId}` + ); + }); + + it('Shows children and parents details on producer-consumer Span C', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' })); + cy.contains('Transaction C').click(); + cy.contains('Span C').click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + + cy.contains('consumer-multiple') + .should('have.attr', 'href') + .and('include', '/services/consumer-multiple/overview'); + cy.contains('Transaction D') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.transactionDId}` + ); + + cy.get('[data-test-subj="spanLinkTypeSelect"]').should( + 'contain.text', + 'Outgoing links (0)' + ); + }); + + it('Shows children and parents details on consumer-multiple Transaction D', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' })); + cy.contains('Transaction D').click(); + cy.get( + `[aria-controls="${ids.producerMultipleIds.transactionDId}"]` + ).click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + + cy.contains('producer-consumer') + .should('have.attr', 'href') + .and('include', '/services/producer-consumer/overview'); + cy.contains('Span C') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.spanCId}` + ); + + cy.contains('producer-internal-only') + .should('have.attr', 'href') + .and('include', '/services/producer-internal-only/overview'); + cy.contains('Span A') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerInternalOnlyIds.transactionAId}?waterfallItemId=${ids.producerInternalOnlyIds.spanAId}` + ); + + cy.get('[data-test-subj="spanLinkTypeSelect"]').should( + 'contain.text', + 'Incoming links (0)' + ); + }); + + it('Shows children and parents details on consumer-multiple Span E', () => { + cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' })); + cy.contains('Transaction D').click(); + cy.contains('Span E').click(); + cy.get('[data-test-subj="spanLinksTab"]').click(); + + cy.contains('producer-external-only') + .should('have.attr', 'href') + .and('include', '/services/producer-external-only/overview'); + cy.contains('Span B') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerExternalOnlyIds.transactionBId}?waterfallItemId=${ids.producerExternalOnlyIds.spanBId}` + ); + + cy.contains('producer-consumer') + .should('have.attr', 'href') + .and('include', '/services/producer-consumer/overview'); + cy.contains('Transaction C') + .should('have.attr', 'href') + .and( + 'include', + `link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.transactionCId}` + ); + + cy.get('[data-test-subj="spanLinkTypeSelect"]').should( + 'contain.text', + 'Incoming links (0)' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.test.ts b/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.test.ts index 56a4facd20496..2b95ffc28680d 100644 --- a/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.test.ts +++ b/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.test.ts @@ -34,7 +34,7 @@ describe('getRedirectToTransactionDetailPageUrl', () => { it('formats url correctly', () => { expect(url).toBe( - '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z' + '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z&waterfallItemId=' ); }); }); @@ -48,7 +48,7 @@ describe('getRedirectToTransactionDetailPageUrl', () => { it('uses timerange provided', () => { expect(url).toBe( - '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A02%3A00.000Z&rangeTo=2020-01-01T00%3A17%3A59.999Z' + '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A02%3A00.000Z&rangeTo=2020-01-01T00%3A17%3A59.999Z&waterfallItemId=' ); }); }); diff --git a/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.ts b/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.ts index c78c08d6f37dd..a3467d7272ff5 100644 --- a/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.ts +++ b/x-pack/plugins/apm/public/components/app/trace_link/get_redirect_to_transaction_detail_page_url.ts @@ -12,10 +12,12 @@ export const getRedirectToTransactionDetailPageUrl = ({ transaction, rangeFrom, rangeTo, + waterfallItemId, }: { transaction: Transaction; rangeFrom?: string; rangeTo?: string; + waterfallItemId?: string; }) => { return format({ pathname: `/services/${transaction.service.name}/transactions/view`, @@ -37,6 +39,7 @@ export const getRedirectToTransactionDetailPageUrl = ({ diff: transaction.transaction.duration.us / 1000, direction: 'up', }), + waterfallItemId, }, }); }; diff --git a/x-pack/plugins/apm/public/components/app/trace_link/trace_link.test.tsx b/x-pack/plugins/apm/public/components/app/trace_link/trace_link.test.tsx index d8a5127af21a3..432262bb79b11 100644 --- a/x-pack/plugins/apm/public/components/app/trace_link/trace_link.test.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_link/trace_link.test.tsx @@ -122,7 +122,7 @@ describe('TraceLink', () => { const component = shallow(); expect(component.prop('to')).toEqual( - '/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now' + '/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now&waterfallItemId=' ); }); }); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts b/x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts index dea15b952a41b..72d52ae09c9bd 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts @@ -10,12 +10,14 @@ import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_ import { useApmParams } from '../../../hooks/use_apm_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { getWaterfall } from './waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; -const INITIAL_DATA = { +const INITIAL_DATA: APIReturnType<'GET /internal/apm/traces/{traceId}'> = { errorDocs: [], traceDocs: [], exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, }; export function useWaterfallFetcher() { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/span_links_badge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/span_links_badge.tsx new file mode 100644 index 0000000000000..194c3ec38bc9e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/span_links_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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers'; + +type Props = SpanLinksCount & { id: string }; + +export function SpanLinksBadge({ linkedParents, linkedChildren, id }: Props) { + if (!linkedParents && !linkedChildren) { + return null; + } + + const total = linkedParents + linkedChildren; + return ( + + + {i18n.translate( + 'xpack.apm.waterfall.spanLinks.tooltip.linkedChildren', + { + defaultMessage: '{linkedChildren} incoming', + values: { linkedChildren }, + } + )} + + + {i18n.translate( + 'xpack.apm.waterfall.spanLinks.tooltip.linkedParents', + { + defaultMessage: '{linkedParents} outgoing', + values: { linkedParents }, + } + )} + + + } + > + + {i18n.translate('xpack.apm.waterfall.spanLinks.badge', { + defaultMessage: + '{total} {total, plural, one {Span link} other {Span links}}', + values: { total }, + })} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx index d50305c22e543..825da91c7d385 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx @@ -20,24 +20,27 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { isEmpty } from 'lodash'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { CompositeSpanDurationSummaryItem } from '../../../../../../shared/summary/composite_span_duration_summary_item'; +import { isEmpty } from 'lodash'; +import React, { Fragment } from 'react'; import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; import { DiscoverSpanLink } from '../../../../../../shared/links/discover_links/discover_span_link'; import { SpanMetadata } from '../../../../../../shared/metadata_table/span_metadata'; +import { getSpanLinksTabContent } from '../../../../../../shared/span_links/span_links_tab_content'; import { Stacktrace } from '../../../../../../shared/stacktrace'; import { Summary } from '../../../../../../shared/summary'; +import { CompositeSpanDurationSummaryItem } from '../../../../../../shared/summary/composite_span_duration_summary_item'; import { DurationSummaryItem } from '../../../../../../shared/summary/duration_summary_item'; import { HttpInfoSummaryItem } from '../../../../../../shared/summary/http_info_summary_item'; import { TimestampTooltip } from '../../../../../../shared/timestamp_tooltip'; -import { ResponsiveFlyout } from '../responsive_flyout'; import { SyncBadge } from '../badge/sync_badge'; +import { FailureBadge } from '../failure_badge'; +import { ResponsiveFlyout } from '../responsive_flyout'; +import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers'; import { SpanDatabase } from './span_db'; import { StickySpanProperties } from './sticky_span_properties'; -import { FailureBadge } from '../failure_badge'; +import { ProcessorEvent } from '../../../../../../../../common/processor_event'; function formatType(type: string) { switch (type) { @@ -86,6 +89,7 @@ interface Props { parentTransaction?: Transaction; totalDuration?: number; onClose: () => void; + spanLinksCount: SpanLinksCount; } export function SpanFlyout({ @@ -93,6 +97,7 @@ export function SpanFlyout({ parentTransaction, totalDuration, onClose, + spanLinksCount, }: Props) { if (!span) { return null; @@ -107,6 +112,13 @@ export function SpanFlyout({ const spanHttpUrl = span.url?.original || span.span?.http?.url?.original; const spanHttpMethod = span.http?.request?.method || span.span?.http?.method; + const spanLinksTabContent = getSpanLinksTabContent({ + spanLinksCount, + traceId: span.trace.id, + spanId: span.span.id, + processorEvent: ProcessorEvent.span, + }); + return ( @@ -254,6 +266,7 @@ export function SpanFlyout({ }, ] : []), + ...(spanLinksTabContent ? [spanLinksTabContent] : []), ]} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx index fd68fde81fb60..9e8661fd523fb 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx @@ -10,19 +10,23 @@ import { EuiFlexItem, EuiFlyoutBody, EuiFlyoutHeader, + EuiHorizontalRule, EuiPortal, EuiSpacer, + EuiTabbedContent, EuiTitle, - EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { ProcessorEvent } from '../../../../../../../../common/processor_event'; import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; -import { TransactionActionMenu } from '../../../../../../shared/transaction_action_menu/transaction_action_menu'; +import { TransactionMetadata } from '../../../../../../shared/metadata_table/transaction_metadata'; +import { getSpanLinksTabContent } from '../../../../../../shared/span_links/span_links_tab_content'; import { TransactionSummary } from '../../../../../../shared/summary/transaction_summary'; +import { TransactionActionMenu } from '../../../../../../shared/transaction_action_menu/transaction_action_menu'; import { FlyoutTopLevelProperties } from '../flyout_top_level_properties'; import { ResponsiveFlyout } from '../responsive_flyout'; -import { TransactionMetadata } from '../../../../../../shared/metadata_table/transaction_metadata'; +import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers'; import { DroppedSpansWarning } from './dropped_spans_warning'; interface Props { @@ -30,21 +34,7 @@ interface Props { transaction?: Transaction; errorCount?: number; rootTransactionDuration?: number; -} - -function TransactionPropertiesTable({ - transaction, -}: { - transaction: Transaction; -}) { - return ( -
- -

Metadata

-
- -
- ); + spanLinksCount: SpanLinksCount; } export function TransactionFlyout({ @@ -52,11 +42,19 @@ export function TransactionFlyout({ onClose, errorCount = 0, rootTransactionDuration, + spanLinksCount, }: Props) { if (!transactionDoc) { return null; } + const spanLinksTabContent = getSpanLinksTabContent({ + spanLinksCount, + traceId: transactionDoc.trace.id, + spanId: transactionDoc.transaction.id, + processorEvent: ProcessorEvent.transaction, + }); + return ( @@ -94,7 +92,26 @@ export function TransactionFlyout({ /> - + + + + + ), + }, + ...(spanLinksTabContent ? [spanLinksTabContent] : []), + ]} + /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_flyout.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_flyout.tsx index 948f790848e8f..bd0ab44e0e208 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_flyout.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_flyout.tsx @@ -45,6 +45,7 @@ export function WaterfallFlyout({ span={currentItem.doc} parentTransaction={parentTransaction} onClose={() => toggleFlyout({ history })} + spanLinksCount={currentItem.spanLinksCount} /> ); case 'transaction': @@ -56,6 +57,7 @@ export function WaterfallFlyout({ waterfall.rootTransaction?.transaction.duration.us } errorCount={waterfall.getErrorCount(currentItem.id)} + spanLinksCount={currentItem.spanLinksCount} /> ); default: diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap index b1ea74c3eb0c0..ad296743c6031 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap @@ -190,18 +190,38 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -389,18 +409,38 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "mySpanIdD": Array [ @@ -516,12 +556,24 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "myTransactionId1": Array [ @@ -596,9 +648,17 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "myTransactionId2": Array [ @@ -751,15 +811,31 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "root": Array [ @@ -797,6 +873,10 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], }, @@ -835,6 +915,10 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "errorItems": Array [ Object { @@ -907,6 +991,10 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, @@ -948,6 +1036,10 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1020,9 +1112,17 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1136,12 +1236,24 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1292,15 +1404,31 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1488,18 +1616,38 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1687,18 +1835,38 @@ Object { "parent": undefined, "parentId": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId1", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "legends": Array [ @@ -1869,12 +2037,24 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -1994,12 +2174,24 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "mySpanIdD": Array [ @@ -2047,6 +2239,10 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "myTransactionId2": Array [ @@ -2131,9 +2327,17 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], }, @@ -2182,6 +2386,10 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "errorItems": Array [], "getErrorCount": [Function], @@ -2230,6 +2438,10 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2312,9 +2524,17 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2434,12 +2654,24 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2559,12 +2791,24 @@ Object { "parent": undefined, "parentId": "mySpanIdD", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "myTransactionId2", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "mySpanIdA", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ], "legends": Array [ @@ -2687,6 +2931,10 @@ Array [ "offset": 0, "parent": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2740,9 +2988,17 @@ Array [ "offset": 0, "parent": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "a", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2796,9 +3052,17 @@ Array [ "offset": 0, "parent": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "a", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2877,12 +3141,24 @@ Array [ "offset": 0, "parent": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "a", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "b", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, Object { "color": "", @@ -2989,15 +3265,31 @@ Array [ "offset": 0, "parent": undefined, "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "a", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "b", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, "parentId": "c", "skew": 0, + "spanLinksCount": Object { + "linkedChildren": 0, + "linkedParents": 0, + }, }, ] `; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts index 8c96c48f47d7c..1aa38688c1549 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts @@ -133,6 +133,7 @@ describe('waterfall_helpers', () => { traceDocs: hits, errorDocs, exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, }; const waterfall = getWaterfall(apiResp, entryTransactionId); const { apiResponse, ...waterfallRest } = waterfall; @@ -151,6 +152,7 @@ describe('waterfall_helpers', () => { traceDocs: hits, errorDocs, exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, }; const waterfall = getWaterfall(apiResp, entryTransactionId); @@ -236,6 +238,7 @@ describe('waterfall_helpers', () => { traceDocs: traceItems, errorDocs: [], exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, }, entryTransactionId ); @@ -342,6 +345,7 @@ describe('waterfall_helpers', () => { traceDocs: traceItems, errorDocs: [], exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, }, entryTransactionId ); @@ -404,6 +408,10 @@ describe('waterfall_helpers', () => { skew: 0, legendValues, color: '', + spanLinksCount: { + linkedChildren: 0, + linkedParents: 0, + }, }, { docType: 'span', @@ -426,6 +434,10 @@ describe('waterfall_helpers', () => { skew: 0, legendValues, color: '', + spanLinksCount: { + linkedChildren: 0, + linkedParents: 0, + }, }, { docType: 'span', @@ -448,6 +460,10 @@ describe('waterfall_helpers', () => { skew: 0, legendValues, color: '', + spanLinksCount: { + linkedChildren: 0, + linkedParents: 0, + }, }, { docType: 'transaction', @@ -464,6 +480,10 @@ describe('waterfall_helpers', () => { skew: 0, legendValues, color: '', + spanLinksCount: { + linkedChildren: 0, + linkedParents: 0, + }, }, { docType: 'transaction', @@ -481,6 +501,10 @@ describe('waterfall_helpers', () => { skew: 0, legendValues, color: '', + spanLinksCount: { + linkedChildren: 0, + linkedParents: 0, + }, }, ]; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts index 489eb30528bf4..cf53606afd9b4 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts @@ -7,10 +7,11 @@ import { euiPaletteColorBlind } from '@elastic/eui'; import { first, flatten, groupBy, isEmpty, sortBy, uniq } from 'lodash'; -import { APIReturnType } from '../../../../../../../services/rest/create_call_apm_api'; -import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error'; -import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; -import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; +import type { APIReturnType } from '../../../../../../../services/rest/create_call_apm_api'; +import type { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error'; +import type { Span } from '../../../../../../../../typings/es_schemas/ui/span'; +import type { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; +import { ProcessorEvent } from '../../../../../../../../common/processor_event'; type TraceAPIResponse = APIReturnType<'GET /internal/apm/traces/{traceId}'>; @@ -20,6 +21,11 @@ interface IWaterfallGroup { const ROOT_ID = 'root'; +export interface SpanLinksCount { + linkedChildren: number; + linkedParents: number; +} + export enum WaterfallLegendType { ServiceName = 'serviceName', SpanType = 'spanType', @@ -48,6 +54,7 @@ interface IWaterfallSpanItemBase */ duration: number; legendValues: Record; + spanLinksCount: SpanLinksCount; } interface IWaterfallItemBase { @@ -93,13 +100,17 @@ function getLegendValues(transactionOrSpan: Transaction | Span) { return { [WaterfallLegendType.ServiceName]: transactionOrSpan.service.name, [WaterfallLegendType.SpanType]: - 'span' in transactionOrSpan - ? transactionOrSpan.span.subtype || transactionOrSpan.span.type + transactionOrSpan.processor.event === ProcessorEvent.span + ? (transactionOrSpan as Span).span.subtype || + (transactionOrSpan as Span).span.type : '', }; } -function getTransactionItem(transaction: Transaction): IWaterfallTransaction { +function getTransactionItem( + transaction: Transaction, + linkedChildrenCount: number = 0 +): IWaterfallTransaction { return { docType: 'transaction', doc: transaction, @@ -110,10 +121,17 @@ function getTransactionItem(transaction: Transaction): IWaterfallTransaction { skew: 0, legendValues: getLegendValues(transaction), color: '', + spanLinksCount: { + linkedParents: transaction.span?.links?.length ?? 0, + linkedChildren: linkedChildrenCount, + }, }; } -function getSpanItem(span: Span): IWaterfallSpan { +function getSpanItem( + span: Span, + linkedChildrenCount: number = 0 +): IWaterfallSpan { return { docType: 'span', doc: span, @@ -124,6 +142,10 @@ function getSpanItem(span: Span): IWaterfallSpan { skew: 0, legendValues: getLegendValues(span), color: '', + spanLinksCount: { + linkedParents: span.span.links?.length ?? 0, + linkedChildren: linkedChildrenCount, + }, }; } @@ -265,14 +287,26 @@ const getWaterfallDuration = (waterfallItems: IWaterfallItem[]) => 0 ); -const getWaterfallItems = (items: TraceAPIResponse['traceDocs']) => +const getWaterfallItems = ( + items: TraceAPIResponse['traceDocs'], + linkedChildrenOfSpanCountBySpanId: TraceAPIResponse['linkedChildrenOfSpanCountBySpanId'] +) => items.map((item) => { const docType: 'span' | 'transaction' = item.processor.event; switch (docType) { - case 'span': - return getSpanItem(item as Span); + case 'span': { + const span = item as Span; + return getSpanItem( + span, + linkedChildrenOfSpanCountBySpanId[span.span.id] + ); + } case 'transaction': - return getTransactionItem(item as Transaction); + const transaction = item as Transaction; + return getTransactionItem( + transaction, + linkedChildrenOfSpanCountBySpanId[transaction.transaction.id] + ); } }); @@ -396,7 +430,8 @@ export function getWaterfall( const errorCountByParentId = getErrorCountByParentId(apiResponse.errorDocs); const waterfallItems: IWaterfallSpanOrTransaction[] = getWaterfallItems( - apiResponse.traceDocs + apiResponse.traceDocs, + apiResponse.linkedChildrenOfSpanCountBySpanId ); const childrenByParentId = getChildrenGroupedByParentId( diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx index 9190fd5924174..d372cec9ce16d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx @@ -19,6 +19,7 @@ import { asDuration } from '../../../../../../../common/utils/formatters'; import { Margins } from '../../../../../shared/charts/timeline'; import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip'; import { SyncBadge } from './badge/sync_badge'; +import { SpanLinksBadge } from './badge/span_links_badge'; import { ColdStartBadge } from './badge/cold_start_badge'; import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers'; import { FailureBadge } from './failure_badge'; @@ -237,6 +238,11 @@ export function WaterfallItem({ agentName={item.doc.agent.name} /> )} + {isServerlessColdstart && } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts index d4af0e92c9054..9ac9497a33d6a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts @@ -530,6 +530,7 @@ export const simpleTrace = { ], exceedsMax: false, errorDocs: [], + linkedChildrenOfSpanCountBySpanId: {}, } as TraceAPIResponse; export const manyChildrenWithSameLength = { @@ -4126,6 +4127,7 @@ export const manyChildrenWithSameLength = { }, }, ], + linkedChildrenOfSpanCountBySpanId: {}, } as TraceAPIResponse; export const traceWithErrors = { @@ -4716,6 +4718,7 @@ export const traceWithErrors = { }, }, ], + linkedChildrenOfSpanCountBySpanId: {}, } as unknown as TraceAPIResponse; export const traceChildStartBeforeParent = { @@ -5211,6 +5214,7 @@ export const traceChildStartBeforeParent = { ], exceedsMax: false, errorDocs: [], + linkedChildrenOfSpanCountBySpanId: {}, } as TraceAPIResponse; export const inferredSpans = { @@ -5865,4 +5869,5 @@ export const inferredSpans = { ], exceedsMax: false, errorDocs: [], + linkedChildrenOfSpanCountBySpanId: {}, } as TraceAPIResponse; diff --git a/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx index b28649d9bc56f..720d3feee581a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx @@ -21,7 +21,7 @@ const CentralizedContainer = euiStyled.div` export function TransactionLink() { const { path: { transactionId }, - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, waterfallItemId }, } = useApmParams('/link-to/transaction/{transactionId}'); const { data = { transaction: null }, status } = useFetcher( @@ -46,6 +46,7 @@ export function TransactionLink() { transaction: data.transaction, rangeFrom, rangeTo, + waterfallItemId, })} /> ); diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx index ad6947c736fe2..fe5e0742f7c2c 100644 --- a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -43,6 +43,7 @@ const apmRoutes = { query: t.partial({ rangeFrom: t.string, rangeTo: t.string, + waterfallItemId: t.string, }), }), ]), diff --git a/x-pack/plugins/apm/public/components/shared/span_links/index.tsx b/x-pack/plugins/apm/public/components/shared/span_links/index.tsx new file mode 100644 index 0000000000000..0dc0213e92d44 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_links/index.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 { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSelect, + EuiSelectOption, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo, useState } from 'react'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { SpanLinksCount } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; +import { KueryBar } from '../kuery_bar'; +import { SpanLinksCallout } from './span_links_callout'; +import { SpanLinksTable } from './span_links_table'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { useLocalStorage } from '../../../hooks/use_local_storage'; + +interface Props { + spanLinksCount: SpanLinksCount; + traceId: string; + spanId: string; + processorEvent: ProcessorEvent; +} + +type LinkType = 'children' | 'parents'; + +export function SpanLinks({ + spanLinksCount, + traceId, + spanId, + processorEvent, +}: Props) { + const { + query: { rangeFrom, rangeTo }, + } = useApmParams('/services/{serviceName}/transactions/view'); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const [selectedLinkType, setSelectedLinkType] = useState( + spanLinksCount.linkedChildren ? 'children' : 'parents' + ); + + const [spanLinksCalloutDismissed, setSpanLinksCalloutDismissed] = + useLocalStorage('apm.spanLinksCalloutDismissed', false); + + const [kuery, setKuery] = useState(''); + + const { data, status } = useFetcher( + (callApmApi) => { + if (selectedLinkType === 'children') { + return callApmApi( + 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children', + { + params: { + path: { traceId, spanId }, + query: { kuery, start, end }, + }, + } + ); + } + return callApmApi( + 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents', + { + params: { + path: { traceId, spanId }, + query: { kuery, start, end, processorEvent }, + }, + } + ); + }, + [selectedLinkType, kuery, traceId, spanId, start, end, processorEvent] + ); + + const selectOptions: EuiSelectOption[] = useMemo( + () => [ + { + value: 'children', + text: i18n.translate('xpack.apm.spanLinks.combo.childrenLinks', { + defaultMessage: 'Incoming links ({linkedChildren})', + values: { linkedChildren: spanLinksCount.linkedChildren }, + }), + disabled: !spanLinksCount.linkedChildren, + }, + { + value: 'parents', + text: i18n.translate('xpack.apm.spanLinks.combo.parentsLinks', { + defaultMessage: 'Outgoing links ({linkedParents})', + values: { linkedParents: spanLinksCount.linkedParents }, + }), + disabled: !spanLinksCount.linkedParents, + }, + ], + [spanLinksCount] + ); + + if ( + !data || + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + ) { + return ( +
+ +
+ ); + } + + return ( + + {!spanLinksCalloutDismissed && ( + + { + setSpanLinksCalloutDismissed(true); + }} + /> + + )} + + + + { + setKuery(value); + }} + value={kuery} + /> + + + { + setSelectedLinkType(e.target.value as LinkType); + }} + /> + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/span_links/span_links_callout.tsx b/x-pack/plugins/apm/public/components/shared/span_links/span_links_callout.tsx new file mode 100644 index 0000000000000..1884db40a2111 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_links/span_links_callout.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface Props { + dismissCallout: () => void; +} + +export function SpanLinksCallout({ dismissCallout }: Props) { + return ( + +

+ +

+ { + dismissCallout(); + }} + > + {i18n.translate('xpack.apm.spanLinks.callout.dimissButton', { + defaultMessage: 'Dismiss', + })} + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/span_links/span_links_tab_content.tsx b/x-pack/plugins/apm/public/components/shared/span_links/span_links_tab_content.tsx new file mode 100644 index 0000000000000..ce20491d4b472 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_links/span_links_tab_content.tsx @@ -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 React from 'react'; +import { EuiNotificationBadge, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SpanLinks } from '.'; +import { SpanLinksCount } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; +import { ProcessorEvent } from '../../../../common/processor_event'; + +interface Props { + spanLinksCount: SpanLinksCount; + traceId: string; + spanId: string; + processorEvent: ProcessorEvent; +} + +export function getSpanLinksTabContent({ + spanLinksCount, + traceId, + spanId, + processorEvent, +}: Props) { + if (!spanLinksCount.linkedChildren && !spanLinksCount.linkedParents) { + return undefined; + } + + return { + id: 'span_links', + 'data-test-subj': 'spanLinksTab', + name: ( + <> + {i18n.translate('xpack.apm.propertiesTable.tabs.spanLinks', { + defaultMessage: 'Span links', + })} + + ), + append: ( + + {spanLinksCount.linkedChildren + spanLinksCount.linkedParents} + + ), + content: ( + <> + + + + ), + }; +} diff --git a/x-pack/plugins/apm/public/components/shared/span_links/span_links_table.tsx b/x-pack/plugins/apm/public/components/shared/span_links/span_links_table.tsx new file mode 100644 index 0000000000000..f14cfd3e086d7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_links/span_links_table.tsx @@ -0,0 +1,234 @@ +/* + * 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 { + EuiBasicTableColumn, + EuiButtonEmpty, + EuiButtonIcon, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { SpanLinkDetails } from '../../../../common/span_links'; +import { asDuration } from '../../../../common/utils/formatters'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { ServiceLink } from '../service_link'; +import { getSpanIcon } from '../span_icon/get_span_icon'; + +interface Props { + items: SpanLinkDetails[]; +} + +export function SpanLinksTable({ items }: Props) { + const router = useApmRouter(); + const { + query: { rangeFrom, rangeTo, comparisonEnabled }, + } = useApmParams('/services/{serviceName}/transactions/view'); + const [idActionMenuOpen, setIdActionMenuOpen] = useState< + string | undefined + >(); + + const columns: Array> = [ + { + field: 'serviceName', + name: i18n.translate('xpack.apm.spanLinks.table.serviceName', { + defaultMessage: 'Service name', + }), + sortable: true, + render: (_, { details }) => { + if (details) { + return ( + + ); + } + return ( + + + + + + {i18n.translate('xpack.apm.spanLinks.table.serviceName.unknown', { + defaultMessage: 'Unknown', + })} + + + ); + }, + }, + { + field: 'spanId', + name: i18n.translate('xpack.apm.spanLinks.table.span', { + defaultMessage: 'Span', + }), + sortable: true, + render: (_, { spanId, traceId, details }) => { + if (details && details.transactionId) { + return ( + + + + + + + {details.spanName} + + + + ); + } + return `${traceId}-${spanId}`; + }, + }, + { + field: 'duration', + name: i18n.translate('xpack.apm.spanLinks.table.spanDuration', { + defaultMessage: 'Span duration', + }), + sortable: true, + width: '150', + render: (_, { details }) => { + return ( + + {asDuration(details?.duration)} + + ); + }, + }, + { + field: 'actions', + name: 'Actions', + width: '100', + render: (_, { spanId, traceId, details }) => { + const id = `${traceId}:${spanId}`; + return ( + { + setIdActionMenuOpen(id); + }} + /> + } + isOpen={idActionMenuOpen === id} + closePopover={() => { + setIdActionMenuOpen(undefined); + }} + > + + {details?.transactionId && ( + + + {i18n.translate( + 'xpack.apm.spanLinks.table.actions.goToTraceDetails', + { defaultMessage: 'Go to trace' } + )} + + + )} + + + {(copy) => ( + { + copy(); + setIdActionMenuOpen(undefined); + }} + flush="both" + > + {i18n.translate( + 'xpack.apm.spanLinks.table.actions.copyParentTraceId', + { defaultMessage: 'Copy parent trace id' } + )} + + )} + + + {details?.transactionId && ( + + + {i18n.translate( + 'xpack.apm.spanLinks.table.actions.goToSpanDetails', + { defaultMessage: 'Go to span details' } + )} + + + )} + + + {(copy) => ( + { + copy(); + setIdActionMenuOpen(undefined); + }} + flush="both" + > + {i18n.translate( + 'xpack.apm.spanLinks.table.actions.copySpanId', + { defaultMessage: 'Copy span id' } + )} + + )} + + + + + ); + }, + }, + ]; + + return ( + + ); +} diff --git a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts index 5e6ac627364d8..7224a58fda982 100644 --- a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts @@ -37,6 +37,7 @@ import { historicalDataRouteRepository } from '../historical_data/route'; import { eventMetadataRouteRepository } from '../event_metadata/route'; import { suggestionsRouteRepository } from '../suggestions/route'; import { agentKeysRouteRepository } from '../agent_keys/route'; +import { spanLinksRouteRepository } from '../span_links/route'; function getTypedGlobalApmServerRouteRepository() { const repository = { @@ -67,6 +68,7 @@ function getTypedGlobalApmServerRouteRepository() { ...historicalDataRouteRepository, ...eventMetadataRouteRepository, ...agentKeysRouteRepository, + ...spanLinksRouteRepository, }; return repository; diff --git a/x-pack/plugins/apm/server/routes/span_links/get_linked_children.ts b/x-pack/plugins/apm/server/routes/span_links/get_linked_children.ts new file mode 100644 index 0000000000000..43b55d31503e4 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/span_links/get_linked_children.ts @@ -0,0 +1,141 @@ +/* + * 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 { rangeQuery } from '@kbn/observability-plugin/server'; +import { isEmpty } from 'lodash'; +import { + PROCESSOR_EVENT, + SPAN_ID, + SPAN_LINKS, + SPAN_LINKS_TRACE_ID, + SPAN_LINKS_SPAN_ID, + TRACE_ID, + TRANSACTION_ID, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import type { SpanRaw } from '../../../typings/es_schemas/raw/span_raw'; +import type { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; +import { Setup } from '../../lib/helpers/setup_request'; +import { getBufferedTimerange } from './utils'; + +async function fetchLinkedChildrenOfSpan({ + traceId, + setup, + start, + end, + spanId, +}: { + traceId: string; + setup: Setup; + start: number; + end: number; + spanId?: string; +}) { + const { apmEventClient } = setup; + + const { startWithBuffer, endWithBuffer } = getBufferedTimerange({ + start, + end, + }); + + const response = await apmEventClient.search( + 'fetch_linked_children_of_span', + { + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + _source: [SPAN_LINKS, TRACE_ID, SPAN_ID, PROCESSOR_EVENT, TRANSACTION_ID], + body: { + size: 1000, + query: { + bool: { + filter: [ + ...rangeQuery(startWithBuffer, endWithBuffer), + { term: { [SPAN_LINKS_TRACE_ID]: traceId } }, + ...(spanId ? [{ term: { [SPAN_LINKS_SPAN_ID]: spanId } }] : []), + ], + }, + }, + }, + } + ); + // Filter out documents that don't have any span.links that match the combination of traceId and spanId + return response.hits.hits.filter(({ _source: source }) => { + const spanLinks = source.span?.links?.filter((spanLink) => { + return ( + spanLink.trace.id === traceId && + (spanId ? spanLink.span.id === spanId : true) + ); + }); + return !isEmpty(spanLinks); + }); +} + +function getSpanId(source: TransactionRaw | SpanRaw) { + return source.processor.event === ProcessorEvent.span + ? (source as SpanRaw).span.id + : (source as TransactionRaw).transaction?.id; +} + +export async function getLinkedChildrenCountBySpanId({ + traceId, + setup, + start, + end, +}: { + traceId: string; + setup: Setup; + start: number; + end: number; +}) { + const linkedChildren = await fetchLinkedChildrenOfSpan({ + traceId, + setup, + start, + end, + }); + return linkedChildren.reduce>( + (acc, { _source: source }) => { + source.span?.links?.forEach((link) => { + // Ignores span links that don't belong to this trace + if (link.trace.id === traceId) { + acc[link.span.id] = (acc[link.span.id] || 0) + 1; + } + }); + return acc; + }, + {} + ); +} + +export async function getLinkedChildrenOfSpan({ + traceId, + spanId, + setup, + start, + end, +}: { + traceId: string; + spanId: string; + setup: Setup; + start: number; + end: number; +}) { + const linkedChildren = await fetchLinkedChildrenOfSpan({ + traceId, + spanId, + setup, + start, + end, + }); + + return linkedChildren.map(({ _source: source }) => { + return { + trace: { id: source.trace.id }, + span: { id: getSpanId(source) }, + }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/span_links/get_linked_parents.ts b/x-pack/plugins/apm/server/routes/span_links/get_linked_parents.ts new file mode 100644 index 0000000000000..876a0f3718642 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/span_links/get_linked_parents.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { rangeQuery } from '@kbn/observability-plugin/server'; +import { + SPAN_ID, + SPAN_LINKS, + TRACE_ID, + TRANSACTION_ID, + PROCESSOR_EVENT, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { SpanRaw } from '../../../typings/es_schemas/raw/span_raw'; +import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; +import { Setup } from '../../lib/helpers/setup_request'; + +export async function getLinkedParentsOfSpan({ + setup, + traceId, + spanId, + start, + end, + processorEvent, +}: { + traceId: string; + spanId: string; + setup: Setup; + start: number; + end: number; + processorEvent: ProcessorEvent; +}) { + const { apmEventClient } = setup; + + const response = await apmEventClient.search('get_linked_parents_of_span', { + apm: { + events: [processorEvent], + }, + _source: [SPAN_LINKS], + body: { + size: 1, + query: { + bool: { + filter: [ + ...rangeQuery(start, end), + { term: { [TRACE_ID]: traceId } }, + { exists: { field: SPAN_LINKS } }, + { term: { [PROCESSOR_EVENT]: processorEvent } }, + ...(processorEvent === ProcessorEvent.transaction + ? [{ term: { [TRANSACTION_ID]: spanId } }] + : [{ term: { [SPAN_ID]: spanId } }]), + ], + }, + }, + }, + }); + + const source = response.hits.hits?.[0]?._source as TransactionRaw | SpanRaw; + + return source?.span?.links || []; +} diff --git a/x-pack/plugins/apm/server/routes/span_links/get_span_links_details.ts b/x-pack/plugins/apm/server/routes/span_links/get_span_links_details.ts new file mode 100644 index 0000000000000..cffd7ff826c88 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/span_links/get_span_links_details.ts @@ -0,0 +1,212 @@ +/* + * 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 { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server'; +import { chunk, compact, isEmpty, keyBy } from 'lodash'; +import { + SERVICE_NAME, + SPAN_ID, + SPAN_NAME, + TRACE_ID, + TRANSACTION_ID, + TRANSACTION_NAME, + TRANSACTION_DURATION, + SPAN_DURATION, + PROCESSOR_EVENT, + SPAN_SUBTYPE, + SPAN_TYPE, + AGENT_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { Environment } from '../../../common/environment_rt'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { SpanLinkDetails } from '../../../common/span_links'; +import { SpanLink } from '../../../typings/es_schemas/raw/fields/span_links'; +import { SpanRaw } from '../../../typings/es_schemas/raw/span_raw'; +import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; +import { Setup } from '../../lib/helpers/setup_request'; +import { getBufferedTimerange } from './utils'; + +async function fetchSpanLinksDetails({ + setup, + kuery, + spanLinks, + start, + end, +}: { + setup: Setup; + kuery: string; + spanLinks: SpanLink[]; + start: number; + end: number; +}) { + const { apmEventClient } = setup; + + const { startWithBuffer, endWithBuffer } = getBufferedTimerange({ + start, + end, + }); + + const response = await apmEventClient.search('get_span_links_details', { + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + _source: [ + TRACE_ID, + SPAN_ID, + TRANSACTION_ID, + SERVICE_NAME, + SPAN_NAME, + TRANSACTION_NAME, + TRANSACTION_DURATION, + SPAN_DURATION, + PROCESSOR_EVENT, + SPAN_SUBTYPE, + SPAN_TYPE, + AGENT_NAME, + ], + body: { + size: 1000, + query: { + bool: { + filter: [ + ...rangeQuery(startWithBuffer, endWithBuffer), + ...kqlQuery(kuery), + { + bool: { + should: spanLinks.map((item) => { + return { + bool: { + filter: [ + { term: { [TRACE_ID]: item.trace.id } }, + { + bool: { + should: [ + { term: { [SPAN_ID]: item.span.id } }, + { term: { [TRANSACTION_ID]: item.span.id } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }; + }), + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + }); + + const spanIdsMap = keyBy(spanLinks, 'span.id'); + + return response.hits.hits.filter(({ _source: source }) => { + // The above query might return other spans from the same transaction because siblings spans share the same transaction.id + // so, if it is a span we need to guarantee that the span.id is the same as the span links ids + if (source.processor.event === ProcessorEvent.span) { + const span = source as SpanRaw; + const hasSpanId = spanIdsMap[span.span.id] || false; + return hasSpanId; + } + return true; + }); +} + +export async function getSpanLinksDetails({ + setup, + spanLinks, + kuery, + start, + end, +}: { + setup: Setup; + spanLinks: SpanLink[]; + kuery: string; + start: number; + end: number; +}): Promise { + if (!spanLinks.length) { + return []; + } + + // chunk span links to avoid too_many_nested_clauses problem + const spanLinksChunks = chunk(spanLinks, 500); + const chunkedResponses = await Promise.all( + spanLinksChunks.map((spanLinksChunk) => + fetchSpanLinksDetails({ + setup, + kuery, + spanLinks: spanLinksChunk, + start, + end, + }) + ) + ); + + const linkedSpans = chunkedResponses.flat(); + + // Creates a map for all span links details found + const spanLinksDetailsMap = linkedSpans.reduce< + Record + >((acc, { _source: source }) => { + const commonDetails = { + serviceName: source.service.name, + agentName: source.agent.name, + environment: source.service.environment as Environment, + transactionId: source.transaction?.id, + }; + + if (source.processor.event === ProcessorEvent.transaction) { + const transaction = source as TransactionRaw; + const key = `${transaction.trace.id}:${transaction.transaction.id}`; + acc[key] = { + traceId: source.trace.id, + spanId: transaction.transaction.id, + details: { + ...commonDetails, + spanName: transaction.transaction.name, + duration: transaction.transaction.duration.us, + }, + }; + } else { + const span = source as SpanRaw; + const key = `${span.trace.id}:${span.span.id}`; + acc[key] = { + traceId: source.trace.id, + spanId: span.span.id, + details: { + ...commonDetails, + spanName: span.span.name, + duration: span.span.duration.us, + spanSubtype: span.span.subtype, + spanType: span.span.type, + }, + }; + } + + return acc; + }, {}); + + // It's important to keep the original order of the span links, + // so loops trough the original list merging external links and links with details. + // external links are links that the details were not found in the ES query. + return compact( + spanLinks.map((item) => { + const key = `${item.trace.id}:${item.span.id}`; + const details = spanLinksDetailsMap[key]; + if (details) { + return details; + } + + // When kuery is not set, returns external links, if not hides this item. + return isEmpty(kuery) + ? { traceId: item.trace.id, spanId: item.span.id } + : undefined; + }) + ); +} diff --git a/x-pack/plugins/apm/server/routes/span_links/route.ts b/x-pack/plugins/apm/server/routes/span_links/route.ts new file mode 100644 index 0000000000000..34b5864778144 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/span_links/route.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { getSpanLinksDetails } from './get_span_links_details'; +import { getLinkedChildrenOfSpan } from './get_linked_children'; +import { kueryRt, rangeRt } from '../default_api_types'; +import { SpanLinkDetails } from '../../../common/span_links'; +import { processorEventRt } from '../../../common/processor_event'; +import { getLinkedParentsOfSpan } from './get_linked_parents'; + +const linkedParentsRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents', + params: t.type({ + path: t.type({ + traceId: t.string, + spanId: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + t.type({ processorEvent: processorEventRt }), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + spanLinksDetails: SpanLinkDetails[]; + }> => { + const { + params: { query, path }, + } = resources; + const setup = await setupRequest(resources); + const linkedParents = await getLinkedParentsOfSpan({ + setup, + traceId: path.traceId, + spanId: path.spanId, + start: query.start, + end: query.end, + processorEvent: query.processorEvent, + }); + + return { + spanLinksDetails: await getSpanLinksDetails({ + setup, + spanLinks: linkedParents, + kuery: query.kuery, + start: query.start, + end: query.end, + }), + }; + }, +}); + +const linkedChildrenRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children', + params: t.type({ + path: t.type({ + traceId: t.string, + spanId: t.string, + }), + query: t.intersection([kueryRt, rangeRt]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + spanLinksDetails: SpanLinkDetails[]; + }> => { + const { + params: { query, path }, + } = resources; + const setup = await setupRequest(resources); + const linkedChildren = await getLinkedChildrenOfSpan({ + setup, + traceId: path.traceId, + spanId: path.spanId, + start: query.start, + end: query.end, + }); + + return { + spanLinksDetails: await getSpanLinksDetails({ + setup, + spanLinks: linkedChildren, + kuery: query.kuery, + start: query.start, + end: query.end, + }), + }; + }, +}); + +export const spanLinksRouteRepository = { + ...linkedParentsRoute, + ...linkedChildrenRoute, +}; diff --git a/x-pack/plugins/apm/server/routes/span_links/utils.ts b/x-pack/plugins/apm/server/routes/span_links/utils.ts new file mode 100644 index 0000000000000..7425d1b774286 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/span_links/utils.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 moment from 'moment'; + +export function getBufferedTimerange({ + start, + end, + bufferSize = 4, +}: { + start: number; + end: number; + bufferSize?: number; +}) { + return { + startWithBuffer: moment(start).subtract(bufferSize, 'days').valueOf(), + endWithBuffer: moment(end).add(bufferSize, 'days').valueOf(), + }; +} diff --git a/x-pack/plugins/apm/server/routes/traces/get_trace_items.ts b/x-pack/plugins/apm/server/routes/traces/get_trace_items.ts index d754c2d53b71f..3f6146c713303 100644 --- a/x-pack/plugins/apm/server/routes/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/routes/traces/get_trace_items.ts @@ -10,15 +10,16 @@ import { Sort, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { rangeQuery } from '@kbn/observability-plugin/server'; -import { ProcessorEvent } from '../../../common/processor_event'; import { + ERROR_LOG_LEVEL, + PARENT_ID, + SPAN_DURATION, TRACE_ID, TRANSACTION_DURATION, - SPAN_DURATION, - PARENT_ID, - ERROR_LOG_LEVEL, } from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; import { Setup } from '../../lib/helpers/setup_request'; +import { getLinkedChildrenCountBySpanId } from '../span_links/get_linked_children'; export async function getTraceItems( traceId: string, @@ -74,12 +75,21 @@ export async function getTraceItems( }, }); - const errorResponse = await errorResponsePromise; - const traceResponse = await traceResponsePromise; + const [errorResponse, traceResponse, linkedChildrenOfSpanCountBySpanId] = + await Promise.all([ + errorResponsePromise, + traceResponsePromise, + getLinkedChildrenCountBySpanId({ traceId, setup, start, end }), + ]); const exceedsMax = traceResponse.hits.total.value > maxTraceItems; const traceDocs = traceResponse.hits.hits.map((hit) => hit._source); const errorDocs = errorResponse.hits.hits.map((hit) => hit._source); - return { exceedsMax, traceDocs, errorDocs }; + return { + exceedsMax, + traceDocs, + errorDocs, + linkedChildrenOfSpanCountBySpanId, + }; } diff --git a/x-pack/plugins/apm/server/routes/traces/route.ts b/x-pack/plugins/apm/server/routes/traces/route.ts index c767a4e67aa63..afca332fea0b5 100644 --- a/x-pack/plugins/apm/server/routes/traces/route.ts +++ b/x-pack/plugins/apm/server/routes/traces/route.ts @@ -82,6 +82,7 @@ const tracesByIdRoute = createApmServerRoute({ errorDocs: Array< import('./../../../typings/es_schemas/ui/apm_error').APMError >; + linkedChildrenOfSpanCountBySpanId: Record; }> => { const setup = await setupRequest(resources); const { params } = resources; diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/span_links.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/span_links.ts new file mode 100644 index 0000000000000..5e0028ad58176 --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/span_links.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface SpanLink { + trace: { id: string }; + span: { id: string }; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index d191cb6d4e84c..1cac68f74b8b7 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -8,6 +8,7 @@ import { APMBaseDoc } from './apm_base_doc'; import { EventOutcome } from './fields/event_outcome'; import { Http } from './fields/http'; +import { SpanLink } from './fields/span_links'; import { Stackframe } from './fields/stackframe'; import { TimestampUs } from './fields/timestamp_us'; import { Url } from './fields/url'; @@ -63,6 +64,7 @@ export interface SpanRaw extends APMBaseDoc { sum: { us: number }; compression_strategy: string; }; + links?: SpanLink[]; }; timestamp: TimestampUs; transaction?: { diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 0811bfb8c1a79..4046bb9470fb7 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -20,6 +20,7 @@ import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; import { Faas } from './fields/faas'; +import { SpanLink } from './fields/span_links'; interface Processor { name: 'transaction'; @@ -71,4 +72,7 @@ export interface TransactionRaw extends APMBaseDoc { user_agent?: UserAgent; cloud?: Cloud; faas?: Faas; + span?: { + links?: SpanLink[]; + }; } diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 31feae3331b04..0cb084b5beb7c 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -341,6 +341,11 @@ "type": "string", "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" }, + "duration": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, "external_service": { "type": "object", "properties": { @@ -821,6 +826,11 @@ "type": "string", "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" }, + "duration": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, "external_service": { "type": "object", "properties": { @@ -1267,6 +1277,11 @@ "type": "string", "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" }, + "duration": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, "external_service": { "type": "object", "properties": { @@ -1753,6 +1768,11 @@ "type": "string", "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" }, + "duration": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, "external_service": { "type": "object", "properties": { @@ -1994,6 +2014,7 @@ }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", @@ -2068,6 +2089,7 @@ }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index afad92f489a74..083aef3c25ad2 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -318,6 +318,13 @@ paths: advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active + duration: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 external_service: type: object properties: @@ -732,6 +739,13 @@ paths: advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active + duration: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 external_service: type: object properties: @@ -1117,6 +1131,13 @@ paths: advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active + duration: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 external_service: type: object properties: @@ -1533,6 +1554,13 @@ paths: advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active + duration: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 external_service: type: object properties: @@ -1709,6 +1737,7 @@ components: James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active + duration: null closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' @@ -1774,6 +1803,7 @@ components: James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active! + duration: null closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml index f9f2ce3d61beb..bc5fa1f5bc049 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -17,6 +17,7 @@ value: }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml index a73191868c8ee..114669b893651 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -18,6 +18,7 @@ value: }, "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml index 780496f1591b4..6a2c3c3963c3c 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -42,6 +42,10 @@ created_by: description: type: string example: "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" +duration: + type: integer + description: The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null. + example: 120 external_service: type: object properties: diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx index dfc78c13b0f3c..679aea94935d8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx @@ -28,6 +28,10 @@ type Accordion = Pick & Pick; const getDetailsList = (data: CspFinding) => [ + { + title: TEXT.RULE_NAME, + description: data.rule.name, + }, { title: TEXT.EVALUATED_AT, description: moment(data['@timestamp']).format('MMMM D, YYYY @ HH:mm:ss.SSS'), @@ -36,10 +40,6 @@ const getDetailsList = (data: CspFinding) => [ title: TEXT.RESOURCE_NAME, description: data.resource.name, }, - { - title: TEXT.RULE_NAME, - description: data.rule.name, - }, { title: TEXT.FRAMEWORK_SOURCES, description: ( @@ -127,7 +127,7 @@ export const OverviewTab = ({ data }: { data: CspFinding }) => { {accordion.title} } - arrowDisplay="right" + arrowDisplay="left" initialIsOpen={accordion.initialIsOpen} > diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx index 7919b836a3a73..4ab801213a66d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx @@ -80,7 +80,7 @@ export const ResourceTab = ({ data }: { data: CspFinding }) => { {accordion.title} } - arrowDisplay="right" + arrowDisplay="left" initialIsOpen > sort: urlQuery.sort, }); + const findingsDistribution = getFindingsDistribution({ + total: findingsGroupByNone.data?.total, + passed: findingsCount.data?.passed, + failed: findingsCount.data?.failed, + pageIndex: urlQuery.pageIndex, + pageSize: urlQuery.pageSize, + currentPageSize: findingsGroupByNone.data?.page.length, + }); + return (
setQuery={setUrlQuery} query={urlQuery.query} filters={urlQuery.filters} - loading={findingsGroupByNone.isFetching} + loading={findingsCount.isFetching} /> - + {findingsDistribution && } ( /> ); + +const getFindingsDistribution = ({ + total, + passed, + failed, + currentPageSize, + pageIndex, + pageSize, +}: Record<'currentPageSize' | 'total' | 'passed' | 'failed', number | undefined> & + Record<'pageIndex' | 'pageSize', number>) => { + if (!number.is(total) || !number.is(passed) || !number.is(failed) || !number.is(currentPageSize)) + return; + + return { + total, + passed, + failed, + pageStart: pageIndex * pageSize + 1, + pageEnd: pageIndex * pageSize + currentPageSize, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts index 3a86ef3a3c037..e7f9655849be0 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts @@ -75,6 +75,7 @@ export const useLatestFindings = ({ index, query, sort, from, size }: UseFinding }) ), { + keepPreviousData: true, select: ({ rawResponse: { hits } }) => ({ page: hits.hits.map((hit) => hit._source!), total: number.is(hits.total) ? hits.total : 0, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx index 8300cdd503fee..3ed52499edbf1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx @@ -4,21 +4,27 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; -import { EuiEmptyPrompt, EuiBasicTable, CriteriaWithPagination, Pagination } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { + EuiEmptyPrompt, + EuiBasicTable, + CriteriaWithPagination, + Pagination, + EuiBasicTableColumn, + EuiTableActionsColumnType, +} from '@elastic/eui'; import { extractErrorMessage } from '../../../../../common/utils/helpers'; import * as TEXT from '../../translations'; import type { ResourceFindingsResult } from './use_resource_findings'; -import { getFindingsColumns } from '../../layout/findings_layout'; +import { getExpandColumn, getFindingsColumns } from '../../layout/findings_layout'; import type { CspFinding } from '../../types'; +import { FindingsRuleFlyout } from '../../findings_flyout/findings_flyout'; interface Props extends ResourceFindingsResult { pagination: Pagination; setTableOptions(options: CriteriaWithPagination): void; } -const columns = getFindingsColumns(); - const ResourceFindingsTableComponent = ({ error, data, @@ -26,18 +32,36 @@ const ResourceFindingsTableComponent = ({ pagination, setTableOptions, }: Props) => { + const [selectedFinding, setSelectedFinding] = useState(); + + const columns: [ + EuiTableActionsColumnType, + ...Array> + ] = useMemo( + () => [getExpandColumn({ onClick: setSelectedFinding }), ...getFindingsColumns()], + [] + ); + if (!loading && !data?.page.length) return {TEXT.NO_FINDINGS}} />; return ( - + <> + + {selectedFinding && ( + setSelectedFinding(undefined)} + /> + )} + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx index 0654f7d9f0999..c155b1cae7eda 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { css } from '@emotion/react'; import { EuiHealth, @@ -29,35 +29,25 @@ interface Props { const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a')); -export const FindingsDistributionBar = ({ failed, passed, total, pageEnd, pageStart }: Props) => { - const count = useMemo( - () => - total - ? { total, passed: passed / total, failed: failed / total } - : { total: 0, passed: 0, failed: 0 }, - [total, failed, passed] - ); - - return ( -
- - - -
- ); -}; +export const FindingsDistributionBar = (props: Props) => ( +
+ + + +
+); const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => ( - {!!total && } + - {!!total && } + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts index f48e630b489d4..a63a3fac32c8b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts @@ -48,6 +48,7 @@ export const useFindingsCounter = ({ index, query }: FindingsBaseEsQuery) => { }) ), { + keepPreviousData: true, onError: (err) => showErrorToast(toasts, err), select: (response) => Object.fromEntries( diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index 3a2bdc1c00faf..d74d7656ad58e 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -255,6 +255,7 @@ export const item: GetInfoResponse['item'] = { csp_rule_template: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 7bba58dcaac7b..1f4b9e85043a6 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const item: GetInfoResponse['item'] = { lens: [], ml_module: [], osquery_pack_asset: [], + osquery_saved_query: [], security_rule: [], csp_rule_template: [], tag: [], diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index dcc7092356a96..7b185960dcb7b 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -107,6 +107,7 @@ export const AGENT_API_ROUTES = { CHECKIN_PATTERN: `${API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${API_ROOT}/agents/{agentId}/acks`, ACTIONS_PATTERN: `${API_ROOT}/agents/{agentId}/actions`, + CANCEL_ACTIONS_PATTERN: `${API_ROOT}/agents/actions/{actionId}/cancel`, UNENROLL_PATTERN: `${API_ROOT}/agents/{agentId}/unenroll`, BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`, REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`, @@ -117,6 +118,7 @@ export const AGENT_API_ROUTES = { STATUS_PATTERN_DEPRECATED: `${API_ROOT}/agent-status`, UPGRADE_PATTERN: `${API_ROOT}/agents/{agentId}/upgrade`, BULK_UPGRADE_PATTERN: `${API_ROOT}/agents/bulk_upgrade`, + CURRENT_UPGRADES_PATTERN: `${API_ROOT}/agents/current_upgrades`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/fleet/common/services/get_max_version.test.ts b/x-pack/plugins/fleet/common/services/get_max_version.test.ts new file mode 100644 index 0000000000000..6b21c81c0a5fe --- /dev/null +++ b/x-pack/plugins/fleet/common/services/get_max_version.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMaxVersion } from './get_max_version'; + +describe('Fleet - getMaxVersion', () => { + it('returns the maximum version', () => { + const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.3.1']; + expect(getMaxVersion(versions)).toEqual('8.3.1'); + }); + + it('returns the maximum version when there are duplicates', () => { + const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.2.0', '7.15.1']; + expect(getMaxVersion(versions)).toEqual('8.3.0'); + }); + + it('returns the maximum version when there is a snapshot version', () => { + const versions = ['8.1.0', '8.2.0-SNAPSHOT', '7.16.0', '7.16.1']; + expect(getMaxVersion(versions)).toEqual('8.2.0-SNAPSHOT'); + }); + + it('returns the maximum version and prefers the major version to the snapshot', () => { + const versions = ['8.1.0', '8.2.0-SNAPSHOT', '8.2.0', '7.16.0', '7.16.1']; + expect(getMaxVersion(versions)).toEqual('8.2.0'); + }); + + it('when there is only a version returns it', () => { + const versions = ['8.1.0']; + expect(getMaxVersion(versions)).toEqual('8.1.0'); + }); + + it('returns an empty string when the passed array is empty', () => { + const versions: string[] = []; + expect(getMaxVersion(versions)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/fleet/common/services/get_max_version.ts b/x-pack/plugins/fleet/common/services/get_max_version.ts new file mode 100644 index 0000000000000..e34dec675999d --- /dev/null +++ b/x-pack/plugins/fleet/common/services/get_max_version.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 { uniq } from 'lodash'; +import semverGt from 'semver/functions/gt'; +import semverCoerce from 'semver/functions/coerce'; + +// Find max version from an array of string versions +export function getMaxVersion(versions: string[]) { + const uniqVersions: string[] = uniq(versions); + + if (uniqVersions.length === 1) { + const semverVersion = semverCoerce(uniqVersions[0])?.version; + return semverVersion ? semverVersion : ''; + } else if (uniqVersions.length > 1) { + const sorted = uniqVersions.sort((a, b) => (semverGt(a, b) ? 1 : -1)); + return sorted[sorted.length - 1]; + } + return ''; +} diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index edffbdabc6c4e..79a501140a184 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -36,6 +36,7 @@ describe('Fleet - packageToPackagePolicy', () => { security_rule: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts index 975d45fd01c64..3a4676a4f9a7f 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts @@ -241,6 +241,7 @@ describe('Fleet - validatePackagePolicy()', () => { ], }, ], + vars: {}, }; const invalidPackagePolicy: NewPackagePolicy = { @@ -332,6 +333,7 @@ describe('Fleet - validatePackagePolicy()', () => { ], }, ], + vars: {}, }; const noErrorsValidationResults = { @@ -370,6 +372,7 @@ describe('Fleet - validatePackagePolicy()', () => { vars: { 'var-name': null }, }, }, + vars: {}, }; it('returns no errors for valid package policy', () => { @@ -416,6 +419,7 @@ describe('Fleet - validatePackagePolicy()', () => { streams: { 'with-no-stream-vars-bar': {} }, }, }, + vars: {}, }); }); @@ -487,6 +491,7 @@ describe('Fleet - validatePackagePolicy()', () => { streams: { 'with-no-stream-vars-bar': {} }, }, }, + vars: {}, }); }); @@ -505,6 +510,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); expect( validatePackagePolicy( @@ -520,6 +526,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); }); @@ -538,6 +545,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); expect( validatePackagePolicy( @@ -553,6 +561,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); }); @@ -604,6 +613,7 @@ describe('Fleet - validatePackagePolicy()', () => { }, }, }, + vars: {}, }); }); @@ -729,6 +739,7 @@ describe('Fleet - validatePackagePolicy()', () => { }, }, }, + vars: {}, name: null, namespace: null, }); diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.ts index 4d54fda6e5df5..3d0e8bed2aafa 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.ts @@ -55,6 +55,7 @@ export const validatePackagePolicy = ( description: null, namespace: null, inputs: {}, + vars: {}, }; const namespaceValidation = isValidNamespace(packagePolicy.namespace); diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 5afa9045f2218..b3847ac8c6892 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -34,7 +34,8 @@ export type AgentActionType = | 'UNENROLL' | 'UPGRADE' | 'SETTINGS' - | 'POLICY_REASSIGN'; + | 'POLICY_REASSIGN' + | 'CANCEL'; export interface NewAgentAction { type: AgentActionType; @@ -92,6 +93,13 @@ export interface AgentSOAttributes extends AgentBase { packages?: string[]; } +export interface CurrentUpgrade { + actionId: string; + complete: boolean; + nbAgents: number; + nbAgentsAck: number; +} + // Generated from FleetServer schema.json /** diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 2359b979d0a17..c7951e86d7866 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -72,6 +72,7 @@ export enum KibanaAssetType { mlModule = 'ml_module', tag = 'tag', osqueryPackAsset = 'osquery_pack_asset', + osquerySavedQuery = 'osquery_saved_query', } /* @@ -89,6 +90,7 @@ export enum KibanaSavedObjectType { cloudSecurityPostureRuleTemplate = 'csp-rule-template', tag = 'tag', osqueryPackAsset = 'osquery-pack-asset', + osquerySavedQuery = 'osquery-saved-query', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index aa256db95634a..7a8b7b918c1e3 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Agent, AgentAction, NewAgentAction } from '../models'; +import type { Agent, AgentAction, CurrentUpgrade, NewAgentAction } from '../models'; import type { ListResult, ListWithKuery } from './common'; @@ -174,3 +174,7 @@ export interface IncomingDataList { export interface GetAgentIncomingDataResponse { items: IncomingDataList[]; } + +export interface GetCurrentUpgradesResponse { + items: CurrentUpgrade[]; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx index 7eb9c5cbd38bb..fe84d933c7495 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx @@ -8,12 +8,12 @@ import React from 'react'; import type { EuiStepProps } from '@elastic/eui'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { PLATFORM_TYPE } from '../../../hooks'; -import { useDefaultOutput, useKibanaVersion } from '../../../hooks'; +import { useStartServices, useDefaultOutput, useKibanaVersion } from '../../../hooks'; import { PlatformSelector } from '../..'; @@ -58,6 +58,7 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ fleetServerPolicyId?: string; deploymentMode: DeploymentMode; }> = ({ serviceToken, fleetServerHost, fleetServerPolicyId, deploymentMode }) => { + const { docLinks } = useStartServices(); const kibanaVersion = useKibanaVersion(); const { output } = useDefaultOutput(); @@ -84,7 +85,17 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ + + + ), + }} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx index a15692b718a32..64fa3ad96fed3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx @@ -82,11 +82,17 @@ describe('StepDefinePackagePolicy', () => { }; }); - const validationResults = { name: null, description: null, namespace: null, inputs: {} }; + const validationResults = { + name: null, + description: null, + namespace: null, + inputs: {}, + vars: {}, + }; let testRenderer: TestRenderer; let renderResult: ReturnType; - const render = () => + const render = ({ isUpdate } = { isUpdate: false }) => (renderResult = testRenderer.render( { updatePackagePolicy={mockUpdatePackagePolicy} validationResults={validationResults} submitAttempted={false} + isUpdate={isUpdate} /> )); @@ -199,4 +206,23 @@ describe('StepDefinePackagePolicy', () => { expect(renderResult.getByDisplayValue('apache-11')).toBeInTheDocument(); }); }); + + describe('update', () => { + describe('when package vars are introduced in a new package version', () => { + it('should display new package vars', () => { + render({ isUpdate: true }); + + waitFor(async () => { + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByText('Required var')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); + }); + + expect(renderResult.getByText('Advanced var')).toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index 7f67452e2f230..893c68236aa6e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -90,9 +90,28 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Update package policy's package and agent policy info useEffect(() => { - if (isUpdate || isLoadingPackagePolicies) { + if (isLoadingPackagePolicies) { return; } + + if (isUpdate) { + // If we're upgrading, we need to make sure we catch an addition of package-level + // vars when they were previously no package-level vars defined + if (!packagePolicy.vars && packageInfo.vars) { + updatePackagePolicy( + packageToPackagePolicy( + packageInfo, + agentPolicy?.id || '', + packagePolicy.output_id, + packagePolicy.namespace, + packagePolicy.name, + packagePolicy.description, + integrationToEnable + ) + ); + } + } + const pkg = packagePolicy.package; const currentPkgKey = pkg ? pkgKeyFromPackageInfo(pkg) : ''; const pkgKey = pkgKeyFromPackageInfo(packageInfo); @@ -211,6 +230,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ const { name: varName, type: varType } = varDef; if (!packagePolicy.vars || !packagePolicy.vars[varName]) return null; const value = packagePolicy.vars[varName].value; + return ( - {tag} + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + + {truncateTag(tag)} + + ) : ( + tag + )} ))}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx index 7650b0d942180..9e084b07e64d1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -9,10 +9,13 @@ import { EuiToolTip } from '@elastic/eui'; import { take } from 'lodash'; import React from 'react'; +import { truncateTag } from '../utils'; + interface Props { tags: string[]; } +// Number of tags displayed before "+ N more" is displayed const MAX_TAGS_TO_DISPLAY = 3; export const Tags: React.FunctionComponent = ({ tags }) => { @@ -22,12 +25,12 @@ export const Tags: React.FunctionComponent = ({ tags }) => { <> {tags.join(', ')}}> - {take(tags, 3).join(', ')} + {tags.length - MAX_TAGS_TO_DISPLAY} more + {take(tags, 3).map(truncateTag).join(', ')} + {tags.length - MAX_TAGS_TO_DISPLAY} more ) : ( - {tags.join(', ')} + {tags.map(truncateTag).join(', ')} )} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 660a06911a5f0..be38f7688c735 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -144,6 +144,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { } if (selectedTags.length) { + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and`; + } kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags .map((tag) => `"${tag}"`) .join(' or ')})`; @@ -338,6 +341,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', { defaultMessage: 'Host', }), + width: '185px', render: (host: string, agent: Agent) => ( {safeMetadata(host)} @@ -346,7 +350,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'active', - width: '120px', + width: '85px', name: i18n.translate('xpack.fleet.agentList.statusColumnTitle', { defaultMessage: 'Status', }), @@ -354,7 +358,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'tags', - width: '240px', + width: '210px', name: i18n.translate('xpack.fleet.agentList.tagsColumnTitle', { defaultMessage: 'Tags', }), @@ -365,12 +369,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', { defaultMessage: 'Agent policy', }), + width: '260px', render: (policyId: string, agent: Agent) => { const agentPolicy = agentPoliciesIndexedById[policyId]; const showWarning = agent.policy_revision && agentPolicy?.revision > agent.policy_revision; return ( - + {agentPolicy && } {showWarning && ( @@ -390,7 +395,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '200px', + width: '120px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), @@ -419,6 +424,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.fleet.agentList.lastCheckinTitle', { defaultMessage: 'Last activity', }), + width: '180px', render: (lastCheckin: string, agent: any) => lastCheckin ? : null, }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts new file mode 100644 index 0000000000000..a549209ac6005 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/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 * from './truncate_tag'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.ts new file mode 100644 index 0000000000000..57046a4b284b9 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Number of characters to display for each tag before truncation +export const MAX_TAG_DISPLAY_LENGTH = 20; + +export function truncateTag(tag: string) { + return tag.length > MAX_TAG_DISPLAY_LENGTH + ? `${tag.substring(0, MAX_TAG_DISPLAY_LENGTH)}...` + : tag; +} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index 713d026726926..f76a1f85772be 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -39,6 +39,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { ml_module: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx index 3af6002e014c1..1fe4b7b38434d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -62,9 +62,12 @@ export const AssetTitleMap: Record = { security_rule: i18n.translate('xpack.fleet.epm.assetTitles.securityRules', { defaultMessage: 'Security rules', }), - osquery_pack_asset: i18n.translate('xpack.fleet.epm.assetTitles.osqueryPackAsset', { + osquery_pack_asset: i18n.translate('xpack.fleet.epm.assetTitles.osqueryPackAssets', { defaultMessage: 'Osquery packs', }), + osquery_saved_query: i18n.translate('xpack.fleet.epm.assetTitles.osquerySavedQuery', { + defaultMessage: 'Osquery saved queries', + }), ml_module: i18n.translate('xpack.fleet.epm.assetTitles.mlModules', { defaultMessage: 'ML modules', }), @@ -102,6 +105,7 @@ export const AssetIcons: Record = { ml_module: 'mlApp', tag: 'tagApp', osquery_pack_asset: 'osqueryApp', + osquery_saved_query: 'osqueryApp', }; export const ServiceIcons: Record = { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/installation_message.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/installation_message.tsx index 81404df2eeabd..48531ef166714 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/installation_message.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/installation_message.tsx @@ -12,7 +12,7 @@ import semverMajor from 'semver/functions/major'; import semverMinor from 'semver/functions/minor'; import semverPatch from 'semver/functions/patch'; -import { useKibanaVersion } from '../../hooks'; +import { useKibanaVersion, useStartServices } from '../../hooks'; import type { K8sMode } from './types'; @@ -21,6 +21,7 @@ interface Props { } export const InstallationMessage: React.FunctionComponent = ({ isK8s }) => { + const { docLinks } = useStartServices(); const kibanaVersion = useKibanaVersion(); const kibanaVersionURLString = useMemo( () => @@ -54,11 +55,7 @@ export const InstallationMessage: React.FunctionComponent = ({ isK8s }) = ), installationLink: ( - + { if (error instanceof AgentNotFoundError) { return 404; } + if (error instanceof AgentActionNotFoundError) { + return 404; + } return 400; // Bad Request }; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index cb1de48ad1958..1d1892f620e93 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -34,6 +34,7 @@ export class PackageOutdatedError extends IngestManagerError {} export class AgentPolicyError extends IngestManagerError {} export class AgentPolicyNotFoundError extends IngestManagerError {} export class AgentNotFoundError extends IngestManagerError {} +export class AgentActionNotFoundError extends IngestManagerError {} export class AgentPolicyNameExistsError extends AgentPolicyError {} export class PackageUnsupportedMediaTypeError extends IngestManagerError {} export class PackageInvalidArchiveError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts index 85f7ea672ecb4..7e7edaae70012 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts @@ -86,6 +86,7 @@ describe('test actions handlers', () => { id: 'agent', }), createAgentAction: jest.fn().mockReturnValueOnce(agentAction), + cancelAgentAction: jest.fn(), } as jest.Mocked; const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 80a6eac2d81b0..36c1fd8401584 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -10,7 +10,10 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import type { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import type { + PostNewAgentActionRequestSchema, + PostCancelActionRequestSchema, +} from '../../types/rest_spec'; import type { ActionsService } from '../../services/agents'; import type { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; import { defaultIngestErrorHandler } from '../../errors'; @@ -46,3 +49,23 @@ export const postNewAgentActionHandlerBuilder = function ( } }; }; + +export const postCancelActionHandlerBuilder = function ( + actionsService: ActionsService +): RequestHandler, undefined, undefined> { + return async (context, request, response) => { + try { + const esClient = (await context.core).elasticsearch.client.asInternalUser; + + const action = await actionsService.cancelAgentAction(esClient, request.params.actionId); + + const body: PostNewAgentActionResponse = { + item: action, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } + }; +}; diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 535bb780abe57..b0191f07e1a2a 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -20,6 +20,7 @@ import { PostBulkAgentReassignRequestSchema, PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema, + PostCancelActionRequestSchema, } from '../../types'; import * as AgentService from '../../services/agents'; import type { FleetConfigType } from '../..'; @@ -35,9 +36,16 @@ import { postBulkAgentsReassignHandler, getAgentDataHandler, } from './handlers'; -import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + postNewAgentActionHandlerBuilder, + postCancelActionHandlerBuilder, +} from './actions_handlers'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; -import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; +import { + getCurrentUpgradesHandler, + postAgentUpgradeHandler, + postBulkAgentsUpgradeHandler, +} from './upgrade_handler'; export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => { // Get one @@ -96,6 +104,22 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT }, postNewAgentActionHandlerBuilder({ getAgent: AgentService.getAgentById, + cancelAgentAction: AgentService.cancelAgentAction, + createAgentAction: AgentService.createAgentAction, + }) + ); + + router.post( + { + path: AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN, + validate: PostCancelActionRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + postCancelActionHandlerBuilder({ + getAgent: AgentService.getAgentById, + cancelAgentAction: AgentService.cancelAgentAction, createAgentAction: AgentService.createAgentAction, }) ); @@ -177,6 +201,18 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT }, postBulkAgentsUpgradeHandler ); + // Current upgrades + router.get( + { + path: AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + validate: false, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getCurrentUpgradesHandler + ); + // Bulk reassign router.post( { diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index 0e5e1873109bc..546b6d54be488 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -7,15 +7,28 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; +import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; + import semverCoerce from 'semver/functions/coerce'; +import semverGt from 'semver/functions/gt'; -import type { PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse } from '../../../common/types'; +import type { + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, + GetCurrentUpgradesResponse, +} from '../../../common/types'; import type { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; +import { SO_SEARCH_LIMIT } from '../../../common'; import { isAgentUpgradeable } from '../../../common/services'; -import { getAgentById } from '../../services/agents'; +import { getAgentById, getAgentsByKuery } from '../../services/agents'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, AGENTS_PREFIX } from '../../constants'; + +import { getMaxVersion } from '../../../common/services/get_max_version'; + +import { packagePolicyService } from '../../services/package_policy'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -28,7 +41,7 @@ export const postAgentUpgradeHandler: RequestHandler< const { version, source_uri: sourceUri, force } = request.body; const kibanaVersion = appContextService.getKibanaVersion(); try { - checkVersionIsSame(version, kibanaVersion); + checkKibanaVersion(version, kibanaVersion); checkSourceUriAllowed(sourceUri); } catch (err) { return response.customError({ @@ -90,8 +103,9 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< } = request.body; const kibanaVersion = appContextService.getKibanaVersion(); try { - checkVersionIsSame(version, kibanaVersion); + checkKibanaVersion(version, kibanaVersion); checkSourceUriAllowed(sourceUri); + await checkFleetServerVersion(version, agents, soClient, esClient); } catch (err) { return response.customError({ statusCode: 400, @@ -125,17 +139,30 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< } }; -export const checkVersionIsSame = (version: string, kibanaVersion: string) => { +export const getCurrentUpgradesHandler: RequestHandler = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + + try { + const upgrades = await AgentService.getCurrentBulkUpgrades(esClient); + const body: GetCurrentUpgradesResponse = { items: upgrades }; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const checkKibanaVersion = (version: string, kibanaVersion: string) => { // get version number only in case "-SNAPSHOT" is in it const kibanaVersionNumber = semverCoerce(kibanaVersion)?.version; if (!kibanaVersionNumber) throw new Error(`kibanaVersion ${kibanaVersionNumber} is not valid`); const versionToUpgradeNumber = semverCoerce(version)?.version; if (!versionToUpgradeNumber) throw new Error(`version to upgrade ${versionToUpgradeNumber} is not valid`); - // temporarily only allow upgrading to the same version as the installed kibana version - if (kibanaVersionNumber !== versionToUpgradeNumber) + + if (semverGt(version, kibanaVersion)) throw new Error( - `cannot upgrade agent to ${versionToUpgradeNumber} because it is different than the installed kibana version ${kibanaVersionNumber}` + `cannot upgrade agent to ${versionToUpgradeNumber} because it is higher than the installed kibana version ${kibanaVersionNumber}` ); }; @@ -146,3 +173,67 @@ const checkSourceUriAllowed = (sourceUri?: string) => { ); } }; + +// Check the installed fleet server versions +// Allow upgrading if the agents to upgrade include fleet server agents +const checkFleetServerVersion = async ( + versionToUpgradeNumber: string, + agentsIds: string | string[], + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient +) => { + let packagePolicyData; + try { + packagePolicyData = await packagePolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: fleet_server`, + }); + } catch (error) { + throw new Error(error.message); + } + const agentPoliciesIds = packagePolicyData?.items.map((item) => item.policy_id); + + if (agentPoliciesIds.length === 0) { + return; + } + + let agentsResponse; + try { + agentsResponse = await getAgentsByKuery(esClient, { + showInactive: false, + perPage: SO_SEARCH_LIMIT, + kuery: `${AGENTS_PREFIX}.policy_id:${agentPoliciesIds.map((id) => `"${id}"`).join(' or ')}`, + }); + } catch (error) { + throw new Error(error.message); + } + + const { agents: fleetServerAgents } = agentsResponse; + + if (fleetServerAgents.length === 0) { + return; + } + const fleetServerIds = fleetServerAgents.map((agent) => agent.id); + + let hasFleetServerAgents: boolean; + if (Array.isArray(agentsIds)) { + hasFleetServerAgents = agentsIds.some((id) => fleetServerIds.includes(id)); + } else { + hasFleetServerAgents = fleetServerIds.includes(agentsIds); + } + if (hasFleetServerAgents) { + return; + } + + const fleetServerVersions = fleetServerAgents.map( + (agent) => agent.local_metadata.elastic.agent.version + ) as string[]; + + const maxFleetServerVersion = getMaxVersion(fleetServerVersions); + + if (semverGt(versionToUpgradeNumber, maxFleetServerVersion)) { + throw new Error( + `cannot upgrade agent to ${versionToUpgradeNumber} because it is higher than the latest fleet server version ${maxFleetServerVersion}` + ); + } +}; diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts new file mode 100644 index 0000000000000..2838f2204ad96 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +import { cancelAgentAction } from './actions'; + +describe('Agent actions', () => { + describe('cancelAgentAction', () => { + it('throw if the target action is not found', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [], + }, + } as any); + await expect(() => cancelAgentAction(esClient, 'i-do-not-exists')).rejects.toThrowError( + /Action not found/ + ); + }); + + it('should create one CANCEL action for each action found', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + { + _source: { + action_id: 'action1', + agents: ['agent3', 'agent4'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + ], + }, + } as any); + await cancelAgentAction(esClient, 'action1'); + + expect(esClient.create).toBeCalledTimes(2); + expect(esClient.create).toBeCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: 'CANCEL', + data: { target_id: 'action1' }, + agents: ['agent1', 'agent2'], + }), + }) + ); + expect(esClient.create).toBeCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: 'CANCEL', + data: { target_id: 'action1' }, + agents: ['agent3', 'agent4'], + }), + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 6b3752dd88d04..afa65bfe91fb3 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -14,7 +14,8 @@ import type { NewAgentAction, FleetServerAgentAction, } from '../../../common/types/models'; -import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; +import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants'; +import { AgentActionNotFoundError } from '../../errors'; const ONE_MONTH_IN_MS = 2592000000; @@ -22,13 +23,13 @@ export async function createAgentAction( esClient: ElasticsearchClient, newAgentAction: NewAgentAction ): Promise { - const id = newAgentAction.id ?? uuid.v4(); + const actionId = newAgentAction.id ?? uuid.v4(); const timestamp = new Date().toISOString(); const body: FleetServerAgentAction = { '@timestamp': timestamp, expiration: newAgentAction.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), agents: newAgentAction.agents, - action_id: id, + action_id: actionId, data: newAgentAction.data, type: newAgentAction.type, start_time: newAgentAction.start_time, @@ -37,13 +38,13 @@ export async function createAgentAction( await esClient.create({ index: AGENT_ACTIONS_INDEX, - id, + id: uuid.v4(), body, refresh: 'wait_for', }); return { - id, + id: actionId, ...newAgentAction, created_at: timestamp, }; @@ -93,9 +94,57 @@ export async function bulkCreateAgentActions( return actions; } +export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + action_id: actionId, + }, + }, + ], + }, + }, + size: SO_SEARCH_LIMIT, + }); + + if (res.hits.hits.length === 0) { + throw new AgentActionNotFoundError('Action not found'); + } + + const cancelActionId = uuid.v4(); + const now = new Date().toISOString(); + for (const hit of res.hits.hits) { + if (!hit._source || !hit._source.agents || !hit._source.action_id) { + continue; + } + await createAgentAction(esClient, { + id: cancelActionId, + type: 'CANCEL', + agents: hit._source.agents, + data: { + target_id: hit._source.action_id, + }, + created_at: now, + expiration: hit._source.expiration, + }); + } + + return { + created_at: now, + id: cancelActionId, + type: 'CANCEL', + } as AgentAction; +} + export interface ActionsService { getAgent: (esClient: ElasticsearchClient, agentId: string) => Promise; + cancelAgentAction: (esClient: ElasticsearchClient, actionId: string) => Promise; + createAgentAction: ( esClient: ElasticsearchClient, newAgentAction: Omit diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index f1bd60d1eba94..55c105495fd54 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -7,8 +7,9 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import moment from 'moment'; +import pMap from 'p-map'; -import type { Agent, BulkActionResult } from '../../types'; +import type { Agent, BulkActionResult, FleetServerAgentAction, CurrentUpgrade } from '../../types'; import { agentPolicyService } from '..'; import { AgentReassignmentError, @@ -17,6 +18,7 @@ import { } from '../../errors'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common'; import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; @@ -207,3 +209,134 @@ export async function sendUpgradeAgentsActions( return { items: orderedOut }; } + +/** + * Return current bulk upgrades (non completed or cancelled) + */ +export async function getCurrentBulkUpgrades( + esClient: ElasticsearchClient, + now = new Date().toISOString() +): Promise { + // Fetch all non expired actions + const [_upgradeActions, cancelledActionIds] = await Promise.all([ + _getUpgradeActions(esClient, now), + _getCancelledActionId(esClient, now), + ]); + + let upgradeActions = _upgradeActions.filter( + (action) => cancelledActionIds.indexOf(action.actionId) < 0 + ); + + // Fetch acknowledged result for every upgrade action + upgradeActions = await pMap( + upgradeActions, + async (upgradeAction) => { + const { count } = await esClient.count({ + index: AGENT_ACTIONS_RESULTS_INDEX, + ignore_unavailable: true, + query: { + bool: { + must: [ + { + term: { + action_id: upgradeAction.actionId, + }, + }, + ], + }, + }, + }); + + return { + ...upgradeAction, + nbAgentsAck: count, + complete: upgradeAction.nbAgents <= count, + }; + }, + { concurrency: 20 } + ); + + upgradeActions = upgradeActions.filter((action) => !action.complete); + + return upgradeActions; +} + +async function _getCancelledActionId( + esClient: ElasticsearchClient, + now = new Date().toISOString() +) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + type: 'CANCEL', + }, + }, + { + exists: { + field: 'agents', + }, + }, + { + range: { + expiration: { gte: now }, + }, + }, + ], + }, + }, + }); + + return res.hits.hits.map((hit) => hit._source?.data?.target_id as string); +} + +async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date().toISOString()) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + type: 'UPGRADE', + }, + }, + { + exists: { + field: 'agents', + }, + }, + { + range: { + expiration: { gte: now }, + }, + }, + ], + }, + }, + }); + + return Object.values( + res.hits.hits.reduce((acc, hit) => { + if (!hit._source || !hit._source.action_id) { + return acc; + } + + if (!acc[hit._source.action_id]) { + acc[hit._source.action_id] = { + actionId: hit._source.action_id, + nbAgents: 0, + complete: false, + nbAgentsAck: 0, + }; + } + + acc[hit._source.action_id].nbAgents += hit._source.agents?.length ?? 0; + + return acc; + }, {} as { [k: string]: CurrentUpgrade }) + ); +} diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index b9582ce1cf148..110fb4535ef92 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -58,6 +58,7 @@ const KibanaSavedObjectTypeMapping: Record ArchiveAsset[]> = { diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 120520db780a5..6bc56e8316da6 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -70,6 +70,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { ml_module: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { component_template: [], @@ -184,6 +185,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { ml_module: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { component_template: [], @@ -278,6 +280,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { ml_module: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { component_template: [], @@ -404,6 +407,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { ml_module: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 37dde581d4b8f..10a00393f8075 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,6 +12,7 @@ export type { AgentStatus, AgentType, AgentAction, + CurrentUpgrade, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 3f92b278d32b5..e080fe66f7e2c 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -34,6 +34,12 @@ export const PostNewAgentActionRequestSchema = { }), }; +export const PostCancelActionRequestSchema = { + params: schema.object({ + actionId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { params: schema.object({ agentId: schema.string(), diff --git a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts new file mode 100644 index 0000000000000..97c7c2cfaac90 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Datatable, ExecutionContext } from '@kbn/expressions-plugin/common'; +import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; +import { CollapseArgs, collapse } from '.'; + +describe('collapse_fn', () => { + const fn = functionWrapper(collapse); + const runFn = (input: Datatable, args: CollapseArgs) => + fn(input, args, {} as ExecutionContext) as Promise; + + it('collapses all rows', async () => { + const result = await runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A' }, + { val: 2, split: 'B' }, + { val: 3, split: 'B' }, + { val: 4, split: 'A' }, + { val: 5, split: 'A' }, + { val: 6, split: 'A' }, + { val: 7, split: 'B' }, + { val: 8, split: 'B' }, + ], + }, + { metric: ['val'], fn: 'sum' } + ); + + expect(result.rows).toEqual([{ val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 }]); + }); + + const twoSplitTable: Datatable = { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + { id: 'split2', name: 'split2', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, split: 'A', split2: 'C' }, + { val: 2, split: 'B', split2: 'C' }, + { val: 3, split2: 'C' }, + { val: 4, split: 'A', split2: 'C' }, + { val: 5 }, + { val: 6, split: 'A', split2: 'D' }, + { val: 7, split: 'B', split2: 'D' }, + { val: 8, split: 'B', split2: 'D' }, + ], + }; + + it('splits by a column', async () => { + const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'sum' }); + expect(result.rows).toEqual([ + { val: 1 + 4 + 6, split: 'A' }, + { val: 2 + 7 + 8, split: 'B' }, + { val: 3 + 5, split: undefined }, + ]); + }); + + it('applies avg', async () => { + const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'avg' }); + expect(result.rows).toEqual([ + { val: (1 + 4 + 6) / 3, split: 'A' }, + { val: (2 + 7 + 8) / 3, split: 'B' }, + { val: (3 + 5) / 2, split: undefined }, + ]); + }); + + it('applies min', async () => { + const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'min' }); + expect(result.rows).toEqual([ + { val: 1, split: 'A' }, + { val: 2, split: 'B' }, + { val: 3, split: undefined }, + ]); + }); + + it('applies max', async () => { + const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'max' }); + expect(result.rows).toEqual([ + { val: 6, split: 'A' }, + { val: 8, split: 'B' }, + { val: 5, split: undefined }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts new file mode 100644 index 0000000000000..b05942ed667a9 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Datatable, DatatableRow, getBucketIdentifier } from '@kbn/expressions-plugin/common'; +import type { CollapseExpressionFunction } from './types'; + +function getValueAsNumberArray(value: unknown) { + if (Array.isArray(value)) { + return value.map((innerVal) => Number(innerVal)); + } else { + return [Number(value)]; + } +} + +export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric, fn }) => { + const accumulators: Record>> = {}; + const valueCounter: Record>> = {}; + metric?.forEach((m) => { + accumulators[m] = {}; + valueCounter[m] = {}; + }); + const setMarker: Partial> = {}; + input.rows.forEach((row) => { + const bucketIdentifier = getBucketIdentifier(row, by); + + metric?.forEach((m) => { + const accumulatorValue = accumulators[m][bucketIdentifier]; + const currentValue = row[m]; + if (currentValue != null) { + const currentNumberValues = getValueAsNumberArray(currentValue); + switch (fn) { + case 'avg': + valueCounter[m][bucketIdentifier] = + (valueCounter[m][bucketIdentifier] ?? 0) + currentNumberValues.length; + case 'sum': + accumulators[m][bucketIdentifier] = currentNumberValues.reduce( + (a, b) => a + b, + accumulatorValue || 0 + ); + break; + case 'min': + if (typeof accumulatorValue !== 'undefined') { + accumulators[m][bucketIdentifier] = Math.min( + accumulatorValue, + ...currentNumberValues + ); + } else { + accumulators[m][bucketIdentifier] = Math.min(...currentNumberValues); + } + break; + case 'max': + if (typeof accumulatorValue !== 'undefined') { + accumulators[m][bucketIdentifier] = Math.max( + accumulatorValue, + ...currentNumberValues + ); + } else { + accumulators[m][bucketIdentifier] = Math.max(...currentNumberValues); + } + break; + } + } + }); + }); + if (fn === 'avg') { + metric?.forEach((m) => { + Object.keys(accumulators[m]).forEach((bucketIdentifier) => { + const accumulatorValue = accumulators[m][bucketIdentifier]; + const valueCount = valueCounter[m][bucketIdentifier]; + if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') { + accumulators[m][bucketIdentifier] = accumulatorValue / valueCount; + } + }); + }); + } + + return { + ...input, + columns: input.columns.filter((c) => by?.indexOf(c.id) !== -1 || metric?.indexOf(c.id) !== -1), + rows: input.rows + .map((row) => { + const bucketIdentifier = getBucketIdentifier(row, by); + if (setMarker[bucketIdentifier]) return undefined; + setMarker[bucketIdentifier] = true; + const newRow: Datatable['rows'][number] = {}; + metric?.forEach((m) => { + newRow[m] = accumulators[m][bucketIdentifier]; + }); + by?.forEach((b) => { + newRow[b] = row[b]; + }); + + return newRow; + }) + .filter(Boolean) as DatatableRow[], + }; +}; diff --git a/x-pack/plugins/lens/common/expressions/collapse/index.ts b/x-pack/plugins/lens/common/expressions/collapse/index.ts new file mode 100644 index 0000000000000..06d2ccbc0fbac --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/collapse/index.ts @@ -0,0 +1,68 @@ +/* + * 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 type { CollapseExpressionFunction } from './types'; + +export interface CollapseArgs { + by?: string[]; + metric?: string[]; + fn: 'sum' | 'avg' | 'min' | 'max'; +} + +/** + * Collapses multiple rows into a single row using the specified function. + * + * The `by` argument specifies the columns to group by - these columns are not collapsed. + * The `metric` arguments specifies the collumns to apply the aggregate function to. + * + * All other columns are removed. + */ +export const collapse: CollapseExpressionFunction = { + name: 'lens_collapse', + type: 'datatable', + + inputTypes: ['datatable'], + + help: i18n.translate('xpack.lens.functions.collapse.help', { + defaultMessage: + 'Collapses multiple rows into a single row using the specified aggregate function.', + }), + + args: { + by: { + help: i18n.translate('xpack.lens.functions.collapse.args.byHelpText', { + defaultMessage: 'Columns to group by - these columns are kept as-is', + }), + multi: true, + types: ['string'], + required: false, + }, + metric: { + help: i18n.translate('xpack.lens.functions.collapse.args.metricHelpText', { + defaultMessage: 'Column to calculate the specified aggregate function of', + }), + types: ['string'], + multi: true, + required: false, + }, + fn: { + help: i18n.translate('xpack.lens.functions.collapse.args.fnHelpText', { + defaultMessage: 'The aggregate function to apply', + }), + types: ['string'], + required: true, + }, + }, + + async fn(...args) { + /** Build optimization: prevent adding extra code into initial bundle **/ + const { collapseFn } = await import('./collapse_fn'); + return collapseFn(...args); + }, +}; diff --git a/x-pack/plugins/lens/common/expressions/collapse/types.ts b/x-pack/plugins/lens/common/expressions/collapse/types.ts new file mode 100644 index 0000000000000..d08736bcfba81 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/collapse/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; +import { CollapseArgs } from '.'; + +export type CollapseExpressionFunction = ExpressionFunctionDefinition< + 'lens_collapse', + Datatable, + CollapseArgs, + Datatable | Promise +>; diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts index 46e483f263b9b..3d193876b5f38 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts @@ -42,6 +42,7 @@ export interface ColumnState { colorMode?: 'none' | 'cell' | 'text'; summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max'; summaryLabel?: string; + collapseFn?: string; } export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' }; diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts index 464c70a412397..a886c42a030fa 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts @@ -47,6 +47,7 @@ export const datatableFn = let untransposedData: Datatable | undefined; // do the sorting at this level to propagate it also at CSV download const [layerId] = Object.keys(context.inspectorAdapters.tables || {}); + const formatters: Record> = {}; const formatFactory = await getFormatFactory(context); diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index 2007a61b11bf9..924141da6074a 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -6,6 +6,7 @@ */ export * from './counter_rate'; +export * from './collapse'; export * from './format_column'; export * from './rename_columns'; export * from './time_scale'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 11fc3fc1e8a6c..7d96db4027bad 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -40,6 +40,7 @@ import { } from '../../../common/expressions'; import './dimension_editor.scss'; +import { CollapseSetting } from '../../shared_components/collapse_setting'; const idPrefix = htmlIdGenerator()(); @@ -128,6 +129,17 @@ export function TableDimensionEditor( return ( <> + {props.groupId === 'rows' && ( + { + setState({ + ...state, + columns: updateColumnWith(state, accessor, { collapseFn }), + }); + }} + /> + )} { describe('#toExpression', () => { const getDatatableExpressionArgs = (state: DatatableVisualizationState) => buildExpression( - datatableVisualization.toExpression(state, frame.datasourceLayers) as Ast + datatableVisualization.toExpression( + state, + frame.datasourceLayers, + + {}, + { '1': { type: 'expression', chain: [] } } + ) as Ast ).findFunction('lens_datatable')[0].arguments; const defaultExpressionTableState = { @@ -524,7 +530,9 @@ describe('Datatable Visualization', () => { const expression = datatableVisualization.toExpression( defaultExpressionTableState, - frame.datasourceLayers + frame.datasourceLayers, + {}, + { '1': { type: 'expression', chain: [] } } ) as Ast; const tableArgs = buildExpression(expression).findFunction('lens_datatable'); @@ -573,7 +581,9 @@ describe('Datatable Visualization', () => { const expression = datatableVisualization.toExpression( defaultExpressionTableState, - frame.datasourceLayers + frame.datasourceLayers, + {}, + { '1': { type: 'expression', chain: [] } } ); expect(expression).toEqual(null); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 4cc44a1b70293..d42af9aa3932c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from 'react-dom'; -import { Ast } from '@kbn/interpreter'; +import { Ast, AstFunction } from '@kbn/interpreter'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { PaletteRegistry, CUSTOM_PALETTE } from '@kbn/coloring'; @@ -203,7 +203,10 @@ export const getDatatableVisualization = ({ ) .map((accessor) => ({ columnId: accessor, - triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, + triggerIcon: + columnMap[accessor].hidden || columnMap[accessor].collapseFn + ? 'invisible' + : undefined, })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, @@ -342,6 +345,10 @@ export const getDatatableVisualization = ({ return null; } + if (!datasourceExpressionsByLayers || Object.keys(datasourceExpressionsByLayers).length === 0) { + return null; + } + const columnMap: Record = {}; state.columns.forEach((column) => { columnMap[column.columnId] = column; @@ -357,58 +364,84 @@ export const getDatatableVisualization = ({ type: 'expression', chain: [ ...(datasourceExpression?.chain ?? []), + ...columns + .filter((c) => c.collapseFn) + .map((c) => { + return { + type: 'function', + function: 'lens_collapse', + arguments: { + by: columns + .filter( + (col) => + col.columnId !== c.columnId && + datasource!.getOperationForColumnId(col.columnId)?.isBucketed + ) + .map((col) => col.columnId), + metric: columns + .filter((col) => !datasource!.getOperationForColumnId(col.columnId)?.isBucketed) + .map((col) => col.columnId), + fn: [c.collapseFn!], + }, + } as AstFunction; + }), { type: 'function', function: 'lens_datatable', arguments: { title: [title || ''], description: [description || ''], - columns: columns.map((column) => { - const paletteParams = { - ...column.palette?.params, - // rewrite colors and stops as two distinct arguments - colors: (column.palette?.params?.stops || []).map(({ color }) => color), - stops: - column.palette?.params?.name === 'custom' - ? (column.palette?.params?.stops || []).map(({ stop }) => stop) - : [], - reverse: false, // managed at UI level - }; - const sortingHint = datasource!.getOperationForColumnId(column.columnId)!.sortingHint; - - const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none'; - - const canColor = - datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number'; - - return { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_column', - arguments: { - columnId: [column.columnId], - hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], - width: typeof column.width === 'undefined' ? [] : [column.width], - isTransposed: - typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], - transposable: [ - !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, - ], - alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], - colorMode: [canColor && column.colorMode ? column.colorMode : 'none'], - palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)], - summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!], - summaryLabel: hasNoSummaryRow - ? [] - : [column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!)], - sortingHint: sortingHint ? [sortingHint] : [], + columns: columns + .filter((c) => !c.collapseFn) + .map((column) => { + const paletteParams = { + ...column.palette?.params, + // rewrite colors and stops as two distinct arguments + colors: (column.palette?.params?.stops || []).map(({ color }) => color), + stops: + column.palette?.params?.name === 'custom' + ? (column.palette?.params?.stops || []).map(({ stop }) => stop) + : [], + reverse: false, // managed at UI level + }; + const sortingHint = datasource!.getOperationForColumnId( + column.columnId + )!.sortingHint; + + const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none'; + + const canColor = + datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number'; + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column', + arguments: { + columnId: [column.columnId], + hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], + width: typeof column.width === 'undefined' ? [] : [column.width], + isTransposed: + typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], + transposable: [ + !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, + ], + alignment: + typeof column.alignment === 'undefined' ? [] : [column.alignment], + colorMode: [canColor && column.colorMode ? column.colorMode : 'none'], + palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)], + summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!], + summaryLabel: hasNoSummaryRow + ? [] + : [column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!)], + sortingHint: sortingHint ? [sortingHint] : [], + }, }, - }, - ], - }; - }), + ], + }; + }), sortingColumnId: [state.sorting?.columnId || ''], sortingDirection: [state.sorting?.direction || 'none'], fitRowToContent: [state.rowHeight === 'auto'], diff --git a/x-pack/plugins/lens/public/expressions.ts b/x-pack/plugins/lens/public/expressions.ts index a52d835e3f002..be0bcaf324c54 100644 --- a/x-pack/plugins/lens/public/expressions.ts +++ b/x-pack/plugins/lens/public/expressions.ts @@ -12,6 +12,7 @@ import { renameColumns } from '../common/expressions/rename_columns/rename_colum import { formatColumn } from '../common/expressions/format_column'; import { counterRate } from '../common/expressions/counter_rate'; import { getTimeScale } from '../common/expressions/time_scale/time_scale'; +import { collapse } from '../common/expressions'; export const setupExpressions = ( expressions: ExpressionsSetup, @@ -19,6 +20,7 @@ export const setupExpressions = ( getTimeZone: Parameters[0] ) => { [ + collapse, counterRate, formatColumn, renameColumns, diff --git a/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx new file mode 100644 index 0000000000000..48a995267163c --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +const options = [ + { text: i18n.translate('xpack.lens.collapse.none', { defaultMessage: 'None' }), value: '' }, + { text: i18n.translate('xpack.lens.collapse.sum', { defaultMessage: 'Sum' }), value: 'sum' }, + { text: i18n.translate('xpack.lens.collapse.min', { defaultMessage: 'Min' }), value: 'min' }, + { text: i18n.translate('xpack.lens.collapse.max', { defaultMessage: 'Max' }), value: 'max' }, + { text: i18n.translate('xpack.lens.collapse.avg', { defaultMessage: 'Average' }), value: 'avg' }, +]; + +export function CollapseSetting({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( + + ) => { + onChange(e.target.value); + }} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index afdfd8e200100..46d7f535f090a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -145,9 +145,6 @@ Object { "linear", ], "yConfig": Array [], - "yScaleType": Array [ - "linear", - ], }, "function": "extendedDataLayer", "type": "function", @@ -234,6 +231,9 @@ Object { "type": "expression", }, ], + "yLeftScale": Array [ + "linear", + ], "yRightExtent": Array [ Object { "chain": Array [ @@ -256,6 +256,9 @@ Object { "type": "expression", }, ], + "yRightScale": Array [ + "linear", + ], "yRightTitle": Array [ "", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts index 73b355bce7ed2..0b14b54f27074 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts @@ -228,7 +228,6 @@ describe('axes_configuration', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: { type: 'palette', name: 'default' }, }; diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index e927266a8385c..3ac89935455c7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -48,7 +48,7 @@ export function getColorAssignments( return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { - if (!layer.splitAccessor) { + if (layer.collapseFn || !layer.splitAccessor) { return { numberOfSeries: layer.accessors.length, splits: [] }; } const splitAccessor = layer.splitAccessor; @@ -108,7 +108,7 @@ export function getAccessorColorConfig( if (isAnnotationsLayer(layer)) { return getAnnotationsAccessorColorConfig(layer); } - const layerContainsSplits = Boolean(layer.splitAccessor); + const layerContainsSplits = !layer.collapseFn && layer.splitAccessor; const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; return layer.accessors.map((accessor) => { diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts index 368b213428ed7..d83847cb54ca4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.test.ts @@ -278,7 +278,6 @@ describe('reference_line helpers', () => { xAccessor: 'a', accessors: [], type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: false, palette: { type: 'palette', name: 'palette1' }, @@ -306,7 +305,6 @@ describe('reference_line helpers', () => { xAccessor: 'a', accessors: [], type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: false, palette: { type: 'palette', name: 'palette1' }, diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index cf9a441fc6f53..cb6e6cff2d70e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Ast } from '@kbn/interpreter'; +import { Ast, AstFunction } from '@kbn/interpreter'; import { ScaleType } from '@elastic/charts'; import type { PaletteRegistry } from '@kbn/coloring'; @@ -250,6 +250,8 @@ export const buildExpression = ( fillOpacity: [state.fillOpacity || 0.3], yLeftExtent: [axisExtentConfigToExpression(state.yLeftExtent, validDataLayers)], yRightExtent: [axisExtentConfigToExpression(state.yRightExtent, validDataLayers)], + yLeftScale: [state.yLeftScale || 'linear'], + yRightScale: [state.yRightScale || 'linear'], axisTitlesVisibilitySettings: [ { type: 'expression', @@ -421,19 +423,40 @@ const dataLayerToExpression = ( layerId: [layer.layerId], hide: [Boolean(layer.hide)], xAccessor: layer.xAccessor ? [layer.xAccessor] : [], - yScaleType: [ - getScaleType(metadata[layer.layerId][layer.accessors[0]], ScaleType.Ordinal), - ], xScaleType: [getScaleType(metadata[layer.layerId][layer.xAccessor], ScaleType.Linear)], isHistogram: [isHistogramDimension], - splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], + splitAccessor: layer.collapseFn || !layer.splitAccessor ? [] : [layer.splitAccessor], yConfig: layer.yConfig ? layer.yConfig.map((yConfig) => yConfigToExpression(yConfig)) : [], seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], - ...(datasourceExpression ? { table: [datasourceExpression] } : {}), + ...(datasourceExpression + ? { + table: [ + { + ...datasourceExpression, + chain: [ + ...datasourceExpression.chain, + ...(layer.collapseFn + ? [ + { + type: 'function', + function: 'lens_collapse', + arguments: { + by: layer.xAccessor ? [layer.xAccessor] : [], + metric: layer.accessors, + fn: [layer.collapseFn!], + }, + } as AstFunction, + ] + : []), + ], + }, + ], + } + : {}), palette: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index b96ddf1aaee2d..d4a83c6f561a3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -46,7 +46,7 @@ export interface XYDataLayerConfig { yConfig?: YConfig[]; splitAccessor?: string; palette?: PaletteOutput; - yScaleType?: YScaleType; + collapseFn?: string; xScaleType?: XScaleType; isHistogram?: boolean; columnToLabel?: string; @@ -92,6 +92,8 @@ export interface XYState { xTitle?: string; yTitle?: string; yRightTitle?: string; + yLeftScale?: YScaleType; + yRightScale?: YScaleType; axisTitlesVisibilitySettings?: AxesSettingsConfig; tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 6c3fe3dcaea7f..096c395b31eaf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -276,10 +276,12 @@ export const getXyVisualization = ({ ? [ { columnId: dataLayer.splitAccessor, - triggerIcon: 'colorBy' as const, - palette: paletteService - .get(dataLayer.palette?.name || 'default') - .getCategoricalColors(10, dataLayer.palette?.params), + triggerIcon: dataLayer.collapseFn ? ('invisible' as const) : ('colorBy' as const), + palette: dataLayer.collapseFn + ? undefined + : paletteService + .get(dataLayer.palette?.name || 'default') + .getCategoricalColors(10, dataLayer.palette?.params), }, ] : [], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx index 28faaf6a9e0b4..2f824cddcd9fc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx @@ -14,10 +14,11 @@ import { htmlIdGenerator, EuiFieldNumber, EuiFormControlLayoutDelimited, + EuiSelect, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; -import { AxesSettingsConfig, AxisExtentConfig } from '@kbn/expression-xy-plugin/common'; +import { AxesSettingsConfig, AxisExtentConfig, YScaleType } from '@kbn/expression-xy-plugin/common'; import { ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; import { XYLayerConfig } from '../types'; import { ToolbarPopover, useDebouncedValue, AxisTitleSettings } from '../../shared_components'; @@ -93,6 +94,14 @@ export interface AxisSettingsPopoverProps { * Flag whether endzones are visible */ endzonesVisible?: boolean; + /** + * Set scale + */ + setScale?: (scale: YScaleType) => void; + /** + * Current scale + */ + scale?: YScaleType; /** * axis extent */ @@ -216,6 +225,8 @@ export const AxisSettingsPopover: React.FunctionComponent { const isHorizontal = layers?.length ? isHorizontalChart(layers) : false; const config = popoverConfig(axis, isHorizontal); @@ -351,6 +362,46 @@ export const AxisSettingsPopover: React.FunctionComponent )} + {setScale && ( + + setScale(e.target.value as YScaleType)} + value={scale} + /> + + )} {localExtent && setExtent && ( = T extends Array ? P : T; @@ -90,6 +91,12 @@ export function DimensionEditor( if (props.groupId === 'breakdown') { return ( <> + { + setLocalState(updateLayer(localState, { ...layer, collapseFn }, index)); + }} + /> - + { + setState({ + ...state, + yLeftScale: scale, + }); + }} /> @@ -516,6 +523,13 @@ export const XyToolbar = memo(function XyToolbar( setExtent={setRightExtent} hasBarOrAreaOnAxis={hasBarOrAreaOnRightAxis} dataBounds={dataBounds.right} + scale={state?.yRightScale} + setScale={(scale) => { + setState({ + ...state, + yRightScale: scale, + }); + }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 912d5adf53c8f..51e1c5fe2a954 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -551,6 +551,8 @@ function buildSuggestion({ valuesInLegend: currentState?.valuesInLegend, yLeftExtent: currentState?.yLeftExtent, yRightExtent: currentState?.yRightExtent, + yLeftScale: currentState?.yLeftScale, + yRightScale: currentState?.yRightScale, axisTitlesVisibilitySettings: currentState?.axisTitlesVisibilitySettings || { x: true, yLeft: true, diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap index 513e3d4148efd..f8c5951e95e04 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap @@ -11,6 +11,7 @@ exports[`scaling form should disable clusters option when clustering is not supp id="xpack.maps.esSearch.scaleTitle" values={Object {}} /> + + { {this._renderModal()}
- + {' '}
- - - + + +
- - - + + +
- - - + {' '} +
diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx new file mode 100644 index 0000000000000..1799b7264611d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx @@ -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 React, { Component } from 'react'; +import { EuiButtonIcon, EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { getDocLinks } from '../../../../kibana_services'; + +interface State { + isPopoverOpen: boolean; +} + +export class JoinDocumentationPopover extends Component<{}, State> { + state: State = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState((prevState) => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _renderContent() { + return ( +
+ +

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

+ +

+ + + +
+
+ ); + } + + render() { + return ( + + } + isOpen={this.state.isPopoverOpen} + closePopover={this._closePopover} + repositionOnScroll + ownFocus + > + + + + {this._renderContent()} + + ); + } +} diff --git a/x-pack/plugins/ml/common/constants/trained_models.ts b/x-pack/plugins/ml/common/constants/trained_models.ts index 75ea25dcc9efa..ad9093fcd8ab4 100644 --- a/x-pack/plugins/ml/common/constants/trained_models.ts +++ b/x-pack/plugins/ml/common/constants/trained_models.ts @@ -22,6 +22,7 @@ export type TrainedModelType = typeof TRAINED_MODEL_TYPE[keyof typeof TRAINED_MO export const SUPPORTED_PYTORCH_TASKS = { NER: 'ner', + QUESTION_ANSWERING: 'question_answering', ZERO_SHOT_CLASSIFICATION: 'zero_shot_classification', TEXT_CLASSIFICATION: 'text_classification', TEXT_EMBEDDING: 'text_embedding', diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts index c6cde8da39469..9e4ffeda26354 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts @@ -6,6 +6,7 @@ */ import { NerInference } from './ner'; +import { QuestionAnsweringInference } from './question_answering'; import { TextClassificationInference, ZeroShotClassificationInference, @@ -16,6 +17,7 @@ import { TextEmbeddingInference } from './text_embedding'; export type InferrerType = | NerInference + | QuestionAnsweringInference | TextClassificationInference | TextEmbeddingInference | ZeroShotClassificationInference diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/index.ts new file mode 100644 index 0000000000000..49b897aa450ed --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + FormattedQuestionAnsweringResponse, + QuestionAnsweringResponse, +} from './question_answering_inference'; +export { QuestionAnsweringInference } from './question_answering_inference'; +export { getQuestionAnsweringOutputComponent } from './question_answering_output'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_inference.ts new file mode 100644 index 0000000000000..9f62e4da4b76e --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_inference.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { BehaviorSubject } from 'rxjs'; + +import { InferenceBase, InferResponse } from '../inference_base'; +import { getQuestionAnsweringInput } from './question_answering_input'; +import { getQuestionAnsweringOutputComponent } from './question_answering_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; + +export interface RawQuestionAnsweringResponse { + inference_results: Array<{ + predicted_value: string; + prediction_probability: number; + start_offset: number; + end_offset: number; + top_classes?: Array<{ + end_offset: number; + score: number; + start_offset: number; + answer: string; + }>; + }>; +} + +export interface FormattedQuestionAnsweringResult { + value: string; + predictionProbability: number; + startOffset: number; + endOffset: number; +} + +export type FormattedQuestionAnsweringResponse = FormattedQuestionAnsweringResult[]; + +export type QuestionAnsweringResponse = InferResponse< + FormattedQuestionAnsweringResponse, + RawQuestionAnsweringResponse +>; + +export class QuestionAnsweringInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.QUESTION_ANSWERING; + + public questionText$ = new BehaviorSubject(''); + + public async infer() { + try { + this.setRunning(); + const inputText = this.inputText$.getValue(); + const questionText = this.questionText$.value; + const numTopClassesConfig = this.getNumTopClassesConfig()?.inference_config; + + const payload = { + docs: [{ [this.inputField]: inputText }], + inference_config: { + [this.inferenceType]: { + question: questionText, + ...(numTopClassesConfig + ? { + num_top_classes: numTopClassesConfig[this.inferenceType].num_top_classes, + } + : {}), + }, + }, + }; + const resp = (await this.trainedModelsApi.inferTrainedModel( + this.model.model_id, + payload, + '30s' + )) as unknown as RawQuestionAnsweringResponse; + + const processedResponse: QuestionAnsweringResponse = processResponse( + resp, + this.model, + inputText + ); + + this.inferenceResult$.next(processedResponse); + this.setFinished(); + + return processedResponse; + } catch (error) { + this.setFinishedWithErrors(error); + throw error; + } + } + + public getInputComponent(): JSX.Element { + return getQuestionAnsweringInput(this); + } + + public getOutputComponent(): JSX.Element { + return getQuestionAnsweringOutputComponent(this); + } +} + +function processResponse( + resp: RawQuestionAnsweringResponse, + model: estypes.MlTrainedModelConfig, + inputText: string +) { + const { + inference_results: [inferenceResults], + } = resp; + + let formattedResponse = [ + { + value: inferenceResults.predicted_value, + predictionProbability: inferenceResults.prediction_probability, + startOffset: inferenceResults.start_offset, + endOffset: inferenceResults.end_offset, + }, + ]; + + if (inferenceResults.top_classes !== undefined) { + formattedResponse = inferenceResults.top_classes.map((topClass) => { + return { + value: topClass.answer, + predictionProbability: topClass.score, + startOffset: topClass.start_offset, + endOffset: topClass.end_offset, + }; + }); + } + + return { + response: formattedResponse, + rawResponse: resp, + inputText, + }; +} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_input.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_input.tsx new file mode 100644 index 0000000000000..fffff1b382c76 --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_input.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, { FC, useEffect, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { i18n } from '@kbn/i18n'; + +import { EuiSpacer, EuiFieldText, EuiFormRow } from '@elastic/eui'; + +import { TextInput } from '../text_input'; +import { QuestionAnsweringInference } from './question_answering_inference'; +import { RUNNING_STATE } from '../inference_base'; + +const QuestionInput: FC<{ + inferrer: QuestionAnsweringInference; +}> = ({ inferrer }) => { + const [questionText, setQuestionText] = useState(''); + + useEffect(() => { + inferrer.questionText$.next(questionText); + }, [questionText]); + + const runningState = useObservable(inferrer.runningState$); + return ( + + { + setQuestionText(e.target.value); + }} + /> + + ); +}; + +export const getQuestionAnsweringInput = ( + inferrer: QuestionAnsweringInference, + placeholder?: string +) => ( + <> + + + + +); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_output.tsx new file mode 100644 index 0000000000000..95c1533efd709 --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/question_answering/question_answering_output.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { EuiBadge } from '@elastic/eui'; + +import { useCurrentEuiTheme } from '../../../../../components/color_range_legend/use_color_range'; + +import type { + QuestionAnsweringInference, + FormattedQuestionAnsweringResult, +} from './question_answering_inference'; + +const ICON_PADDING = '2px'; +const TRIM_CHAR_COUNT = 200; + +export const getQuestionAnsweringOutputComponent = (inferrer: QuestionAnsweringInference) => ( + +); + +const QuestionAnsweringOutput: FC<{ inferrer: QuestionAnsweringInference }> = ({ inferrer }) => { + const result = useObservable(inferrer.inferenceResult$); + if (!result || result.response.length === 0) { + return null; + } + + const bestResult = result.response[0]; + const { inputText } = result; + + return <>{insertHighlighting(bestResult, inputText)}; +}; + +function insertHighlighting(result: FormattedQuestionAnsweringResult, inputText: string) { + const start = inputText.slice(0, result.startOffset); + const end = inputText.slice(result.endOffset, inputText.length); + const truncatedStart = + start.length > TRIM_CHAR_COUNT + ? `...${start.slice(start.length - TRIM_CHAR_COUNT, start.length)}` + : start; + const truncatedEnd = end.length > TRIM_CHAR_COUNT ? `${end.slice(0, TRIM_CHAR_COUNT)}...` : end; + + return ( +
+ {truncatedStart} + {result.value} + {truncatedEnd} +
+ ); +} + +const ResultBadge = ({ children }: { children: ReactNode }) => { + const { euiTheme } = useCurrentEuiTheme(); + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx index 816166c5cbcbf..35f0670646004 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx @@ -9,6 +9,7 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import React, { FC } from 'react'; import { NerInference } from './models/ner'; +import { QuestionAnsweringInference } from './models/question_answering'; import { TextClassificationInference, @@ -64,6 +65,11 @@ export const SelectedModel: FC = ({ model }) => { const inferrer = new FillMaskInference(trainedModels, model); return ; } + + if (Object.keys(model.inference_config)[0] === SUPPORTED_PYTORCH_TASKS.QUESTION_ANSWERING) { + const inferrer = new QuestionAnsweringInference(trainedModels, model); + return ; + } } if (model.model_type === TRAINED_MODEL_TYPE.LANG_IDENT) { const inferrer = new LangIdentInference(trainedModels, model); diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts index 01a7a5c4d8a52..823712652fdf4 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts @@ -10,6 +10,7 @@ import { ElasticsearchModifiedSource, ElasticsearchLegacySource, ElasticsearchSourceKibanaStats, + ElasticsearchMetricbeatSource, } from '../../../common/types/es'; // @ts-ignore import { calculateOverallStatus } from '../calculate_overall_status'; @@ -47,7 +48,7 @@ export function getClustersSummary( } = cluster; const license = cluster.license || cluster.elasticsearch?.cluster?.stats?.license; - const version = cluster.version || cluster.elasticsearch?.version; + const version = cluster.version || ecsFormatVersion(cluster); const clusterUuid = cluster.cluster_uuid || cluster.elasticsearch?.cluster?.id; const clusterStatsLegacy = cluster.cluster_stats; const clusterStatsMB = cluster.elasticsearch?.cluster?.stats; @@ -161,3 +162,9 @@ export function getClustersSummary( }; }); } + +function ecsFormatVersion(cluster: ElasticsearchMetricbeatSource) { + const versions = cluster.elasticsearch?.cluster?.stats?.nodes?.versions || []; + const sortedVersions = [...versions].sort().reverse(); + return sortedVersions.join('/'); +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts new file mode 100644 index 0000000000000..cc1313be29340 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts @@ -0,0 +1,159 @@ +/* + * 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. + */ +/* + * 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, useState, useCallback, useRef } from 'react'; +import { AsApiContract } from '@kbn/actions-plugin/common'; +import { HttpSetup } from '@kbn/core/public'; +import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +interface UseFetchLast24hAlertsProps { + http: HttpSetup; + features: string; + ruleId: string; +} +interface FetchLast24hAlerts { + isLoadingLast24hAlerts: boolean; + last24hAlerts: number; + errorLast24hAlerts: string | undefined; +} + +export function useFetchLast24hAlerts({ http, features, ruleId }: UseFetchLast24hAlertsProps) { + const [last24hAlerts, setLast24hAlerts] = useState({ + isLoadingLast24hAlerts: true, + last24hAlerts: 0, + errorLast24hAlerts: undefined, + }); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const fetchLast24hAlerts = useCallback(async () => { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + try { + if (!features) return; + const { index } = await fetchIndexNameAPI({ + http, + features, + }); + const { error, alertsCount } = await fetchLast24hAlertsAPI({ + http, + index, + ruleId, + signal: abortCtrlRef.current.signal, + }); + if (error) throw error; + if (!isCancelledRef.current) { + setLast24hAlerts((oldState: FetchLast24hAlerts) => ({ + ...oldState, + last24hAlerts: alertsCount, + isLoading: false, + })); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + setLast24hAlerts((oldState: FetchLast24hAlerts) => ({ + ...oldState, + isLoading: false, + errorLast24hAlerts: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + } + } + }, [http, features, ruleId]); + useEffect(() => { + fetchLast24hAlerts(); + }, [fetchLast24hAlerts]); + + return last24hAlerts; +} + +interface IndexName { + index: string; +} + +export async function fetchIndexNameAPI({ + http, + features, +}: { + http: HttpSetup; + features: string; +}): Promise { + const res = await http.get<{ index_name: string[] }>(`${BASE_RAC_ALERTS_API_PATH}/index`, { + query: { features }, + }); + return { + index: res.index_name[0], + }; +} +export async function fetchLast24hAlertsAPI({ + http, + index, + ruleId, + signal, +}: { + http: HttpSetup; + index: string; + ruleId: string; + signal: AbortSignal; +}): Promise<{ + error: string | null; + alertsCount: number; +}> { + try { + const res = await http.post>(`${BASE_RAC_ALERTS_API_PATH}/find`, { + signal, + body: JSON.stringify({ + index, + query: { + bool: { + must: [ + { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + { + range: { + '@timestamp': { + gte: 'now-24h', + lt: 'now', + }, + }, + }, + ], + }, + }, + aggs: { + alerts_count: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + }), + }); + return { + error: null, + alertsCount: res.aggregations.alerts_count.value, + }; + } catch (error) { + return { + error, + alertsCount: 0, + }; + } +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts new file mode 100644 index 0000000000000..07f13b4c80e7e --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule.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 { useEffect, useState, useCallback } from 'react'; +import { loadRule } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleProps, FetchRule } from '../pages/rule_details/types'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +export function useFetchRule({ ruleId, http }: FetchRuleProps) { + const [ruleSummary, setRuleSummary] = useState({ + isRuleLoading: true, + rule: undefined, + errorRule: undefined, + }); + const fetchRuleSummary = useCallback(async () => { + try { + const rule = await loadRule({ + http, + ruleId, + }); + + setRuleSummary((oldState: FetchRule) => ({ + ...oldState, + isRuleLoading: false, + rule, + })); + } catch (error) { + setRuleSummary((oldState: FetchRule) => ({ + ...oldState, + isRuleLoading: false, + errorRule: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [ruleId, http]); + useEffect(() => { + fetchRuleSummary(); + }, [fetchRuleSummary]); + + return { ...ruleSummary, reloadRule: fetchRuleSummary }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts new file mode 100644 index 0000000000000..eaf01ed5ba59d --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts @@ -0,0 +1,51 @@ +/* + * 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, useState, useCallback } from 'react'; +import { ActionConnector, loadAllActions } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleActionsProps } from '../pages/rule_details/types'; +import { ACTIONS_LOAD_ERROR } from '../pages/rule_details/translations'; + +interface FetchActions { + isLoadingActions: boolean; + allActions: Array>>; + errorActions: string | undefined; +} + +export function useFetchRuleActions({ http }: FetchRuleActionsProps) { + const [ruleActions, setRuleActions] = useState({ + isLoadingActions: true, + allActions: [] as Array>>, + errorActions: undefined, + }); + + const fetchRuleActions = useCallback(async () => { + try { + const response = await loadAllActions({ + http, + }); + setRuleActions((oldState: FetchActions) => ({ + ...oldState, + isLoadingActions: false, + allActions: response, + })); + } catch (error) { + setRuleActions((oldState: FetchActions) => ({ + ...oldState, + isLoadingActions: false, + errorActions: ACTIONS_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [http]); + useEffect(() => { + fetchRuleActions(); + }, [fetchRuleActions]); + + return { ...ruleActions, reloadRuleActions: fetchRuleActions }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts new file mode 100644 index 0000000000000..7e7c71e503329 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.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 { useEffect, useState, useCallback } from 'react'; +import { loadRuleSummary } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleSummaryProps, FetchRuleSummary } from '../pages/rule_details/types'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +export function useFetchRuleSummary({ ruleId, http }: FetchRuleSummaryProps) { + const [ruleSummary, setRuleSummary] = useState({ + isLoadingRuleSummary: true, + ruleSummary: undefined, + errorRuleSummary: undefined, + }); + + const fetchRuleSummary = useCallback(async () => { + setRuleSummary((oldState: FetchRuleSummary) => ({ ...oldState, isLoading: true })); + + try { + const response = await loadRuleSummary({ + http, + ruleId, + }); + setRuleSummary((oldState: FetchRuleSummary) => ({ + ...oldState, + isLoading: false, + ruleSummary: response, + })); + } catch (error) { + setRuleSummary((oldState: FetchRuleSummary) => ({ + ...oldState, + isLoading: false, + errorRuleSummary: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [ruleId, http]); + useEffect(() => { + fetchRuleSummary(); + }, [fetchRuleSummary]); + + return { ...ruleSummary, reloadRuleSummary: fetchRuleSummary }; +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx new file mode 100644 index 0000000000000..e3aadb60f8c4c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -0,0 +1,61 @@ +/* + * 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 { + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + IconType, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { intersectionBy } from 'lodash'; +import { ActionsProps } from '../types'; +import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; +import { useKibana } from '../../../utils/kibana_react'; + +interface MapActionTypeIcon { + [key: string]: string | IconType; +} +const mapActionTypeIcon: MapActionTypeIcon = { + /* TODO: Add the rest of the application logs (SVGs ones) */ + '.server-log': 'logsApp', + '.email': 'email', + '.pagerduty': 'apps', + '.index': 'indexOpen', + '.slack': 'logoSlack', + '.webhook': 'logoWebhook', +}; +export function Actions({ ruleActions }: ActionsProps) { + const { + http, + notifications: { toasts }, + } = useKibana().services; + const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); + if (ruleActions && ruleActions.length <= 0) return 0; + const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); + if (isLoadingActions) return ; + return ( + + {actions.map((action) => ( + <> + + + + + + {action.name} + + + + + ))} + {errorActions && toasts.addDanger({ title: errorActions })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts new file mode 100644 index 0000000000000..8020af09dedc2 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PageTitle } from './page_title'; +export { ItemTitleRuleSummary } from './item_title_rule_summary'; +export { ItemValueRuleSummary } from './item_value_rule_summary'; +export { Actions } from './actions'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx new file mode 100644 index 0000000000000..d2a4805938305 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { ItemTitleRuleSummaryProps } from '../types'; + +export function ItemTitleRuleSummary({ children }: ItemTitleRuleSummaryProps) { + return ( + + + {children} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx new file mode 100644 index 0000000000000..6e178250c53ff --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexItem, EuiText } from '@elastic/eui'; +import { ItemValueRuleSummaryProps } from '../types'; + +export function ItemValueRuleSummary({ itemValue, extraSpace = true }: ItemValueRuleSummaryProps) { + return ( + + {itemValue} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx new file mode 100644 index 0000000000000..478fbf69a226c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx @@ -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 React, { useState } from 'react'; +import moment from 'moment'; +import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ExperimentalBadge } from '../../../components/shared/experimental_badge'; +import { PageHeaderProps } from '../types'; +import { useKibana } from '../../../utils/kibana_react'; +import { LAST_UPDATED_MESSAGE, CREATED_WORD, BY_WORD, ON_WORD } from '../translations'; + +export function PageTitle({ rule }: PageHeaderProps) { + const { triggersActionsUi } = useKibana().services; + const [isTagsPopoverOpen, setIsTagsPopoverOpen] = useState(false); + const tagsClicked = () => + setIsTagsPopoverOpen( + (oldStateIsTagsPopoverOpen) => rule.tags.length > 0 && !oldStateIsTagsPopoverOpen + ); + const closeTagsPopover = () => setIsTagsPopoverOpen(false); + return ( + <> + {rule.name} + + + + {LAST_UPDATED_MESSAGE} {BY_WORD} {rule.updatedBy} {ON_WORD}  + {moment(rule.updatedAt).format('ll')}   + {CREATED_WORD} {BY_WORD} {rule.createdBy} {ON_WORD}  + {moment(rule.createdAt).format('ll')} + + + + {rule.tags.length > 0 && + triggersActionsUi.getRuleTagBadge({ + isOpen: isTagsPopoverOpen, + tags: rule.tags, + onClick: () => tagsClicked(), + onClose: () => closeTagsPopover(), + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts new file mode 100644 index 0000000000000..e73849f47e7b3 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/config.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 { RuleType, Rule } from '@kbn/triggers-actions-ui-plugin/public'; + +type Capabilities = Record; + +export type InitialRule = Partial & + Pick; + +export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean { + return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; +} + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; + +export const RULES_PAGE_LINK = '/app/observability/alerts/rules'; +export const ALERT_PAGE_LINK = '/app/observability/alerts'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx new file mode 100644 index 0000000000000..ce7049bd61056 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -0,0 +1,483 @@ +/* + * 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, { useState, useEffect, useCallback } from 'react'; +import moment from 'moment'; +import { useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiPanel, + EuiTitle, + EuiHealth, + EuiPopover, + EuiHorizontalRule, + EuiTabbedContent, + EuiEmptyPrompt, +} from '@elastic/eui'; + +import { + enableRule, + disableRule, + snoozeRule, + unsnoozeRule, + deleteRules, + useLoadRuleTypes, + RuleType, +} from '@kbn/triggers-actions-ui-plugin/public'; +// TODO: use a Delete modal from triggersActionUI when it's sharable +import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; +import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; +import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; +import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config'; +import { + RuleDetailsPathParams, + EVENT_ERROR_LOG_TAB, + EVENT_LOG_LIST_TAB, + ALERT_LIST_TAB, +} from './types'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useFetchRule } from '../../hooks/use_fetch_rule'; +import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; +import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components'; +import { useKibana } from '../../utils/kibana_react'; +import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; +import { formatInterval } from './utils'; +import { + hasExecuteActionsCapability, + hasAllPrivilege, + RULES_PAGE_LINK, + ALERT_PAGE_LINK, +} from './config'; + +export function RuleDetailsPage() { + const { + http, + triggersActionsUi: { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout }, + application: { capabilities, navigateToUrl }, + notifications: { toasts }, + } = useKibana().services; + + const { ruleId } = useParams(); + const { ObservabilityPageTemplate } = usePluginContext(); + const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); + const { ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + + const [features, setFeatures] = useState(''); + const [ruleType, setRuleType] = useState>(); + const [ruleToDelete, setRuleToDelete] = useState([]); + const [isPageLoading, setIsPageLoading] = useState(false); + const { last24hAlerts } = useFetchLast24hAlerts({ + http, + features, + ruleId, + }); + + const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); + const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false); + + const handleClosePopover = useCallback(() => setIsRuleEditPopoverOpen(false), []); + + const handleOpenPopover = useCallback(() => setIsRuleEditPopoverOpen(true), []); + + const handleRemoveRule = useCallback(() => { + setIsRuleEditPopoverOpen(false); + if (rule) setRuleToDelete([rule.id]); + }, [rule]); + + const handleEditRule = useCallback(() => { + setIsRuleEditPopoverOpen(false); + setEditFlyoutVisible(true); + }, []); + + useEffect(() => { + if (ruleTypes.length && rule) { + const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); + if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { + setRuleType(matchedRuleType); + setFeatures(matchedRuleType.producer); + } else setFeatures(rule.consumer); + } + }, [rule, ruleTypes]); + + useBreadcrumbs([ + { + text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', { + defaultMessage: 'Alerts', + }), + href: http.basePath.prepend(ALERT_PAGE_LINK), + }, + { + href: http.basePath.prepend(RULES_PAGE_LINK), + text: RULES_BREADCRUMB_TEXT, + }, + { + text: rule && rule.name, + }, + ]); + + const canExecuteActions = hasExecuteActionsCapability(capabilities); + const canSaveRule = + rule && + hasAllPrivilege(rule, ruleType) && + // if the rule has actions, can the user save the rule's action params + (canExecuteActions || (!canExecuteActions && rule.actions.length === 0)); + + const hasEditButton = + // can the user save the rule + canSaveRule && + // is this rule type editable from within Rules Management + (ruleTypeRegistry.has(rule.ruleTypeId) + ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext + : false); + + const getRuleConditionsWording = () => { + const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0; + return ( + <> + {numberOfConditions}  + {i18n.translate('xpack.observability.ruleDetails.conditions', { + defaultMessage: 'condition{s}', + values: { s: numberOfConditions > 1 ? 's' : '' }, + })} + + ); + }; + + const tabs = [ + { + id: EVENT_LOG_LIST_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', { + defaultMessage: 'Execution history', + }), + 'data-test-subj': 'eventLogListTab', + content: Execution history, + }, + { + id: ALERT_LIST_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { + defaultMessage: 'Alerts', + }), + 'data-test-subj': 'ruleAlertListTab', + content: Alerts, + }, + { + id: EVENT_ERROR_LOG_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.errorLogTabText', { + defaultMessage: 'Error log', + }), + 'data-test-subj': 'errorLogTab', + content: Error log, + }, + ]; + + if (isPageLoading || isRuleLoading) return ; + if (!rule || errorRule) + return ( + + + {i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', { + defaultMessage: 'Unable to load rule details', + })} +
+ } + body={ +

+ {i18n.translate('xpack.observability.ruleDetails.errorPromptBody', { + defaultMessage: 'There was an error loading the rule details.', + })} +

+ } + /> + + ); + return ( + , + bottomBorder: false, + rightSideItems: hasEditButton + ? [ + + + + } + > + + + + + {i18n.translate('xpack.observability.ruleDetails.editRule', { + defaultMessage: 'Edit rule', + })} + + + + + + {i18n.translate('xpack.observability.ruleDetails.deleteRule', { + defaultMessage: 'Delete rule', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.triggreAction.status', { + defaultMessage: 'Status', + })} + + + + {getRuleStatusDropdown({ + rule, + enableRule: async () => await enableRule({ http, id: rule.id }), + disableRule: async () => await disableRule({ http, id: rule.id }), + onRuleChanged: () => reloadRule(), + isEditable: hasEditButton, + snoozeRule: async (snoozeEndTime: string | -1) => { + await snoozeRule({ http, id: rule.id, snoozeEndTime }); + }, + unsnoozeRule: async () => await unsnoozeRule({ http, id: rule.id }), + })} + + , + ] + : [], + }} + > + + {/* Left side of Rule Summary */} + + + + + + + {rule.executionStatus.status.charAt(0).toUpperCase() + + rule.executionStatus.status.slice(1)} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.lastRun', { + defaultMessage: 'Last Run', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.alerts', { + defaultMessage: 'Alerts', + })} + + + + + + + + + + + {/* Right side of Rule Summary */} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.definition', { + defaultMessage: 'Definition', + })} + + + {hasEditButton && ( + + setEditFlyoutVisible(true)} /> + + )} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.ruleType', { + defaultMessage: 'Rule type', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.description', { + defaultMessage: 'Description', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.conditionsTitle', { + defaultMessage: 'Conditions', + })} + + + + {hasEditButton ? ( + setEditFlyoutVisible(true)}> + {getRuleConditionsWording()} + + ) : ( + {getRuleConditionsWording()} + )} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.runsEvery', { + defaultMessage: 'Runs every', + })} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.notifyWhen', { + defaultMessage: 'Notify', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.actions', { + defaultMessage: 'Actions', + })} + + + + + + + + + + + + + + {editFlyoutVisible && + getEditAlertFlyout({ + initialRule: rule, + onClose: () => { + setEditFlyoutVisible(false); + }, + onSave: reloadRule, + })} + { + setRuleToDelete([]); + navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + }} + onErrors={async () => { + setRuleToDelete([]); + navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + }} + onCancel={() => {}} + apiDeleteCall={deleteRules} + idsToDelete={ruleToDelete} + singleTitle={rule.name} + multipleTitle={rule.name} + setIsLoadingState={(isLoading: boolean) => { + setIsPageLoading(isLoading); + }} + /> + {errorRule && toasts.addDanger({ title: errorRule })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts new file mode 100644 index 0000000000000..f162f30906c21 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const RULE_LOAD_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.ruleLoadError', { + defaultMessage: 'Unable to load rule. Reason: {message}', + values: { message: errorMessage }, + }); + +export const ACTIONS_LOAD_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.connectorsLoadError', { + defaultMessage: 'Unable to load rule actions connectors. Reason: {message}', + values: { message: errorMessage }, + }); + +export const TAGS_TITLE = i18n.translate('xpack.observability.ruleDetails.tagsTitle', { + defaultMessage: 'Tags', +}); + +export const LAST_UPDATED_MESSAGE = i18n.translate( + 'xpack.observability.ruleDetails.lastUpdatedMessage', + { + defaultMessage: 'Last updated', + } +); + +export const BY_WORD = i18n.translate('xpack.observability.ruleDetails.byWord', { + defaultMessage: 'by', +}); + +export const ON_WORD = i18n.translate('xpack.observability.ruleDetails.onWord', { + defaultMessage: 'on', +}); + +export const CREATED_WORD = i18n.translate('xpack.observability.ruleDetails.createdWord', { + defaultMessage: 'Created', +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts new file mode 100644 index 0000000000000..9855bf2c7f184 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; + +export interface RuleDetailsPathParams { + ruleId: string; +} +export interface PageHeaderProps { + rule: Rule; +} + +export interface FetchRuleProps { + ruleId: string; + http: HttpSetup; +} + +export interface FetchRule { + isRuleLoading: boolean; + rule?: Rule; + ruleType?: RuleType; + errorRule?: string; +} + +export interface FetchRuleSummaryProps { + ruleId: string; + http: HttpSetup; +} +export interface FetchRuleActionsProps { + http: HttpSetup; +} + +export interface FetchRuleSummary { + isLoadingRuleSummary: boolean; + ruleSummary?: RuleSummary; + errorRuleSummary?: string; +} + +export interface AlertListItemStatus { + label: string; + healthColor: string; + actionGroup?: string; +} +export interface AlertListItem { + alert: string; + status: AlertListItemStatus; + start?: Date; + duration: number; + isMuted: boolean; + sortPriority: number; +} +export interface ItemTitleRuleSummaryProps { + children: string; +} +export interface ItemValueRuleSummaryProps { + itemValue: string; + extraSpace?: boolean; +} +export interface ActionsProps { + ruleActions: any[]; +} + +export const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; +export const ALERT_LIST_TAB = 'rule_alert_list'; +export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/utils.ts new file mode 100644 index 0000000000000..0c907d93228a6 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../common'; + +export const formatInterval = (ruleInterval: string) => { + const interval: string[] | null = ruleInterval.match(/(^\d*)([s|m|h|d])/); + if (!interval || interval.length < 3) return ruleInterval; + const value: number = +interval[1]; + const unit = interval[2] as TimeUnitChar; + return formatDurationFromTimeUnitChar(value, unit); +}; diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index cbde68ea27eb4..15cb44412d880 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -12,9 +12,7 @@ import { useKibana } from '../../../utils/kibana_react'; export function Name({ name, rule }: RuleNameProps) { const { http } = useKibana().services; - const detailsLink = http.basePath.prepend( - `/app/management/insightsAndAlerting/triggersActions/rule/${rule.id}` - ); + const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`); const link = ( diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 573eb0b7308e4..867e44613e07c 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -17,6 +17,7 @@ import { OverviewPage } from '../pages/overview'; import { jsonRt } from './json_rt'; import { ObservabilityExploratoryView } from '../components/shared/exploratory_view/obsv_exploratory_view'; import { RulesPage } from '../pages/rules'; +import { RuleDetailsPage } from '../pages/rule_details'; import { AlertingPages } from '../config'; export type RouteParams = DecodeParams; @@ -109,4 +110,11 @@ export const routes = { params: {}, exact: true, }, + '/alerts/rules/:ruleId': { + handler: () => { + return ; + }, + params: {}, + exact: true, + }, }; diff --git a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index 673ff082a606c..d6f8e14381bc2 100644 --- a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { FLEET_AGENT_POLICIES, OLD_OSQUERY_MANAGER } from '../../tasks/navigation'; +import { FLEET_AGENT_POLICIES, navigateTo, OLD_OSQUERY_MANAGER } from '../../tasks/navigation'; import { addIntegration, closeModalIfVisible } from '../../tasks/integrations'; import { login } from '../../tasks/login'; -// import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query'; +import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { DEFAULT_POLICY } from '../../screens/fleet'; @@ -76,53 +76,58 @@ describe('ALL - Add Integration', () => { addIntegration(); cy.contains('osquery_manager-'); }); - // it('should have integration and packs copied when upgrading integration', () => { - // const packageName = 'osquery_manager'; - // const oldVersion = '0.7.4'; - // const newVersion = '0.8.1'; - // - // cy.visit(`app/integrations/detail/${packageName}-${oldVersion}/overview`); - // cy.contains('Add Osquery Manager').click(); - // cy.contains('Save and continue').click(); - // cy.contains('Add Elastic Agent later').click(); - // cy.contains('Upgrade'); - // cy.contains('Default policy').click(); - // cy.get('tr') - // .should('contain', 'osquery_manager-2') - // .and('contain', 'Osquery Manager') - // .and('contain', `v${oldVersion}`); - // cy.contains('Actions').click(); - // cy.contains('View policy').click(); - // cy.contains('name: osquery_manager-2'); - // cy.contains(`version: ${oldVersion}`); - // cy.contains('Close').click(); - // navigateTo('app/osquery/packs'); - // findAndClickButton('Add pack'); - // findFormFieldByRowsLabelAndType('Name', 'Integration'); - // findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', '{downArrow} {enter}'); - // findAndClickButton('Add query'); - // cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - // .click() - // .type('{downArrow} {enter}'); - // cy.contains(/^Save$/).click(); - // cy.contains(/^Save pack$/).click(); - // cy.visit('app/fleet/policies'); - // cy.contains('Default policy').click(); - // cy.contains('Upgrade').click(); - // cy.contains(/^Advanced$/).click(); - // cy.contains('"Integration":'); - // cy.contains(/^Upgrade integration$/).click(); - // cy.contains(/^osquery_manager-2$/).click(); - // cy.contains(/^Advanced$/).click(); - // cy.contains('"Integration":'); - // cy.contains('Cancel').click(); - // cy.get('tr') - // .should('contain', 'osquery_manager-2') - // .and('contain', 'Osquery Manager') - // .and('contain', `v${newVersion}`); - // cy.contains('Actions').click(); - // cy.contains('View policy').click(); - // cy.contains('name: osquery_manager-2'); - // cy.contains(`version: ${newVersion}`); - // }); + it('should have integration and packs copied when upgrading integration', () => { + const packageName = 'osquery_manager'; + const oldVersion = '1.2.0'; + const newVersion = '1.3.0'; + + cy.visit(`app/integrations/detail/${packageName}-${oldVersion}/overview`); + cy.contains('Add Osquery Manager').click(); + cy.contains('Save and continue').click(); + cy.contains('Add Elastic Agent later').click(); + cy.contains('Upgrade'); + cy.contains('Agent policy 1').click(); + cy.get('tr') + .should('contain', 'osquery_manager-2') + .and('contain', 'Osquery Manager') + .and('contain', `v${oldVersion}`); + cy.contains('Actions').click(); + cy.contains('View policy').click(); + cy.contains('name: osquery_manager-2'); + cy.contains(`version: ${oldVersion}`); + cy.contains('Close').click(); + navigateTo('app/osquery/packs'); + findAndClickButton('Add pack'); + findFormFieldByRowsLabelAndType('Name', 'Integration'); + findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', '{downArrow} {enter}'); + findAndClickButton('Add query'); + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type('{downArrow} {enter}'); + cy.contains(/^Save$/).click(); + cy.contains(/^Save pack$/).click(); + cy.visit('app/fleet/policies'); + cy.contains('Agent policy 1').click(); + cy.contains('Upgrade').click(); + cy.contains(/^Advanced$/).click(); + cy.contains('"Integration":'); + cy.contains(/^Upgrade integration$/).click(); + cy.contains(/^osquery_manager-2$/).click(); + cy.contains(/^Advanced$/).click(); + cy.contains('"Integration":'); + cy.contains('Cancel').click(); + cy.get('tr') + .should('contain', 'osquery_manager-2') + .and('contain', 'Osquery Manager') + .and('contain', `v${newVersion}`); + cy.contains('Actions').click(); + cy.contains('View policy').click(); + cy.contains('name: osquery_manager-2'); + cy.contains(`version: ${newVersion}`); + + // test list of prebuilt queries + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(); + cy.react('EuiTableRow').should('have.length.above', 5); + }); }); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/admin.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/admin.spec.ts new file mode 100644 index 0000000000000..a22177955c4ac --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/admin.spec.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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { checkResults, inputQuery, selectAllAgents, submitQuery } from '../../tasks/live_query'; + +describe('Admin', () => { + beforeEach(() => { + login(ROLES.admin); + navigateTo('/app/osquery'); + }); + + it('should be able to run live query with BASE All permissions', () => { + cy.contains('New live query').click(); + selectAllAgents(); + inputQuery('select * from uptime; '); + cy.wait(500); + submitQuery(); + checkResults(); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/test/index.ts b/x-pack/plugins/osquery/cypress/test/index.ts index 11cca6c93c553..48905420f9d3d 100644 --- a/x-pack/plugins/osquery/cypress/test/index.ts +++ b/x-pack/plugins/osquery/cypress/test/index.ts @@ -15,5 +15,6 @@ export enum ROLES { rule_author = 'rule_author', platform_engineer = 'platform_engineer', detections_admin = 'detections_admin', + admin = 'admin', // base: ['all'] alert_test = 'alert_test', } diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx index 555a9ce973407..94b1f092e1ede 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx @@ -12,18 +12,24 @@ import { EuiFlexItem, EuiConfirmModal, EuiText, + EuiCallOut, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; import { useKibana, useRouterNavigate } from '../../../common/lib/kibana'; import { WithHeaderLayout } from '../../../components/layouts'; import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; import { EditSavedQueryForm } from './form'; import { useDeleteSavedQuery, useUpdateSavedQuery, useSavedQuery } from '../../../saved_queries'; +const StyledEuiCallOut = styled(EuiCallOut)` + margin: 10px; +`; + const EditSavedQueryPageComponent = () => { const permissions = useKibana().services.application.capabilities.osquery; @@ -37,7 +43,14 @@ const EditSavedQueryPageComponent = () => { useBreadcrumbs('saved_query_edit', { savedQueryName: savedQueryDetails?.attributes?.id ?? '' }); - const viewMode = useMemo(() => !permissions.writeSavedQueries, [permissions.writeSavedQueries]); + const elasticPrebuiltQuery = useMemo( + () => savedQueryDetails?.attributes?.version, + [savedQueryDetails] + ); + const viewMode = useMemo( + () => !permissions.writeSavedQueries || elasticPrebuiltQuery, + [permissions.writeSavedQueries, elasticPrebuiltQuery] + ); const handleCloseDeleteConfirmationModal = useCallback(() => { setIsDeleteModalVisible(false); @@ -68,14 +81,24 @@ const EditSavedQueryPageComponent = () => {

{viewMode ? ( - + <> + + {elasticPrebuiltQuery && ( + + + + )} + ) : ( { ), - [savedQueryDetails?.attributes?.id, savedQueryListProps, viewMode] + [elasticPrebuiltQuery, savedQueryDetails?.attributes?.id, savedQueryListProps, viewMode] ); const RightColumn = useMemo( diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx index e742958b989f0..2fe33261e69d9 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiText, EuiBasicTableColumn, + EuiToolTip, } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; @@ -145,6 +146,16 @@ const SavedQueriesPageComponent = () => { return updatedAt ? `${moment(updatedAt).fromNow()}${updatedBy}` : '-'; }, []); + const renderDescriptionColumn = useCallback((description?: string) => { + const content = + description && description.length > 80 ? `${description?.substring(0, 80)}...` : description; + + return ( + {description}}> + {content} + + ); + }, []); const columns: Array> = useMemo( () => [ { @@ -154,19 +165,22 @@ const SavedQueriesPageComponent = () => { }), sortable: (item) => item.attributes.id.toLowerCase(), truncateText: true, + width: '15%', }, { field: 'attributes.description', name: i18n.translate('xpack.osquery.savedQueries.table.descriptionColumnTitle', { defaultMessage: 'Description', }), - truncateText: true, + render: renderDescriptionColumn, + width: '50%', }, { field: 'attributes.created_by', name: i18n.translate('xpack.osquery.savedQueries.table.createdByColumnTitle', { defaultMessage: 'Created by', }), + width: '15%', sortable: true, truncateText: true, }, @@ -175,6 +189,7 @@ const SavedQueriesPageComponent = () => { name: i18n.translate('xpack.osquery.savedQueries.table.updatedAtColumnTitle', { defaultMessage: 'Last updated at', }), + width: '10%', sortable: (item) => item.attributes.updated_at ? Date.parse(item.attributes.updated_at) : 0, truncateText: true, @@ -187,7 +202,7 @@ const SavedQueriesPageComponent = () => { actions: [{ render: renderPlayAction }, { render: renderEditAction }], }, ], - [renderEditAction, renderPlayAction, renderUpdatedAt] + [renderDescriptionColumn, renderEditAction, renderPlayAction, renderUpdatedAt] ); const onTableChange = useCallback(({ page = {}, sort = {} }) => { diff --git a/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx index cc64e539e399f..441960e1c2c98 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx @@ -36,7 +36,7 @@ const CodeEditorFieldComponent: React.FC = ({ euiFieldProp error={error} fullWidth > - {euiFieldProps?.disabled ? ( + {euiFieldProps?.isDisabled ? ( = ({ - + {!viewMode && hasPlayground && ( @@ -124,7 +124,11 @@ const SavedQueryFormComponent: React.FC = ({ - + {playgroundVisible && ( diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/admin/delete_user.sh new file mode 100755 index 0000000000000..9a26b09cf20b1 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/delete_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/admin diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/admin/get_role.sh new file mode 100755 index 0000000000000..10dc481194e5c --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/get_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/admin | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/index.ts b/x-pack/plugins/osquery/scripts/roles_users/admin/index.ts new file mode 100644 index 0000000000000..783937d100fe2 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as adminUser from './user.json'; +import * as adminRole from './role.json'; + +export { adminUser, adminRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/admin/post_role.sh new file mode 100755 index 0000000000000..b31a836fc677c --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/post_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/admin \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/admin/post_user.sh new file mode 100755 index 0000000000000..e19e71fc601b3 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/post_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/admin \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/role.json b/x-pack/plugins/osquery/scripts/roles_users/admin/role.json new file mode 100644 index 0000000000000..e8cd245fda341 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/role.json @@ -0,0 +1,17 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "base": ["all"], + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/admin/user.json b/x-pack/plugins/osquery/scripts/roles_users/admin/user.json new file mode 100644 index 0000000000000..60e2dccc8be70 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/admin/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["admin"], + "full_name": "Admin", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index 6f0cf824684e7..93646ab40137e 100644 --- a/x-pack/plugins/osquery/server/plugin.ts +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -45,7 +45,6 @@ const registerFeatures = (features: SetupPlugins['features']) => { app: [PLUGIN_ID, 'kibana'], catalogue: [PLUGIN_ID], order: 2300, - excludeFromBasePrivileges: true, privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-write`], diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts index effd2f4f81b89..dc7be33ad3739 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -97,6 +97,7 @@ interface SingleSearchAfterAndAudit { track_total_hits?: boolean | undefined; size?: number | undefined; operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get; + sort?: estypes.SortOptions[] | undefined; lastSortIds?: Array | undefined; } @@ -224,6 +225,7 @@ export class AlertsClient { size, index, operation, + sort, lastSortIds = [], }: SingleSearchAfterAndAudit) { try { @@ -243,7 +245,7 @@ export class AlertsClient { _source, track_total_hits: trackTotalHits, size, - sort: [ + sort: sort || [ { '@timestamp': { order: 'asc', @@ -605,6 +607,8 @@ export class AlertsClient { track_total_hits: trackTotalHits, size, index, + sort, + search_after: searchAfter, }: { query?: object | undefined; aggs?: object | undefined; @@ -612,6 +616,8 @@ export class AlertsClient { track_total_hits?: boolean | undefined; _source?: string[] | undefined; size?: number | undefined; + sort?: estypes.SortOptions[] | undefined; + search_after?: Array | undefined; }) { try { // first search for the alert by id, then use the alert info to check if user has access to it @@ -623,6 +629,8 @@ export class AlertsClient { size, index, operation: ReadOperations.Find, + sort, + lastSortIds: searchAfter, }); if (alertsSearchResponse == null) { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts index b818ff183f3e0..00c034b980109 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts @@ -198,6 +198,136 @@ describe('find()', () => { `); }); + test('allows custom sort', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResponseOnce({ + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + // @ts-expect-error incorrect fields + found: true, + _type: 'alert', + _index: '.alerts-observability.apm.alerts', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + }, + }, + ], + }, + }); + const result = await alertsClient.find({ + query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } }, + index: '.alerts-observability.apm.alerts', + sort: [ + { + '@timestamp': 'desc', + }, + ], + }); + expect(result).toMatchInlineSnapshot(` + Object { + "_shards": Object { + "failed": 0, + "skipped": 0, + "successful": 1, + "total": 1, + }, + "hits": Object { + "hits": Array [ + Object { + "_id": "NoxgpHkBqbdrfX07MqXV", + "_index": ".alerts-observability.apm.alerts", + "_primary_term": 2, + "_seq_no": 362, + "_source": Object { + "kibana.alert.rule.consumer": "apm", + "kibana.alert.rule.rule_type_id": "apm.error_rate", + "kibana.alert.workflow_status": "open", + "kibana.space_ids": Array [ + "test_default_space_id", + ], + "message": "hello world 1", + }, + "_type": "alert", + "_version": 1, + "found": true, + }, + ], + "max_score": 999, + "total": 1, + }, + "timed_out": false, + "took": 5, + } + `); + expect(esClientMock.search).toHaveBeenCalledTimes(1); + expect(esClientMock.search.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "_source": undefined, + "aggs": undefined, + "fields": Array [ + "kibana.alert.rule.rule_type_id", + "kibana.alert.rule.consumer", + "kibana.alert.workflow_status", + "kibana.space_ids", + ], + "query": Object { + "bool": Object { + "filter": Array [ + Object {}, + Object { + "term": Object { + "kibana.space_ids": "test_default_space_id", + }, + }, + ], + "must": Array [ + Object { + "match": Object { + "kibana.alert.workflow_status": "open", + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + }, + "size": undefined, + "sort": Array [ + Object { + "@timestamp": "desc", + }, + ], + "track_total_hits": undefined, + }, + "ignore_unavailable": true, + "index": ".alerts-observability.apm.alerts", + "seq_no_primary_term": true, + }, + ] + `); + }); + test('logs successful event in audit logger', async () => { const alertsClient = new AlertsClient(alertsClientParams); esClientMock.search.mockResponseOnce({ diff --git a/x-pack/plugins/rule_registry/server/routes/find.ts b/x-pack/plugins/rule_registry/server/routes/find.ts index 09ce3893f35a1..5cdaad09503c8 100644 --- a/x-pack/plugins/rule_registry/server/routes/find.ts +++ b/x-pack/plugins/rule_registry/server/routes/find.ts @@ -9,6 +9,7 @@ import { IRouter } from '@kbn/core/server'; import * as t from 'io-ts'; import { transformError } from '@kbn/securitysolution-es-utils'; import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; +import { SortOptions } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { RacRequestHandlerContext } from '../types'; import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; @@ -30,6 +31,8 @@ export const findAlertsByQueryRoute = (router: IRouter t.record(t.string, metricsAggsSchemas), t.undefined, ]), + sort: t.union([t.array(t.object), t.undefined]), + search_after: t.union([t.array(t.number), t.array(t.string), t.undefined]), size: t.union([PositiveInteger, t.undefined]), track_total_hits: t.union([t.boolean, t.undefined]), _source: t.union([t.array(t.string), t.undefined]), @@ -44,11 +47,11 @@ export const findAlertsByQueryRoute = (router: IRouter async (context, request, response) => { try { // eslint-disable-next-line @typescript-eslint/naming-convention - const { query, aggs, _source, track_total_hits, size, index } = request.body; + const { query, aggs, _source, track_total_hits, size, index, sort, search_after } = + request.body; const racContext = await context.rac; const alertsClient = await racContext.getAlertsClient(); - const alerts = await alertsClient.find({ query, aggs, @@ -56,6 +59,8 @@ export const findAlertsByQueryRoute = (router: IRouter track_total_hits, size, index, + sort: sort as SortOptions[], + search_after, }); if (alerts == null) { return response.notFound({ diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts index 4df376acb256e..460264e776838 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts @@ -19,7 +19,7 @@ export interface EventSource { } export interface EventsActionGroupData { - key: number; + key: number | string; events: { bucket: EventsMatrixHistogramData[]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/authentication/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/authentication/helpers.tsx index bff5072043527..cd323af0c12c1 100644 --- a/x-pack/plugins/security_solution/public/common/components/authentication/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/authentication/helpers.tsx @@ -97,6 +97,8 @@ const FAILURES_COLUMN: Columns = { operator: IS_OPERATOR, }, }} + isAggregatable={true} + fieldType={'keyword'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( @@ -129,6 +131,8 @@ const LAST_SUCCESSFUL_SOURCE_COLUMN: Columns getRowItemDraggables({ rowItems: node.lastSuccess?.source?.ip || null, + isAggregatable: true, + fieldType: 'ip', attrName: 'source.ip', idPrefix: `authentications-table-${node._id}-lastSuccessSource`, render: (item) => , @@ -141,6 +145,8 @@ const LAST_SUCCESSFUL_DESTINATION_COLUMN: Columns getRowItemDraggables({ rowItems: node.lastSuccess?.host?.name ?? null, + isAggregatable: true, + fieldType: 'keyword', attrName: 'host.name', idPrefix: `authentications-table-${node._id}-lastSuccessfulDestination`, render: (item) => , @@ -164,6 +170,8 @@ const LAST_FAILED_SOURCE_COLUMN: Columns getRowItemDraggables({ rowItems: node.lastFailure?.source?.ip || null, + isAggregatable: true, + fieldType: 'ip', attrName: 'source.ip', idPrefix: `authentications-table-${node._id}-lastFailureSource`, render: (item) => , @@ -179,6 +187,8 @@ const LAST_FAILED_DESTINATION_COLUMN: Columns , + isAggregatable: true, + fieldType: 'ip', }), }; @@ -192,6 +202,8 @@ const getUserColumn = ( getRowItemDraggables({ rowItems: node.stackedValue, attrName: 'user.name', + isAggregatable: true, + fieldType: 'keyword', idPrefix: `authentications-table-${node._id}-userName`, render: (item) => (usersEnabled ? : <>{item}), }), @@ -205,6 +217,8 @@ const HOST_COLUMN: Columns = { getRowItemDraggables({ rowItems: node.stackedValue, attrName: 'host.name', + isAggregatable: true, + fieldType: 'keyword', idPrefix: `authentications-table-${node._id}-hostName`, render: (item) => , }), @@ -234,6 +248,8 @@ const SUCCESS_COLUMN: Columns = { operator: IS_OPERATOR, }, }} + isAggregatable={true} + fieldType="keyword" render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index d7b7b593ab547..738f9f96d67c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -102,6 +102,8 @@ interface Props { hideTopN?: boolean; isDraggable?: boolean; render: RenderFunctionProp; + isAggregatable?: boolean; + fieldType?: string; timelineId?: string; truncate?: boolean; onFilterAdded?: () => void; @@ -131,6 +133,8 @@ const DraggableOnWrapperComponent: React.FC = ({ hideTopN = false, onFilterAdded, render, + fieldType = '', + isAggregatable = false, timelineId, truncate, }) => { @@ -154,6 +158,8 @@ const DraggableOnWrapperComponent: React.FC = ({ hideTopN, onFilterAdded, render, + fieldType, + isAggregatable, timelineId, truncate, }); @@ -313,6 +319,8 @@ const DraggableWrapperComponent: React.FC = ({ isDraggable = false, onFilterAdded, render, + isAggregatable = false, + fieldType = '', timelineId, truncate, }) => { @@ -327,6 +335,8 @@ const DraggableWrapperComponent: React.FC = ({ dataProvider, hideTopN, isDraggable, + isAggregatable, + fieldType, onFilterAdded, render, timelineId, @@ -372,6 +382,8 @@ const DraggableWrapperComponent: React.FC = ({ dataProvider={dataProvider} hideTopN={hideTopN} onFilterAdded={onFilterAdded} + fieldType={fieldType} + isAggregatable={isAggregatable} render={render} timelineId={timelineId} truncate={truncate} diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts index e03c7586ba621..4858b6f2da66c 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts @@ -653,7 +653,8 @@ describe('helpers', () => { test('it returns true for an aggregatable field that is an allowed type', () => { expect( allowTopN({ - browserField: aggregatableAllowedType, + fieldType: 'keyword', + isAggregatable: true, fieldName: aggregatableAllowedType.name, hideTopN: false, }) @@ -663,7 +664,8 @@ describe('helpers', () => { test('it returns true for a allowlisted non-BrowserField', () => { expect( allowTopN({ - browserField: undefined, + fieldType: 'not right', + isAggregatable: false, fieldName: 'kibana.alert.rule.name', hideTopN: false, }) @@ -678,7 +680,8 @@ describe('helpers', () => { expect( allowTopN({ - browserField: nonAggregatableAllowedType, + fieldType: 'keyword', + isAggregatable: false, fieldName: nonAggregatableAllowedType.name, hideTopN: false, }) @@ -693,31 +696,21 @@ describe('helpers', () => { expect( allowTopN({ - browserField: aggregatableNotAllowedType, + fieldType: 'text', + isAggregatable: false, fieldName: aggregatableNotAllowedType.name, hideTopN: false, }) ).toBe(false); }); - test('it returns false if the BrowserField is missing the aggregatable property', () => { - const missingAggregatable = omit('aggregatable', aggregatableAllowedType); - - expect( - allowTopN({ - browserField: missingAggregatable, - fieldName: missingAggregatable.name, - hideTopN: false, - }) - ).toBe(false); - }); - test('it returns false if the BrowserField is missing the type property', () => { const missingType = omit('type', aggregatableAllowedType); expect( allowTopN({ - browserField: missingType, + fieldType: 'not real', + isAggregatable: false, fieldName: missingType.name, hideTopN: false, }) @@ -727,7 +720,8 @@ describe('helpers', () => { test('it returns false for a non-allowlisted field when a BrowserField is not provided', () => { expect( allowTopN({ - browserField: undefined, + fieldType: 'string', + isAggregatable: false, fieldName: 'non-allowlisted', hideTopN: false, }) @@ -737,7 +731,8 @@ describe('helpers', () => { test('it returns false when hideTopN is true', () => { expect( allowTopN({ - browserField: aggregatableAllowedType, + fieldType: 'keyword', + isAggregatable: true, fieldName: aggregatableAllowedType.name, hideTopN: true, // <-- the Top N action shall not be shown for this (otherwise valid) field }) diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index a43ae947a5f12..99af5e2443915 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -9,7 +9,6 @@ import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; import { getProviderIdFromDraggable } from '@kbn/securitysolution-t-grid'; -import { BrowserField } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { addContentToTimeline } from '../../../timelines/components/timeline/data_providers/helpers'; @@ -90,16 +89,16 @@ export const addProviderToTimeline = ({ }; export const allowTopN = ({ - browserField, + isAggregatable, + fieldType, fieldName, hideTopN, }: { - browserField: Partial | undefined; fieldName: string; + isAggregatable: boolean; + fieldType: string; hideTopN: boolean; }): boolean => { - const isAggregatable = browserField?.aggregatable ?? false; - const fieldType = browserField?.type ?? ''; const isAllowedType = [ 'boolean', 'geo-point', diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/draggables/__snapshots__/index.test.tsx.snap index 3cbb0d27a0e2f..72956a3f89312 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/draggables/__snapshots__/index.test.tsx.snap @@ -36,7 +36,9 @@ exports[`draggables rendering it renders the default DefaultDraggable 1`] = ` }, } } + fieldType="" hideTopN={false} + isAggregatable={false} isDraggable={true} render={[Function]} /> diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index c9e67e8181d28..1c98d385d671c 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -23,6 +23,8 @@ export interface DefaultDraggableType { hideTopN?: boolean; id: string; isDraggable?: boolean; + fieldType?: string; + isAggregatable?: boolean; field: string; value?: string | number | null; name?: string | null; @@ -102,6 +104,8 @@ export const DefaultDraggable = React.memo( id, isDraggable = true, field, + fieldType = '', + isAggregatable = false, value, name, children, @@ -151,6 +155,8 @@ export const DefaultDraggable = React.memo( return ( = ({ value, iconType, isDraggable, + isAggregatable, + fieldType, name, color = 'hollow', children, @@ -208,6 +216,8 @@ const DraggableBadgeComponent: React.FC = ({ theme.eui.euiScrollBar}; width: ${({ theme }) => theme.eui.euiScrollBar}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx index d8081414cdacf..7e46948cef190 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx @@ -51,6 +51,8 @@ export const ActionCell: React.FC = React.memo( values, }); + const { aggregatable, type } = fieldFromBrowserField || { aggregatable: false, type: '' }; + const [showTopN, setShowTopN] = useState(false); const { timelineId: timelineIdFind } = useContext(TimelineContext); const [hoverActionsOwnFocus] = useState(false); @@ -74,6 +76,8 @@ export const ActionCell: React.FC = React.memo( dataProvider={actionCellConfig?.dataProvider} enableOverflowButton={true} field={data.field} + isAggregatable={aggregatable} + fieldType={type} hideAddToTimeline={hideAddToTimeline} isObjectArray={data.isObjectArray} onFilterAdded={onFilterAdded} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index cab7a58243cca..2be7b4071f15a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -72,6 +72,7 @@ export const FieldValueCell = React.memo( fieldFormat={data.format} fieldName={data.field} fieldType={data.type} + isAggregatable={fieldFromBrowserField.aggregatable} isDraggable={isDraggable} isObjectArray={data.isObjectArray} value={value} diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx index ca526bf39264f..731b4bb85ba42 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx @@ -96,6 +96,8 @@ interface Props { draggableId?: DraggableId; enableOverflowButton?: boolean; field: string; + fieldType: string; + isAggregatable: boolean; goGetTimelineId?: (args: boolean) => void; hideAddToTimeline?: boolean; hideTopN?: boolean; @@ -136,6 +138,8 @@ export const HoverActions: React.FC = React.memo( enableOverflowButton = false, applyWidthAndPadding = true, field, + fieldType, + isAggregatable, goGetTimelineId, isObjectArray, hideAddToTimeline = false, @@ -219,6 +223,8 @@ export const HoverActions: React.FC = React.memo( draggableId, enableOverflowButton: enableOverflowButton && !isCaseView, field, + fieldType, + isAggregatable, handleHoverActionClicked, hideAddToTimeline, hideTopN, diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx index 71989f9673907..e62f605afca2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx @@ -13,12 +13,9 @@ import { isEmpty } from 'lodash'; import { FilterManager } from '@kbn/data-plugin/public'; import { useKibana } from '../../lib/kibana'; -import { getAllFieldsByName } from '../../containers/source'; import { allowTopN } from '../drag_and_drop/helpers'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { ColumnHeaderOptions, DataProvider, TimelineId } from '../../../../common/types/timeline'; -import { SourcererScopeName } from '../../store/sourcerer/model'; -import { useSourcererDataView } from '../../containers/sourcerer'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { ShowTopNButton } from './actions/show_top_n'; @@ -29,6 +26,8 @@ export interface UseHoverActionItemsProps { draggableId?: DraggableId; enableOverflowButton?: boolean; field: string; + fieldType: string; + isAggregatable: boolean; handleHoverActionClicked: () => void; hideAddToTimeline: boolean; hideTopN: boolean; @@ -59,6 +58,8 @@ export const useHoverActionItems = ({ draggableId, enableOverflowButton, field, + fieldType, + isAggregatable, handleHoverActionClicked, hideTopN, hideAddToTimeline, @@ -103,23 +104,6 @@ export const useHoverActionItems = ({ [uiSettings, timelineId, activeFilterManager, filterManagerBackup] ); - // Regarding data from useManageTimeline: - // * `indexToAdd`, which enables the alerts index to be appended to - // the `indexPattern` returned by `useWithSource`, may only be populated when - // this component is rendered in the context of the active timeline. This - // behavior enables the 'All events' view by appending the alerts index - // to the index pattern. - const activeScope: SourcererScopeName = - timelineId === TimelineId.active - ? SourcererScopeName.timeline - : timelineId != null && - [TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage].includes( - timelineId as TimelineId - ) - ? SourcererScopeName.detections - : SourcererScopeName.default; - const { browserFields } = useSourcererDataView(activeScope); - /* * In the case of `DisableOverflowButton`, we show filters only when topN is NOT opened. As after topN button is clicked, the chart panel replace current hover actions in the hover actions' popover, so we have to hide all the actions. * in the case of `EnableOverflowButton`, we only need to hide all the items in the overflow popover as the chart's panel opens in the overflow popover, so non-overflowed actions are not affected. @@ -222,7 +206,8 @@ export const useHoverActionItems = ({

) : null, allowTopN({ - browserField: getAllFieldsByName(browserFields)[field], + fieldType, + isAggregatable, fieldName: field, hideTopN, }) @@ -246,13 +231,14 @@ export const useHoverActionItems = ({ return item != null; }), [ - browserFields, dataProvider, dataType, defaultFocusedButtonRef, draggableId, enableOverflowButton, field, + fieldType, + isAggregatable, filterManager, getAddToTimelineButton, getColumnToggleButton, diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx index 13ade71212a22..c5198621541a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx @@ -27,6 +27,8 @@ type RenderFunctionProp = ( interface Props { dataProvider: DataProvider; + isAggregatable: boolean; + fieldType: string; disabled?: boolean; hideTopN: boolean; isDraggable?: boolean; @@ -39,6 +41,8 @@ interface Props { export const useHoverActions = ({ dataProvider, + isAggregatable, + fieldType, hideTopN, isDraggable, onFilterAdded, @@ -102,6 +106,8 @@ export const useHoverActions = ({ dataProvider={dataProvider} draggableId={isDraggable ? getDraggableId(dataProvider.id) : undefined} field={dataProvider.queryMatch.field} + isAggregatable={isAggregatable} + fieldType={fieldType} hideTopN={hideTopN} isObjectArray={false} onFilterAdded={onFilterAdded} @@ -120,9 +126,11 @@ export const useHoverActions = ({ }, [ closeTopN, dataProvider, + fieldType, handleClosePopOverTrigger, hideTopN, hoverActionsOwnFocus, + isAggregatable, isDraggable, onFilterAdded, render, diff --git a/x-pack/plugins/security_solution/public/common/components/ml/__snapshots__/entity_draggable.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml/__snapshots__/entity_draggable.test.tsx.snap index 44c1e717afaa0..eaa7721271d98 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/__snapshots__/entity_draggable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml/__snapshots__/entity_draggable.test.tsx.snap @@ -17,6 +17,8 @@ exports[`entity_draggable renders correctly against snapshot 1`] = ` }, } } + fieldType="keyword" + isAggregatable={true} key="entity-draggable-id-prefix-entity-name-entity-value" render={[Function]} /> diff --git a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.tsx b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.tsx index 48dc366ee0ddc..6d3302d06ade3 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.tsx @@ -56,7 +56,15 @@ export const EntityDraggableComponent: React.FC = ({ [entityName, entityValue] ); - return ; + return ( + + ); }; EntityDraggableComponent.displayName = 'EntityDraggableComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/draggable_score.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/draggable_score.test.tsx.snap index 123fa72b1cfff..392c0bd4ba606 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/draggable_score.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/__snapshots__/draggable_score.test.tsx.snap @@ -17,6 +17,8 @@ exports[`draggable_score renders correctly against snapshot 1`] = ` }, } } + fieldType="keyword" + isAggregatable={true} key="draggable-score-draggable-wrapper-some-id" render={[Function]} /> @@ -39,6 +41,8 @@ exports[`draggable_score renders correctly against snapshot when the index is no }, } } + fieldType="keyword" + isAggregatable={true} key="draggable-score-draggable-wrapper-some-id" render={[Function]} /> diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.tsx index 4b1dbeab714a9..2f18d3a7c76c5 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.tsx @@ -69,6 +69,8 @@ export const DraggableScoreComponent = ({ key={`draggable-score-draggable-wrapper-${id}`} dataProvider={dataProviderProp} render={render} + isAggregatable={true} + fieldType="keyword" /> ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.tsx index 7ee0b3ca88469..a207e30d562e9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.tsx @@ -38,6 +38,8 @@ export const getAnomaliesHostTableColumns = ( anomaliesByHost.anomaly )}-hostName`, render: (item) => , + isAggregatable: true, + fieldType: 'keyword', }), }, ...getAnomaliesDefaultTableColumns(startDate, endDate), diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.tsx index 44c6a193c30fd..0a1e257aa87de 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.tsx @@ -42,6 +42,8 @@ export const getAnomaliesNetworkTableColumns = ( anomaliesByNetwork.anomaly )}`, render: (item) => , + isAggregatable: true, + fieldType: 'ip', }), }, ...getAnomaliesDefaultTableColumns(startDate, endDate), diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_user_table_columns.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_user_table_columns.tsx index 6bc9aefecfae1..ff0d023eaf03a 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_user_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_user_table_columns.tsx @@ -39,6 +39,8 @@ export const getAnomaliesUserTableColumns = ( anomaliesByUser.anomaly )}-userName`, render: (item) => , + isAggregatable: true, + fieldType: 'keyword', }), }, ...getAnomaliesDefaultTableColumns(startDate, endDate), diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts new file mode 100644 index 0000000000000..ff7aa7581fc4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { SecurityPageName } from '../../../app/types'; +import { NavLinkItem } from '../../links/types'; +import { TestProviders } from '../../mock'; +import { useAppNavLinks, useAppRootNavLink } from './nav_links'; + +const mockNavLinks = [ + { + description: 'description', + id: SecurityPageName.administration, + links: [ + { + description: 'description 2', + id: SecurityPageName.endpoints, + links: [], + path: '/path_2', + title: 'title 2', + }, + ], + path: '/path', + title: 'title', + }, +]; + +jest.mock('../../links', () => ({ + getNavLinkItems: () => mockNavLinks, +})); + +const renderUseAppNavLinks = () => + renderHook<{}, NavLinkItem[]>(() => useAppNavLinks(), { wrapper: TestProviders }); + +const renderUseAppRootNavLink = (id: SecurityPageName) => + renderHook<{ id: SecurityPageName }, NavLinkItem | undefined>(() => useAppRootNavLink(id), { + wrapper: TestProviders, + }); + +describe('useAppNavLinks', () => { + it('should return all nav links', () => { + const { result } = renderUseAppNavLinks(); + expect(result.current).toEqual(mockNavLinks); + }); + + it('should return a root nav links', () => { + const { result } = renderUseAppRootNavLink(SecurityPageName.administration); + expect(result.current).toEqual(mockNavLinks[0]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts new file mode 100644 index 0000000000000..efdf72a1f7926 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from '../../lib/kibana'; +import { useEnableExperimental } from '../../hooks/use_experimental_features'; +import { useLicense } from '../../hooks/use_license'; +import { getNavLinkItems } from '../../links'; +import type { SecurityPageName } from '../../../app/types'; +import type { NavLinkItem } from '../../links/types'; + +export const useAppNavLinks = (): NavLinkItem[] => { + const license = useLicense(); + const enableExperimental = useEnableExperimental(); + const capabilities = useKibana().services.application.capabilities; + + return getNavLinkItems({ enableExperimental, license, capabilities }); +}; + +export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { + return useAppNavLinks().find(({ id }) => id === linkId); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index bc20a98eae1e8..91edd1feea2da 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -76,3 +76,10 @@ export type GetUrlForApp = ( ) => string; export type NavigateToUrl = (url: string) => void; + +export interface NavigationCategory { + label: string; + linkIds: readonly SecurityPageName[]; +} + +export type NavigationCategories = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..cadb9057ccbcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -0,0 +1,170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useSecuritySolutionNavigation should create navigation config 1`] = ` +Object { + "icon": "logoSecurity", + "items": Array [ + Object { + "id": "main", + "items": Array [ + Object { + "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-get_started", + "disabled": false, + "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "get_started", + "isSelected": false, + "name": "Getting started", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-overview", + "disabled": false, + "href": "securitySolutionUI/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "overview", + "isSelected": false, + "name": "Overview", + "onClick": [Function], + }, + ], + "name": "", + }, + Object { + "id": "detect", + "items": Array [ + Object { + "data-href": "securitySolutionUI/alerts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-alerts", + "disabled": false, + "href": "securitySolutionUI/alerts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "alerts", + "isSelected": false, + "name": "Alerts", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/rules?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-rules", + "disabled": false, + "href": "securitySolutionUI/rules?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "rules", + "isSelected": false, + "name": "Rules", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/exceptions?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-exceptions", + "disabled": false, + "href": "securitySolutionUI/exceptions?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "exceptions", + "isSelected": false, + "name": "Exception lists", + "onClick": [Function], + }, + ], + "name": "Detect", + }, + Object { + "id": "explore", + "items": Array [ + Object { + "data-href": "securitySolutionUI/hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-hosts", + "disabled": false, + "href": "securitySolutionUI/hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "hosts", + "isSelected": true, + "name": "Hosts", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-network", + "disabled": false, + "href": "securitySolutionUI/network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "network", + "isSelected": false, + "name": "Network", + "onClick": [Function], + }, + ], + "name": "Explore", + }, + Object { + "id": "investigate", + "items": Array [ + Object { + "data-href": "securitySolutionUI/timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-timelines", + "disabled": false, + "href": "securitySolutionUI/timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "timelines", + "isSelected": false, + "name": "Timelines", + "onClick": [Function], + }, + ], + "name": "Investigate", + }, + Object { + "id": "manage", + "items": Array [ + Object { + "data-href": "securitySolutionUI/endpoints", + "data-test-subj": "navigation-endpoints", + "disabled": false, + "href": "securitySolutionUI/endpoints", + "id": "endpoints", + "isSelected": false, + "name": "Endpoints", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/trusted_apps", + "data-test-subj": "navigation-trusted_apps", + "disabled": false, + "href": "securitySolutionUI/trusted_apps", + "id": "trusted_apps", + "isSelected": false, + "name": "Trusted applications", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/event_filters", + "data-test-subj": "navigation-event_filters", + "disabled": false, + "href": "securitySolutionUI/event_filters", + "id": "event_filters", + "isSelected": false, + "name": "Event filters", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/host_isolation_exceptions", + "data-test-subj": "navigation-host_isolation_exceptions", + "disabled": false, + "href": "securitySolutionUI/host_isolation_exceptions", + "id": "host_isolation_exceptions", + "isSelected": false, + "name": "Host isolation exceptions", + "onClick": [Function], + }, + Object { + "data-href": "securitySolutionUI/blocklist", + "data-test-subj": "navigation-blocklist", + "disabled": false, + "href": "securitySolutionUI/blocklist", + "id": "blocklist", + "isSelected": false, + "name": "Blocklist", + "onClick": [Function], + }, + ], + "name": "Manage", + }, + ], + "name": "Security", +} +`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index c515f43ee181d..bba345c325d0f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -108,174 +108,7 @@ describe('useSecuritySolutionNavigation', () => { { wrapper: TestProviders } ); - expect(result.current).toMatchInlineSnapshot(` - Object { - "icon": "logoSecurity", - "items": Array [ - Object { - "id": "main", - "items": Array [ - Object { - "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-get_started", - "disabled": false, - "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "get_started", - "isSelected": false, - "name": "Getting started", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-overview", - "disabled": false, - "href": "securitySolutionUI/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "overview", - "isSelected": false, - "name": "Overview", - "onClick": [Function], - }, - ], - "name": "", - }, - Object { - "id": "detect", - "items": Array [ - Object { - "data-href": "securitySolutionUI/alerts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-alerts", - "disabled": false, - "href": "securitySolutionUI/alerts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "alerts", - "isSelected": false, - "name": "Alerts", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/rules?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-rules", - "disabled": false, - "href": "securitySolutionUI/rules?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "rules", - "isSelected": false, - "name": "Rules", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/exceptions?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-exceptions", - "disabled": false, - "href": "securitySolutionUI/exceptions?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "exceptions", - "isSelected": false, - "name": "Exception lists", - "onClick": [Function], - }, - ], - "name": "Detect", - }, - Object { - "id": "explore", - "items": Array [ - Object { - "data-href": "securitySolutionUI/hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-hosts", - "disabled": false, - "href": "securitySolutionUI/hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "hosts", - "isSelected": true, - "name": "Hosts", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-network", - "disabled": false, - "href": "securitySolutionUI/network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "network", - "isSelected": false, - "name": "Network", - "onClick": [Function], - }, - ], - "name": "Explore", - }, - Object { - "id": "investigate", - "items": Array [ - Object { - "data-href": "securitySolutionUI/timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "data-test-subj": "navigation-timelines", - "disabled": false, - "href": "securitySolutionUI/timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", - "id": "timelines", - "isSelected": false, - "name": "Timelines", - "onClick": [Function], - }, - ], - "name": "Investigate", - }, - Object { - "id": "manage", - "items": Array [ - Object { - "data-href": "securitySolutionUI/endpoints", - "data-test-subj": "navigation-endpoints", - "disabled": false, - "href": "securitySolutionUI/endpoints", - "id": "endpoints", - "isSelected": false, - "name": "Endpoints", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/trusted_apps", - "data-test-subj": "navigation-trusted_apps", - "disabled": false, - "href": "securitySolutionUI/trusted_apps", - "id": "trusted_apps", - "isSelected": false, - "name": "Trusted applications", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/event_filters", - "data-test-subj": "navigation-event_filters", - "disabled": false, - "href": "securitySolutionUI/event_filters", - "id": "event_filters", - "isSelected": false, - "name": "Event filters", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/host_isolation_exceptions", - "data-test-subj": "navigation-host_isolation_exceptions", - "disabled": false, - "href": "securitySolutionUI/host_isolation_exceptions", - "id": "host_isolation_exceptions", - "isSelected": false, - "name": "Host isolation exceptions", - "onClick": [Function], - }, - Object { - "data-href": "securitySolutionUI/blocklist", - "data-test-subj": "navigation-blocklist", - "disabled": false, - "href": "securitySolutionUI/blocklist", - "id": "blocklist", - "isSelected": false, - "name": "Blocklist", - "onClick": [Function], - }, - ], - "name": "Manage", - }, - ], - "name": "Security", - } - `); + expect(result.current).toMatchSnapshot(); }); // TODO: Steph/users remove when no longer experimental diff --git a/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap index 009482e043f5e..27aa4fdafcea3 100644 --- a/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap @@ -103,6 +103,8 @@ exports[`Table Helpers #getRowItemDraggables it returns correctly against snapsh }, } } + fieldType="keyword" + isAggregatable={false} key="idPrefix-attrName-item1-0" render={[Function]} /> @@ -123,6 +125,8 @@ exports[`Table Helpers #getRowItemDraggables it returns correctly against snapsh }, } } + fieldType="keyword" + isAggregatable={false} key="idPrefix-attrName-item2-1" render={[Function]} /> @@ -143,13 +147,17 @@ exports[`Table Helpers #getRowItemDraggables it returns correctly against snapsh }, } } + fieldType="keyword" + isAggregatable={false} key="idPrefix-attrName-item3-2" render={[Function]} /> JSX.Element; + fieldType?: string; + isAggregatable?: boolean; displayCount?: number; dragDisplayValue?: string; maxOverflow?: number; @@ -45,6 +47,8 @@ export const getRowItemDraggable = ({ rowItem, attrName, idPrefix, + fieldType, + isAggregatable, render, dragDisplayValue, }: GetRowItemDraggableParams): JSX.Element => { @@ -67,6 +71,8 @@ export const getRowItemDraggable = ({ operator: IS_OPERATOR, }, }} + fieldType={fieldType} + isAggregatable={isAggregatable} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( @@ -88,6 +94,8 @@ interface GetRowItemDraggablesParams { attrName: string; idPrefix: string; render?: (item: string) => JSX.Element; + fieldType?: string; + isAggregatable?: boolean; displayCount?: number; dragDisplayValue?: string; maxOverflow?: number; @@ -98,6 +106,8 @@ export const getRowItemDraggables = ({ idPrefix, render, dragDisplayValue, + fieldType = 'keyword', + isAggregatable = false, displayCount = 5, maxOverflow = 5, }: GetRowItemDraggablesParams): JSX.Element => { @@ -122,6 +132,8 @@ export const getRowItemDraggables = ({ operator: IS_OPERATOR, }, }} + fieldType={fieldType} + isAggregatable={isAggregatable} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( @@ -146,6 +158,8 @@ export const getRowItemDraggables = ({ maxOverflowItems={maxOverflow} overflowIndexStart={displayCount} rowItems={rowItems} + fieldType={fieldType} + isAggregatable={isAggregatable} /> ) : ( @@ -161,12 +175,16 @@ interface OverflowItemProps { dragDisplayValue?: string; field: string; rowItem: string; + fieldType?: string; + isAggregatable?: boolean; } export const OverflowItemComponent: React.FC = ({ dataProvider, dragDisplayValue, field, + fieldType = '', + isAggregatable = false, rowItem, }) => { const [showTopN, setShowTopN] = useState(false); @@ -196,6 +214,8 @@ export const OverflowItemComponent: React.FC = ({ closeTopN={closeTopN} dataProvider={dataProvider} field={field} + fieldType={fieldType} + isAggregatable={isAggregatable} isObjectArray={false} ownFocus={hoverActionsOwnFocus} showOwnFocus={false} @@ -219,6 +239,8 @@ interface RowItemOverflowProps { maxOverflowItems: number; overflowIndexStart: number; rowItems: string[]; + fieldType?: string; + isAggregatable?: boolean; } export const RowItemOverflowComponent: React.FC = ({ @@ -228,6 +250,8 @@ export const RowItemOverflowComponent: React.FC = ({ maxOverflowItems = 5, overflowIndexStart = 5, rowItems, + fieldType, + isAggregatable, }) => { const overflowItems = useMemo( () => @@ -257,11 +281,22 @@ export const RowItemOverflowComponent: React.FC = ({ dragDisplayValue={dragDisplayValue} rowItem={rowItem} field={attrName} + fieldType={fieldType} + isAggregatable={isAggregatable} />
); }), - [attrName, dragDisplayValue, idPrefix, maxOverflowItems, overflowIndexStart, rowItems] + [ + attrName, + dragDisplayValue, + idPrefix, + maxOverflowItems, + overflowIndexStart, + rowItems, + fieldType, + isAggregatable, + ] ); return ( <> diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts index 3132ae70381a2..1cc2506ec3996 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -14,14 +14,16 @@ import { const allowedExperimentalValues = getExperimentalAllowedValues(); -export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => - useSelector(({ app: { enableExperimental } }: State) => { - if (!enableExperimental || !(feature in enableExperimental)) { - throw new Error( - `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( - ', ' - )}` - ); - } - return enableExperimental[feature]; - }); +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { + const enableExperimental = useEnableExperimental(); + + if (!enableExperimental || !(feature in enableExperimental)) { + throw new Error( + `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join(', ')}` + ); + } + return enableExperimental[feature]; +}; + +export const useEnableExperimental = (): ExperimentalFeatures => + useSelector(({ app: { enableExperimental } }: State) => enableExperimental); diff --git a/x-pack/plugins/security_solution/public/common/images/detection_response_page.png b/x-pack/plugins/security_solution/public/common/images/detection_response_page.png new file mode 100644 index 0000000000000..630cd55559843 Binary files /dev/null and b/x-pack/plugins/security_solution/public/common/images/detection_response_page.png differ diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx index d9511f89c9c81..55a978a84d25b 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx @@ -324,6 +324,7 @@ export const cellActions: TGridCellAction[] = [ contextId={`expanded-value-${columnId}-row-${pageRowIndex}-${timelineId}`} eventId={eventId} fieldFormat={fieldFormat} + isAggregatable={header.aggregatable ?? false} fieldName={fieldName} fieldType={fieldType} isButton={true} diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx index c818b16ac1f4f..15776fd165e93 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx @@ -8,26 +8,18 @@ import { shallow } from 'enzyme'; import React from 'react'; import { ExpandedCellValueActions } from './expanded_cell_value_actions'; +import { ColumnHeaderType } from '@kbn/timelines-plugin/common/types'; jest.mock('../kibana'); describe('ExpandedCellValueActions', () => { const props = { - browserFields: { - host: { - fields: { - 'host.name': { - aggregatable: true, - category: 'host', - description: - 'Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', - type: 'string', - name: 'host.name', - }, - }, - }, + field: { + id: 'host.name', + type: 'keyword', + columnHeaderType: 'not-filtered' as ColumnHeaderType, + aggregatable: true, }, - field: 'host.name', globalFilters: [], onFilterAdded: () => {}, timelineId: 'mockTimelineId', diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx index 5a238c19511ad..4062f314d8658 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx @@ -10,16 +10,14 @@ import { noop } from 'lodash/fp'; import React, { useMemo, useState, useCallback } from 'react'; import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; -import { BrowserFields } from '@kbn/timelines-plugin/common/search_strategy'; +import { ColumnHeaderOptions } from '@kbn/timelines-plugin/common/types'; import { allowTopN } from '../../components/drag_and_drop/helpers'; import { ShowTopNButton } from '../../components/hover_actions/actions/show_top_n'; -import { getAllFieldsByName } from '../../containers/source'; import { useKibana } from '../kibana'; import { SHOW_TOP_VALUES, HIDE_TOP_VALUES } from './translations'; interface Props { - browserFields: BrowserFields; - field: string; + field: ColumnHeaderOptions; globalFilters?: Filter[]; timelineId: string; value: string[] | undefined; @@ -37,7 +35,6 @@ export const StyledContent = styled.div<{ $isDetails: boolean }>` `; const ExpandedCellValueActionsComponent: React.FC = ({ - browserFields, field, globalFilters, onFilterAdded, @@ -53,11 +50,12 @@ const ExpandedCellValueActionsComponent: React.FC = ({ const showButton = useMemo( () => allowTopN({ - browserField: getAllFieldsByName(browserFields)[field], - fieldName: field, + fieldName: field.id, + fieldType: field.type ?? '', + isAggregatable: field.aggregatable ?? false, hideTopN: false, }), - [browserFields, field] + [field] ); const [showTopN, setShowTopN] = useState(false); @@ -71,7 +69,7 @@ const ExpandedCellValueActionsComponent: React.FC = ({ className="eui-displayBlock expandable-top-value-button" Component={EuiButtonEmpty} data-test-subj="data-grid-expanded-show-top-n" - field={field} + field={field.id} flush="both" globalFilters={globalFilters} iconSide="right" @@ -94,7 +92,7 @@ const ExpandedCellValueActionsComponent: React.FC = ({ {timelines.getHoverActions().getFilterForValueButton({ Component: EuiButtonEmpty, - field, + field: field.id, filterManager, onFilterAdded, ownFocus: false, @@ -106,7 +104,7 @@ const ExpandedCellValueActionsComponent: React.FC = ({ {timelines.getHoverActions().getFilterOutValueButton({ Component: EuiButtonEmpty, - field, + field: field.id, filterManager, onFilterAdded, ownFocus: false, diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index d8f6711cfc629..b86b05f48607d 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -94,6 +94,27 @@ const mockLicense = { isAtLeast: licensePremiumMock, } as unknown as LicenseService; +const threatHuntingLinkInfo = { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', +}; + +const hostsLinkInfo = { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + landingImage: 'test-file-stub', + description: 'A comprehensive overview of all hosts and host-related security events.', +}; + describe('security app link helpers', () => { beforeEach(() => { mockLicense.isAtLeast = licensePremiumMock; @@ -344,46 +365,13 @@ describe('security app link helpers', () => { describe('getAncestorLinksInfo', () => { it('finds flattened links for hosts', () => { const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); - expect(hierarchy).toEqual([ - { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat-hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - }, - { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - }, - ]); + expect(hierarchy).toEqual([threatHuntingLinkInfo, hostsLinkInfo]); }); it('finds flattened links for uncommonProcesses', () => { const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); expect(hierarchy).toEqual([ - { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat-hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - }, - { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - }, + threatHuntingLinkInfo, + hostsLinkInfo, { id: 'uncommon_processes', path: '/hosts/uncommonProcesses', @@ -407,15 +395,7 @@ describe('security app link helpers', () => { describe('getLinkInfo', () => { it('gets information for an individual link', () => { const linkInfo = getLinkInfo(SecurityPageName.hosts); - expect(linkInfo).toEqual({ - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - }); + expect(linkInfo).toEqual(hostsLinkInfo); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 290a1f3fbd820..af9357a122a1e 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -32,8 +32,6 @@ const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLin }), } : {}), - ...(link.icon != null ? { euiIconType: link.icon } : {}), - ...(link.image != null ? { icon: link.image } : {}), ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), ...(link.globalNavEnabled != null ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } @@ -47,8 +45,8 @@ const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLink path: link.path, title: link.title, ...(link.description != null ? { description: link.description } : {}), - ...(link.icon != null ? { icon: link.icon } : {}), - ...(link.image != null ? { image: link.image } : {}), + ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), + ...(link.landingImage != null ? { image: link.landingImage } : {}), ...(link.links && link.links.length ? { links: reduceLinks({ diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index eea348b3df737..320c38d1d229b 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -7,6 +7,7 @@ import { Capabilities } from '@kbn/core/types'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { IconType } from '@elastic/eui'; import { LicenseService } from '../../../common/license'; import { ExperimentalFeatures } from '../../../common/experimental_features'; import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; @@ -41,9 +42,17 @@ export interface LinkItem { globalSearchEnabled?: boolean; globalSearchKeywords?: string[]; hideWhenExperimentalKey?: keyof ExperimentalFeatures; - icon?: string; id: SecurityPageName; - image?: string; + /** + * Icon that is displayed on menu navigation landing page. + * Only required for pages that are displayed inside a landing page. + */ + landingIcon?: IconType; + /** + * Image that is displayed on menu navigation landing page. + * Only required for pages that are displayed inside a landing page. + */ + landingImage?: string; isBeta?: boolean; licenseType?: LicenseType; links?: LinkItem[]; @@ -54,7 +63,7 @@ export interface LinkItem { export interface NavLinkItem { description?: string; - icon?: string; + icon?: IconType; id: SecurityPageName; links?: NavLinkItem[]; image?: string; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.test.tsx index b459f13a85480..4aa68e524f846 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.test.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { showInitialLoadingSpinner } from './helpers'; +import { formatAlertsData, showInitialLoadingSpinner } from './helpers'; +import { result, textResult, stackedByBooleanField, stackedByTextField } from './mock_data'; describe('helpers', () => { describe('showInitialLoadingSpinner', () => { @@ -34,3 +35,15 @@ describe('helpers', () => { }); }); }); + +describe('formatAlertsData', () => { + test('stack by a boolean field', () => { + const res = formatAlertsData(stackedByBooleanField); + expect(res).toEqual(result); + }); + + test('stack by a text field', () => { + const res = formatAlertsData(stackedByTextField); + expect(res).toEqual(textResult); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx index 1f5c67c61b9e2..a20a3e1c37e83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx @@ -17,19 +17,22 @@ const EMPTY_ALERTS_DATA: HistogramData[] = []; export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggregation> | null) => { const groupBuckets: AlertsGroupBucket[] = alertsData?.aggregations?.alertsByGrouping?.buckets ?? []; - return groupBuckets.reduce((acc, { key: group, alerts }) => { - const alertsBucket: AlertsBucket[] = alerts.buckets ?? []; + return groupBuckets.reduce( + (acc, { key_as_string: keyAsString, key: group, alerts }) => { + const alertsBucket: AlertsBucket[] = alerts.buckets ?? []; - return [ - ...acc, - // eslint-disable-next-line @typescript-eslint/naming-convention - ...alertsBucket.map(({ key, doc_count }: AlertsBucket) => ({ - x: key, - y: doc_count, - g: group, - })), - ]; - }, EMPTY_ALERTS_DATA); + return [ + ...acc, + // eslint-disable-next-line @typescript-eslint/naming-convention + ...alertsBucket.map(({ key, doc_count }: AlertsBucket) => ({ + x: key, + y: doc_count, + g: keyAsString ?? group.toString(), + })), + ]; + }, + EMPTY_ALERTS_DATA + ); }; export const getAlertsHistogramQuery = ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 297027fdb9ab6..63d3bbeed6785 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -186,7 +186,7 @@ export const AlertsHistogramPanel = memo( ), field: selectedStackByOption, timelineId, - value: bucket.key, + value: bucket?.key_as_string ?? bucket.key, })) : NO_LEGEND_DATA, [ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts new file mode 100644 index 0000000000000..6e5551eb69201 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const stackedByBooleanField = { + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + total: { + value: 3, + relation: 'eq', + }, + hits: [], + }, + timeout: false, + aggregations: { + alertsByGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 1, + key_as_string: 'true', + doc_count: 2683, + alerts: { + buckets: [ + { key_as_string: '2022-05-10T15:34:48.075Z', key: 1652196888075, doc_count: 0 }, + { key_as_string: '2022-05-10T16:19:48.074Z', key: 1652199588074, doc_count: 0 }, + { key_as_string: '2022-05-10T17:04:48.073Z', key: 1652202288073, doc_count: 0 }, + ], + }, + }, + ], + }, + }, +}; + +export const result = [ + { x: 1652196888075, y: 0, g: 'true' }, + { x: 1652199588074, y: 0, g: 'true' }, + { x: 1652202288073, y: 0, g: 'true' }, +]; + +export const stackedByTextField = { + took: 1, + timeout: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + total: { + value: 3, + relation: 'eq', + }, + hits: [], + }, + aggregations: { + alertsByGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'MacBook-Pro.local', + doc_count: 2706, + alerts: { + buckets: [ + { key_as_string: '2022-05-10T15:34:48.075Z', key: 1652196888075, doc_count: 0 }, + { key_as_string: '2022-05-10T16:19:48.074Z', key: 1652199588074, doc_count: 0 }, + { key_as_string: '2022-05-10T17:04:48.073Z', key: 1652202288073, doc_count: 0 }, + ], + }, + }, + ], + }, + }, +}; + +export const textResult = [ + { x: 1652196888075, y: 0, g: 'MacBook-Pro.local' }, + { x: 1652199588074, y: 0, g: 'MacBook-Pro.local' }, + { x: 1652202288073, y: 0, g: 'MacBook-Pro.local' }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts index 8c2a53dc23d43..433fee1716a47 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts @@ -26,7 +26,8 @@ export interface AlertsBucket { } export interface AlertsGroupBucket { - key: string; + key: string | number; + key_as_string?: string; alerts: { buckets: AlertsBucket[]; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index 8cb29901abdad..674bcdab5e415 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -188,6 +188,36 @@ describe('InvestigateInResolverAction', () => { expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); }); + + test('it enables AddEndpointEventFilter when timeline id is user events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false); + }); + + test('it disables AddEndpointEventFilter when timeline id is user events page but is not from endpoint', () => { + const customProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, + }; + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); }); describe('when users can NOT access endpoint management', () => { beforeEach(() => { @@ -209,6 +239,19 @@ describe('InvestigateInResolverAction', () => { expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); }); + + test('it disables AddEndpointEventFilter when timeline id is user events page but cannot acces endpoint management', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 1427b2b3bf388..160252f4d11c1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -91,6 +91,10 @@ const AlertContextMenuComponent: React.FC ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]); const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); + const timelineIdAllowsAddEndpointEventFilter = useMemo( + () => timelineId === TimelineId.hostsPageEvents || timelineId === TimelineId.usersPageEvents, + [timelineId] + ); const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); @@ -177,13 +181,10 @@ const AlertContextMenuComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx index cfa2c20ec0604..316fadc8bd197 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/columns.tsx @@ -49,6 +49,8 @@ export const getHostRiskScoreColumns = ({ kqlQuery: '', queryMatch: { field: 'host.name', value: hostName, operator: IS_OPERATOR }, }} + isAggregatable={true} + fieldType={'keyword'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx index f4da6983fc590..bbbda683bb869 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx @@ -151,7 +151,7 @@ const HostRiskScoreTableComponent: React.FC = ({ const headerTitle = ( - {i18nHosts.HOSTS_BY_RISK} + {i18nHosts.HOST_RISK_TITLE} snapshot.isDragging ? ( diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx index cbdae1747e5f6..bb5d9b75a66a3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx @@ -153,6 +153,8 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [ rowItems: node.process.name, attrName: 'process.name', idPrefix: `uncommon-process-table-${node._id}-processName`, + isAggregatable: true, + fieldType: 'keyword', }), width: '20%', }, @@ -182,6 +184,8 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [ attrName: 'host.name', idPrefix: `uncommon-process-table-${node._id}-processHost`, render: (item) => , + isAggregatable: true, + fieldType: 'keyword', }), width: '25%', }, @@ -195,6 +199,8 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [ attrName: 'process.args', idPrefix: `uncommon-process-table-${node._id}-processArgs`, displayCount: 1, // TODO: Change this back once we have improved the UI + isAggregatable: true, + fieldType: 'keyword', }), width: '25%', }, @@ -207,6 +213,8 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [ rowItems: node.user != null ? node.user.name : null, attrName: 'user.name', idPrefix: `uncommon-process-table-${node._id}-processUser`, + isAggregatable: true, + fieldType: 'keyword', }), }, ]; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts index 35730291d6c74..d1bc26c5fb3f2 100644 --- a/x-pack/plugins/security_solution/public/hosts/links.ts +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -8,10 +8,15 @@ import { i18n } from '@kbn/i18n'; import { HOSTS_PATH, SecurityPageName } from '../../common/constants'; import { HOSTS } from '../app/translations'; import { LinkItem } from '../common/links/types'; +import hostsPageImg from '../common/images/hosts_page.png'; export const links: LinkItem = { id: SecurityPageName.hosts, title: HOSTS, + landingImage: hostsPageImg, + description: i18n.translate('xpack.securitySolution.landing.threatHunting.hostsDescription', { + defaultMessage: 'A comprehensive overview of all hosts and host-related security events.', + }), path: HOSTS_PATH, globalNavEnabled: true, globalSearchKeywords: [ @@ -62,7 +67,7 @@ export const links: LinkItem = { { id: SecurityPageName.hostsRisk, title: i18n.translate('xpack.securitySolution.appLinks.hosts.risk', { - defaultMessage: 'Hosts by risk', + defaultMessage: 'Host risk', }), path: `${HOSTS_PATH}/hostRisk`, experimentalKey: 'riskyHostsEnabled', diff --git a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts index 3e6b23a521026..b0b64dcd43d8e 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts @@ -58,7 +58,7 @@ export const NAVIGATION_ALERTS_TITLE = i18n.translate( ); export const NAVIGATION_HOST_RISK_TITLE = i18n.translate( - 'xpack.securitySolution.hosts.navigation.hostRisk', + 'xpack.securitySolution.hosts.navigation.hostRiskTitle', { defaultMessage: 'Host risk', } diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index c2ab11ceead79..81b72527500ad 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -8,14 +8,16 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; +import { NavLinkItem } from '../../common/links/types'; import { TestProviders } from '../../common/mock'; -import { LandingLinksIcons, NavItem } from './landing_links_icons'; +import { LandingLinksIcons } from './landing_links_icons'; -const DEFAULT_NAV_ITEM: NavItem = { +const DEFAULT_NAV_ITEM: NavLinkItem = { id: SecurityPageName.overview, - label: 'TEST LABEL', + title: 'TEST LABEL', description: 'TEST DESCRIPTION', icon: 'myTestIcon', + path: '', }; const mockNavigateTo = jest.fn(); @@ -40,28 +42,28 @@ jest.mock('../../common/components/link_to', () => { describe('LandingLinksIcons', () => { it('renders', () => { - const label = 'test label'; + const title = 'test label'; const { queryByText } = render( - + ); - expect(queryByText(label)).toBeInTheDocument(); + expect(queryByText(title)).toBeInTheDocument(); }); it('renders navigation link', () => { const id = SecurityPageName.administration; - const label = 'myTestLable'; + const title = 'myTestLable'; const { getByText } = render( - + ); - getByText(label).click(); + getByText(title).click(); expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' }); }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx index 82a0d2148f683..04a3e20b1f178 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx @@ -4,33 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiText, - EuiTitle, - IconType, -} from '@elastic/eui'; +import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import { SecurityPageName } from '../../app/types'; + import { SecuritySolutionLinkAnchor, withSecuritySolutionLink, } from '../../common/components/links'; +import { NavLinkItem } from '../../common/links/types'; interface LandingLinksImagesProps { - items: NavItem[]; -} - -export interface NavItem { - id: SecurityPageName; - label: string; - icon: IconType; - description: string; - path?: string; + items: NavLinkItem[]; } const Link = styled.a` @@ -50,7 +35,7 @@ const StyledEuiTitle = styled(EuiTitle)` export const LandingLinksIcons: React.FC = ({ items }) => ( - {items.map(({ label, description, path, id, icon }) => ( + {items.map(({ title, description, id, icon }) => ( = ({ items }) responsive={false} > - - - -

{label}

+ +

{title}

diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx index 479de5e13f432..c44374852f29b 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx @@ -8,14 +8,16 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; +import { NavLinkItem } from '../../common/links/types'; import { TestProviders } from '../../common/mock'; -import { LandingLinksImages, NavItem } from './landing_links_images'; +import { LandingLinksImages } from './landing_links_images'; -const DEFAULT_NAV_ITEM: NavItem = { +const DEFAULT_NAV_ITEM: NavLinkItem = { id: SecurityPageName.overview, - label: 'TEST LABEL', + title: 'TEST LABEL', description: 'TEST DESCRIPTION', image: 'TEST_IMAGE.png', + path: '', }; jest.mock('../../common/lib/kibana/kibana_react', () => { @@ -32,24 +34,24 @@ jest.mock('../../common/lib/kibana/kibana_react', () => { describe('LandingLinksImages', () => { it('renders', () => { - const label = 'test label'; + const title = 'test label'; const { queryByText } = render( - + ); - expect(queryByText(label)).toBeInTheDocument(); + expect(queryByText(title)).toBeInTheDocument(); }); it('renders image', () => { const image = 'test_image.jpeg'; - const label = 'TEST_LABEL'; + const title = 'TEST_LABEL'; const { getByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx index b6a16da8cdc82..22bcc0f1aa251 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx @@ -7,19 +7,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import { SecurityPageName } from '../../app/types'; import { withSecuritySolutionLink } from '../../common/components/links'; +import { NavLinkItem } from '../../common/links/types'; interface LandingLinksImagesProps { - items: NavItem[]; -} - -export interface NavItem { - id: SecurityPageName; - label: string; - image: string; - description: string; - path?: string; + items: NavLinkItem[]; } const PrimaryEuiTitle = styled(EuiTitle)` @@ -47,24 +39,26 @@ const Content = styled(EuiFlexItem)` export const LandingLinksImages: React.FC = ({ items }) => ( - {items.map(({ label, description, path, image, id }) => ( + {items.map(({ title, description, image, id }) => ( - + {/* Empty onClick is to force hover style on `EuiPanel` */} {}}> - + {image && ( + + )} -

{label}

+

{title}

{description} diff --git a/x-pack/plugins/security_solution/public/landing_pages/constants.ts b/x-pack/plugins/security_solution/public/landing_pages/constants.ts new file mode 100644 index 0000000000000..a6b72a5e7db4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/landing_pages/constants.ts @@ -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 { i18n } from '@kbn/i18n'; +import { SecurityPageName } from '../app/types'; + +export interface LandingNavGroup { + label: string; + itemIds: SecurityPageName[]; +} + +export const MANAGE_NAVIGATION_CATEGORIES: LandingNavGroup[] = [ + { + label: i18n.translate('xpack.securitySolution.landing.siemTitle', { + defaultMessage: 'SIEM', + }), + itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.landing.endpointsTitle', { + defaultMessage: 'ENDPOINTS', + }), + itemIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.blocklist, + SecurityPageName.hostIsolationExceptions, + ], + }, +]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx index 8c49fda169ad3..1d46aa6706a26 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx @@ -5,31 +5,22 @@ * 2.0. */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; +import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { LandingLinksImages, NavItem } from '../components/landing_links_images'; +import { LandingLinksImages } from '../components/landing_links_images'; import { DASHBOARDS_PAGE_TITLE } from './translations'; -import overviewPageImg from '../../common/images/overview_page.png'; -import { OVERVIEW } from '../../app/translations'; -const items: NavItem[] = [ - { - id: SecurityPageName.overview, - label: OVERVIEW, - description: i18n.translate('xpack.securitySolution.landing.dashboards.overviewDescription', { - defaultMessage: 'What is going in your secuity environment', - }), - image: overviewPageImg, - }, -]; +export const DashboardsLandingPage = () => { + const dashboardLinks = useAppRootNavLink(SecurityPageName.dashboardsLanding)?.links ?? []; -export const DashboardsLandingPage = () => ( - - - - - -); + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index efb1bcf35c39e..1955d56c0a151 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -9,43 +9,53 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; -import { LandingCategories, NavConfigType } from './manage'; +import { LandingCategories } from './manage'; +import { NavLinkItem } from '../../common/links/types'; const RULES_ITEM_LABEL = 'elastic rules!'; const EXCEPTIONS_ITEM_LABEL = 'exceptional!'; -const testConfig: NavConfigType = { - categories: [ - { - label: 'first tests category', - itemIds: [SecurityPageName.rules], - }, - { - label: 'second tests category', - itemIds: [SecurityPageName.exceptions], - }, - ], - items: [ +const mockAppManageLink: NavLinkItem = { + id: SecurityPageName.administration, + path: '', + title: 'admin', + links: [ { id: SecurityPageName.rules, - label: RULES_ITEM_LABEL, + title: RULES_ITEM_LABEL, description: '', icon: 'testIcon1', + path: '', }, { id: SecurityPageName.exceptions, - label: EXCEPTIONS_ITEM_LABEL, + title: EXCEPTIONS_ITEM_LABEL, description: '', icon: 'testIcon2', + path: '', }, ], }; +jest.mock('../../common/components/navigation/nav_links', () => ({ + useAppRootNavLink: jest.fn(() => mockAppManageLink), +})); describe('LandingCategories', () => { it('renders items', () => { const { queryByText } = render( - + ); @@ -57,15 +67,12 @@ describe('LandingCategories', () => { const { queryAllByTestId } = render( ); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index da4d25f621305..f0e6094d5113f 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -5,140 +5,23 @@ * 2.0. */ import { EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { compact } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import { - BLOCKLIST, - ENDPOINTS, - EVENT_FILTERS, - EXCEPTIONS, - TRUSTED_APPLICATIONS, -} from '../../app/translations'; + import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; +import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; +import { NavigationCategories } from '../../common/components/navigation/types'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { LandingLinksIcons, NavItem } from '../components/landing_links_icons'; -import { IconBlocklist } from '../icons/blocklist'; -import { IconEndpoints } from '../icons/endpoints'; -import { IconEndpointPolicies } from '../icons/endpoint_policies'; -import { IconEventFilters } from '../icons/event_filters'; -import { IconExceptionLists } from '../icons/exception_lists'; -import { IconHostIsolation } from '../icons/host_isolation'; -import { IconSiemRules } from '../icons/siem_rules'; -import { IconTrustedApplications } from '../icons/trusted_applications'; +import { navigationCategories } from '../../management/links'; +import { LandingLinksIcons } from '../components/landing_links_icons'; import { MANAGE_PAGE_TITLE } from './translations'; -// TODO -const FIX_ME_TEMPORARY_DESCRIPTION = 'Description here'; - -export interface NavConfigType { - items: NavItem[]; - categories: Array<{ label: string; itemIds: SecurityPageName[] }>; -} - -const config: NavConfigType = { - categories: [ - { - label: i18n.translate('xpack.securitySolution.landing.threatHunting.siemTitle', { - defaultMessage: 'SIEM', - }), - itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.landing.threatHunting.endpointsTitle', { - defaultMessage: 'ENDPOINTS', - }), - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, - ], - items: [ - { - id: SecurityPageName.rules, - label: i18n.translate('xpack.securitySolution.landing.manage.rulesLabel', { - defaultMessage: 'SIEM rules', - }), - description: FIX_ME_TEMPORARY_DESCRIPTION, - icon: IconSiemRules, - }, - { - id: SecurityPageName.exceptions, - label: EXCEPTIONS, - description: FIX_ME_TEMPORARY_DESCRIPTION, - icon: IconExceptionLists, - }, - { - id: SecurityPageName.endpoints, - label: ENDPOINTS, - description: i18n.translate('xpack.securitySolution.landing.manage.endpointsDescription', { - defaultMessage: 'Hosts running endpoint security', - }), - icon: IconEndpoints, - }, - { - id: SecurityPageName.policies, - label: i18n.translate('xpack.securitySolution.landing.manage.endpointPoliceLabel', { - defaultMessage: 'Endpoint policies', - }), - description: FIX_ME_TEMPORARY_DESCRIPTION, - icon: IconEndpointPolicies, - }, - { - id: SecurityPageName.trustedApps, - label: TRUSTED_APPLICATIONS, - description: i18n.translate( - 'xpack.securitySolution.landing.manage.trustedApplicationsDescription', - { - defaultMessage: - 'Improve performance or alleviate conflicts with other applications running on your hosts', - } - ), - icon: IconTrustedApplications, - }, - { - id: SecurityPageName.eventFilters, - label: EVENT_FILTERS, - description: i18n.translate('xpack.securitySolution.landing.manage.eventFiltersDescription', { - defaultMessage: 'Exclude unwanted applications from running on your hosts', - }), - icon: IconEventFilters, - }, - { - id: SecurityPageName.blocklist, - label: BLOCKLIST, - description: FIX_ME_TEMPORARY_DESCRIPTION, - icon: IconBlocklist, - }, - { - id: SecurityPageName.hostIsolationExceptions, - label: i18n.translate('xpack.securitySolution.landing.manage.hostIsolationLabel', { - defaultMessage: 'Host isolation IP exceptions', - }), - description: i18n.translate( - 'xpack.securitySolution.landing.manage.hostIsolationDescription', - { - defaultMessage: 'Allow isolated hosts to communicate with specific IPs', - } - ), - - icon: IconHostIsolation, - }, - ], -}; - export const ManageLandingPage = () => ( - + ); @@ -148,32 +31,37 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const getNavItembyId = (navConfig: NavConfigType) => (itemId: string) => - navConfig.items.find(({ id }: NavItem) => id === itemId); +const useGetManageNavLinks = () => { + const manageNavLinks = useAppRootNavLink(SecurityPageName.administration)?.links ?? []; + + const manageLinksById = Object.fromEntries(manageNavLinks.map((link) => [link.id, link])); + return (linkIds: readonly SecurityPageName[]) => linkIds.map((linkId) => manageLinksById[linkId]); +}; -const navItemsFromIds = (itemIds: SecurityPageName[], navConfig: NavConfigType) => - compact(itemIds.map(getNavItembyId(navConfig))); +export const LandingCategories = React.memo( + ({ categories }: { categories: NavigationCategories }) => { + const getManageNavLinks = useGetManageNavLinks(); -export const LandingCategories = React.memo(({ navConfig }: { navConfig: NavConfigType }) => { - return ( - <> - {navConfig.categories.map(({ label, itemIds }, index) => ( -
- {index > 0 && ( - <> - - - - )} - -

{label}

-
- - -
- ))} - - ); -}); + return ( + <> + {categories.map(({ label, linkIds }, index) => ( +
+ {index > 0 && ( + <> + + + + )} + +

{label}

+
+ + +
+ ))} + + ); + } +); LandingCategories.displayName = 'LandingCategories'; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/threat_hunting.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/threat_hunting.tsx index 2a0f4e471a75d..605a1baeedbd6 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/threat_hunting.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/threat_hunting.tsx @@ -5,51 +5,22 @@ * 2.0. */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; +import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { LandingLinksImages, NavItem } from '../components/landing_links_images'; +import { LandingLinksImages } from '../components/landing_links_images'; import { THREAT_HUNTING_PAGE_TITLE } from './translations'; -import userPageImg from '../../common/images/users_page.png'; -import hostsPageImg from '../../common/images/hosts_page.png'; -import networkPageImg from '../../common/images/network_page.png'; -import { HOSTS, NETWORK, USERS } from '../../app/translations'; -const items: NavItem[] = [ - { - id: SecurityPageName.hosts, - label: HOSTS, - description: i18n.translate('xpack.securitySolution.landing.threatHunting.hostsDescription', { - defaultMessage: - 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', - }), - image: hostsPageImg, - }, - { - id: SecurityPageName.network, - label: NETWORK, - description: i18n.translate('xpack.securitySolution.landing.threatHunting.networkDescription', { - defaultMessage: - 'The action or process of interacting with others to exchange information and develop professional or social contacts.', - }), - image: networkPageImg, - }, - { - id: SecurityPageName.users, - label: USERS, - description: i18n.translate('xpack.securitySolution.landing.threatHunting.usersDescription', { - defaultMessage: 'Sudo commands dashboard from the Logs System integration.', - }), - image: userPageImg, - }, -]; +export const ThreatHuntingLandingPage = () => { + const threatHuntinglinks = useAppRootNavLink(SecurityPageName.threatHuntingLanding)?.links ?? []; -export const ThreatHuntingLandingPage = () => ( - - - - - -); + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/blocklist.tsx b/x-pack/plugins/security_solution/public/management/icons/blocklist.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/blocklist.tsx rename to x-pack/plugins/security_solution/public/management/icons/blocklist.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/endpoint_policies.tsx b/x-pack/plugins/security_solution/public/management/icons/endpoint_policies.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/endpoint_policies.tsx rename to x-pack/plugins/security_solution/public/management/icons/endpoint_policies.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/endpoints.tsx b/x-pack/plugins/security_solution/public/management/icons/endpoints.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/endpoints.tsx rename to x-pack/plugins/security_solution/public/management/icons/endpoints.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/event_filters.tsx b/x-pack/plugins/security_solution/public/management/icons/event_filters.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/event_filters.tsx rename to x-pack/plugins/security_solution/public/management/icons/event_filters.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/exception_lists.tsx b/x-pack/plugins/security_solution/public/management/icons/exception_lists.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/exception_lists.tsx rename to x-pack/plugins/security_solution/public/management/icons/exception_lists.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/host_isolation.tsx b/x-pack/plugins/security_solution/public/management/icons/host_isolation.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/host_isolation.tsx rename to x-pack/plugins/security_solution/public/management/icons/host_isolation.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/siem_rules.tsx b/x-pack/plugins/security_solution/public/management/icons/siem_rules.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/siem_rules.tsx rename to x-pack/plugins/security_solution/public/management/icons/siem_rules.tsx diff --git a/x-pack/plugins/security_solution/public/landing_pages/icons/trusted_applications.tsx b/x-pack/plugins/security_solution/public/management/icons/trusted_applications.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/landing_pages/icons/trusted_applications.tsx rename to x-pack/plugins/security_solution/public/management/icons/trusted_applications.tsx diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index d941d538c80f7..54c0b3f0d8dd2 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -29,8 +29,18 @@ import { RULES, TRUSTED_APPLICATIONS, } from '../app/translations'; +import { NavigationCategories } from '../common/components/navigation/types'; import { FEATURE, LinkItem } from '../common/links/types'; +import { IconBlocklist } from './icons/blocklist'; +import { IconEndpoints } from './icons/endpoints'; +import { IconEndpointPolicies } from './icons/endpoint_policies'; +import { IconEventFilters } from './icons/event_filters'; +import { IconExceptionLists } from './icons/exception_lists'; +import { IconHostIsolation } from './icons/host_isolation'; +import { IconSiemRules } from './icons/siem_rules'; +import { IconTrustedApplications } from './icons/trusted_applications'; + export const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, @@ -47,6 +57,12 @@ export const links: LinkItem = { { id: SecurityPageName.rules, title: RULES, + description: i18n.translate('xpack.securitySolution.appLinks.rulesDescription', { + defaultMessage: + "Create and manage rules to check for suspicious source events, and create alerts when a rule's conditions are met.", + }), + + landingIcon: IconSiemRules, path: RULES_PATH, globalNavEnabled: false, globalSearchKeywords: [ @@ -59,6 +75,10 @@ export const links: LinkItem = { { id: SecurityPageName.exceptions, title: EXCEPTIONS, + description: i18n.translate('xpack.securitySolution.appLinks.exceptionsDescription', { + defaultMessage: 'Create and manage exceptions to prevent the creation of unwanted alerts.', + }), + landingIcon: IconExceptionLists, path: EXCEPTIONS_PATH, globalNavEnabled: false, globalSearchKeywords: [ @@ -70,6 +90,10 @@ export const links: LinkItem = { }, { id: SecurityPageName.endpoints, + description: i18n.translate('xpack.securitySolution.appLinks.endpointsDescription', { + defaultMessage: 'Hosts running endpoint security.', + }), + landingIcon: IconEndpoints, globalNavEnabled: true, title: ENDPOINTS, globalNavOrder: 9006, @@ -79,6 +103,11 @@ export const links: LinkItem = { { id: SecurityPageName.policies, title: POLICIES, + description: i18n.translate('xpack.securitySolution.appLinks.policiesDescription', { + defaultMessage: + 'Use policies to customize endpoint and cloud workload protections and other configurations.', + }), + landingIcon: IconEndpointPolicies, path: POLICIES_PATH, skipUrlState: true, experimentalKey: 'policyListEnabled', @@ -86,26 +115,68 @@ export const links: LinkItem = { { id: SecurityPageName.trustedApps, title: TRUSTED_APPLICATIONS, + description: i18n.translate( + 'xpack.securitySolution.appLinks.trustedApplicationsDescription', + { + defaultMessage: + 'Improve performance or alleviate conflicts with other applications running on your hosts.', + } + ), + landingIcon: IconTrustedApplications, path: TRUSTED_APPS_PATH, skipUrlState: true, }, { id: SecurityPageName.eventFilters, title: EVENT_FILTERS, + description: i18n.translate('xpack.securitySolution.appLinks.eventFiltersDescription', { + defaultMessage: 'Exclude high volume or unwanted events being written into Elasticsearch.', + }), + landingIcon: IconEventFilters, path: EVENT_FILTERS_PATH, skipUrlState: true, }, { id: SecurityPageName.hostIsolationExceptions, title: HOST_ISOLATION_EXCEPTIONS, + description: i18n.translate('xpack.securitySolution.appLinks.hostIsolationDescription', { + defaultMessage: 'Allow isolated hosts to communicate with specific IPs.', + }), + landingIcon: IconHostIsolation, path: HOST_ISOLATION_EXCEPTIONS_PATH, skipUrlState: true, }, { id: SecurityPageName.blocklist, title: BLOCKLIST, + description: i18n.translate('xpack.securitySolution.appLinks.blocklistDescription', { + defaultMessage: 'Exclude unwanted applications from running on your hosts.', + }), + landingIcon: IconBlocklist, path: BLOCKLIST_PATH, skipUrlState: true, }, ], }; + +export const navigationCategories: NavigationCategories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { + defaultMessage: 'SIEM', + }), + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { + defaultMessage: 'ENDPOINTS', + }), + linkIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.blocklist, + SecurityPageName.hostIsolationExceptions, + ], + }, +] as const; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/mocks.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/mocks.tsx index daa44f01dbffd..21aa203a36f53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/mocks.tsx @@ -181,6 +181,7 @@ export const generateFleetPackageInfo = (): PackageInfo => { security_rule: [], tag: [], osquery_pack_asset: [], + osquery_saved_query: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/security_solution/public/network/components/direction/index.tsx b/x-pack/plugins/security_solution/public/network/components/direction/index.tsx index d87756cb9bbab..d3661f390b2d6 100644 --- a/x-pack/plugins/security_solution/public/network/components/direction/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/direction/index.tsx @@ -70,6 +70,8 @@ export const DirectionBadge = React.memo<{ iconType={getDirectionIcon(direction)} isDraggable={isDraggable} value={direction} + isAggregatable={true} + fieldType="keyword" /> )); diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/columns.tsx index 4dcdc92983168..be88d18cddf1d 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/columns.tsx @@ -55,6 +55,8 @@ export const getNetworkDnsColumns = (): NetworkDnsColumns => [ operator: IS_OPERATOR, }, }} + isAggregatable={true} + fieldType={'keyword'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/columns.tsx index 9b8f842851064..b9c3ed7073e45 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/columns.tsx @@ -42,6 +42,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ displayCount: 3, idPrefix: escapeDataProviderId(`${tableId}-table-methods-${path}`), rowItems: methods, + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(); }, @@ -55,6 +57,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ displayCount: 3, idPrefix: escapeDataProviderId(`${tableId}-table-domains-${path}`), rowItems: domains, + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(), }, @@ -67,6 +71,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ attrName: 'url.path', idPrefix: escapeDataProviderId(`${tableId}-table-path-${path}`), rowItem: path, + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(), }, @@ -79,6 +85,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ displayCount: 3, idPrefix: escapeDataProviderId(`${tableId}-table-statuses-${path}`), rowItems: statuses, + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(), }, @@ -90,6 +98,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ attrName: 'host.name', idPrefix: escapeDataProviderId(`${tableId}-table-lastHost-${path}`), rowItem: lastHost, + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(), }, @@ -102,6 +112,8 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [ idPrefix: escapeDataProviderId(`${tableId}-table-lastSourceIp-${path}`), rowItem: lastSourceIp, render: () => , + isAggregatable: true, + fieldType: 'keyword', }) : getEmptyTagValue(), }, diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/columns.tsx index 5a2780164a8a2..e3d8de02a25bb 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/columns.tsx @@ -70,6 +70,8 @@ export const getNetworkTopCountriesColumns = ( kqlQuery: '', queryMatch: { field: geoAttr, value: geo, operator: IS_OPERATOR }, }} + isAggregatable={true} + fieldType={'keyword'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/columns.tsx index ac07ee01875d4..3a85c6d93269e 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/columns.tsx @@ -81,6 +81,8 @@ export const getNetworkTopNFlowColumns = ( kqlQuery: '', queryMatch: { field: ipAttr, value: ip, operator: IS_OPERATOR }, }} + isAggregatable={true} + fieldType={'ip'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( @@ -104,6 +106,8 @@ export const getNetworkTopNFlowColumns = ( kqlQuery: '', queryMatch: { field: geoAttrName, value: geo, operator: IS_OPERATOR }, }} + isAggregatable={true} + fieldType={'geo_point'} render={(dataProvider, _, snapshot) => snapshot.isDragging ? ( @@ -141,6 +145,8 @@ export const getNetworkTopNFlowColumns = ( attrName: domainAttr, idPrefix: id, displayCount: 1, + isAggregatable: true, + fieldType: 'keyword', }); } else { return getEmptyTagValue(); @@ -162,6 +168,8 @@ export const getNetworkTopNFlowColumns = ( rowItem: as.name, attrName: `${flowTarget}.as.organization.name`, idPrefix: `${id}-name`, + isAggregatable: true, + fieldType: 'keyword', })} {as.number && ( @@ -171,6 +179,8 @@ export const getNetworkTopNFlowColumns = ( rowItem: `${as.number}`, attrName: `${flowTarget}.as.number`, idPrefix: `${id}-number`, + isAggregatable: true, + fieldType: 'keyword', })} )} diff --git a/x-pack/plugins/security_solution/public/network/components/port/index.tsx b/x-pack/plugins/security_solution/public/network/components/port/index.tsx index 769aa7300d138..f22dc9f65dae2 100644 --- a/x-pack/plugins/security_solution/public/network/components/port/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/port/index.tsx @@ -17,32 +17,47 @@ export const Port = React.memo<{ Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; eventId: string; fieldName: string; + fieldType?: string; + isAggregatable?: boolean; isDraggable?: boolean; title?: string; value: string | undefined | null; -}>(({ Component, contextId, eventId, fieldName, isDraggable, title, value }) => - isDraggable ? ( - +}>( + ({ + Component, + contextId, + eventId, + fieldName, + fieldType, + isAggregatable, + isDraggable, + title, + value, + }) => + isDraggable ? ( + + + + ) : ( - - ) : ( - - ) + ) ); Port.displayName = 'Port'; diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/network.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/network.tsx index 88bfd19b7066e..e2f2d8d725181 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/network.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/network.tsx @@ -85,6 +85,8 @@ export const Network = React.memo<{ field={NETWORK_PROTOCOL_FIELD_NAME} isDraggable={isDraggable} value={proto} + isAggregatable={true} + fieldType="keyword" /> )) @@ -138,6 +140,8 @@ export const Network = React.memo<{ field={NETWORK_TRANSPORT_FIELD_NAME} isDraggable={isDraggable} value={trans} + isAggregatable={true} + fieldType="keyword" /> )) @@ -153,6 +157,8 @@ export const Network = React.memo<{ field={NETWORK_COMMUNITY_ID_FIELD_NAME} isDraggable={isDraggable} value={trans} + isAggregatable={true} + fieldType="keyword" />
)) diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/columns.tsx index 6641927082b6b..56e2a8e00fedb 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/columns.tsx @@ -39,6 +39,8 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ rowItems: issuers, attrName: 'tls.server.issuer', idPrefix: `${tableId}-${_id}-table-issuers`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -52,6 +54,8 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ rowItems: subjects, attrName: 'tls.server.subject', idPrefix: `${tableId}-${_id}-table-subjects`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -65,6 +69,8 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ rowItem: sha1, attrName: 'tls.server.hash.sha1', idPrefix: `${tableId}-${sha1}-table-sha1`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -78,6 +84,8 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ rowItems: ja3, attrName: 'tls.server.ja3s', idPrefix: `${tableId}-${_id}-table-ja3`, + isAggregatable: true, + fieldType: 'keyword', }), }, { diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/columns.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/columns.tsx index 4068c616228f2..2c170cc622fe0 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/columns.tsx @@ -35,6 +35,8 @@ export const getUsersColumns = (flowTarget: FlowTarget, tableId: string): UsersC rowItem: userName, attrName: 'user.name', idPrefix: `${tableId}-table-${flowTarget}-user`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -48,6 +50,8 @@ export const getUsersColumns = (flowTarget: FlowTarget, tableId: string): UsersC rowItems: userIds, attrName: 'user.id', idPrefix: `${tableId}-table-${flowTarget}`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -61,6 +65,8 @@ export const getUsersColumns = (flowTarget: FlowTarget, tableId: string): UsersC rowItems: groupNames, attrName: 'user.group.name', idPrefix: `${tableId}-table-${flowTarget}`, + isAggregatable: true, + fieldType: 'keyword', }), }, { @@ -74,6 +80,8 @@ export const getUsersColumns = (flowTarget: FlowTarget, tableId: string): UsersC rowItems: groupId, attrName: 'user.group.id', idPrefix: `${tableId}-table-${flowTarget}`, + isAggregatable: true, + fieldType: 'keyword', }), }, { diff --git a/x-pack/plugins/security_solution/public/network/links.ts b/x-pack/plugins/security_solution/public/network/links.ts index ad209a220eebc..47194fe3d67a6 100644 --- a/x-pack/plugins/security_solution/public/network/links.ts +++ b/x-pack/plugins/security_solution/public/network/links.ts @@ -9,10 +9,16 @@ import { i18n } from '@kbn/i18n'; import { NETWORK_PATH, SecurityPageName } from '../../common/constants'; import { NETWORK } from '../app/translations'; import { LinkItem } from '../common/links/types'; +import networkPageImg from '../common/images/network_page.png'; export const links: LinkItem = { id: SecurityPageName.network, title: NETWORK, + landingImage: networkPageImg, + description: i18n.translate('xpack.securitySolution.appLinks.network.description', { + defaultMessage: + 'Provides key activity metrics in an interactive map as well as event tables that enable interaction with the Timeline.', + }), path: NETWORK_PATH, globalNavEnabled: true, globalSearchKeywords: [ diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 89f75053b3d6f..d09c23a6cfc62 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -15,10 +15,16 @@ import { } from '../../common/constants'; import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; import { FEATURE, LinkItem } from '../common/links/types'; +import overviewPageImg from '../common/images/overview_page.png'; +import detectionResponsePageImg from '../common/images/detection_response_page.png'; export const overviewLinks: LinkItem = { id: SecurityPageName.overview, title: OVERVIEW, + landingImage: overviewPageImg, + description: i18n.translate('xpack.securitySolution.appLinks.overviewDescription', { + defaultMessage: 'What is going on in your security environment.', + }), path: OVERVIEW_PATH, globalNavEnabled: true, features: [FEATURE.general], @@ -47,6 +53,11 @@ export const gettingStartedLinks: LinkItem = { export const detectionResponseLinks: LinkItem = { id: SecurityPageName.detectionAndResponse, title: DETECTION_RESPONSE, + landingImage: detectionResponsePageImg, + description: i18n.translate('xpack.securitySolution.appLinks.detectionAndResponseDescription', { + defaultMessage: + "Monitor the impact of application and device performance from the end user's point of view.", + }), path: DETECTION_RESPONSE_PATH, globalNavEnabled: false, experimentalKey: 'detectionResponseEnabled', diff --git a/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.tsx index 296faf208ac91..3baf64c51c4cb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.tsx @@ -57,6 +57,8 @@ export const CertificateFingerprint = React.memo<{ } value={value} + isAggregatable={true} + fieldType="keyword" > {certificateType === 'client' ? i18n.CLIENT_CERT : i18n.SERVER_CERT} diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx index e868b3e4c21dd..41dc70316a1a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx @@ -27,6 +27,8 @@ describe('Duration', () => { eventId="abc" fieldName="event.duration" isDraggable={true} + isAggregatable={true} + fieldType={'keyword'} value={`${ONE_MILLISECOND_AS_NANOSECONDS}`} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx index 7500fdb122fae..cc4950c762401 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx @@ -20,15 +20,17 @@ export const Duration = React.memo<{ contextId: string; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; value?: string | null; -}>(({ contextId, eventId, fieldName, isDraggable, value }) => +}>(({ contextId, eventId, fieldName, fieldType, isAggregatable, isDraggable, value }) => isDraggable ? ( @@ -60,7 +62,9 @@ exports[`Field Renderers #hostIdRenderer it renders correctly against snapshot 1 }, } } + fieldType="keyword" hideTopN={false} + isAggregatable={true} isDraggable={false} render={[Function]} /> @@ -83,7 +87,9 @@ exports[`Field Renderers #hostNameRenderer it renders correctly against snapshot }, } } + fieldType="keyword" hideTopN={false} + isAggregatable={true} isDraggable={false} render={[Function]} /> @@ -99,7 +105,9 @@ exports[`Field Renderers #locationRenderer it renders correctly against snapshot >
@@ -101,6 +103,8 @@ export const autonomousSystemRenderer = ( isDraggable={false} field={`${flowTarget}.as.number`} value={`${as.number}`} + isAggregatable={true} + fieldType={'number'} /> @@ -133,6 +137,8 @@ export const hostIdRenderer = ({ isDraggable={isDraggable} field="host.id" value={host.id[0]} + isAggregatable={true} + fieldType={'keyword'} > {noLink ? ( <>{host.id} @@ -166,6 +172,8 @@ export const hostNameRenderer = ( isDraggable={isDraggable ?? false} field={'host.name'} value={host.name[0]} + isAggregatable={true} + fieldType={'keyword'} > {host.name ? host.name : getEmptyTagValue()} @@ -216,7 +224,14 @@ export const DefaultFieldRendererComponent: React.FC )} {typeof rowItem === 'string' && ( - + {render ? render(rowItem) : rowItem} )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx index ee0551c5f5296..db59857300177 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx @@ -60,6 +60,8 @@ describe('FormattedIp', () => { value: '192.168.1.1', contextId: 'test-context-id', eventId: 'test-event-id', + isAggregatable: true, + fieldType: 'ip', isDraggable: false, fieldName: 'host.ip', }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index 72d5ad43e91e2..3ca293f942d86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -80,10 +80,21 @@ const NonDecoratedIpComponent: React.FC<{ contextId: string; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; truncate?: boolean; value: string | object | null | undefined; -}> = ({ contextId, eventId, fieldName, isDraggable, truncate, value }) => { +}> = ({ + contextId, + eventId, + fieldName, + fieldType, + isAggregatable, + isDraggable, + truncate, + value, +}) => { const key = useMemo( () => `non-decorated-ip-draggable-wrapper-${getUniqueId({ @@ -127,6 +138,8 @@ const NonDecoratedIpComponent: React.FC<{ return ( = ({ contextId, eventId, fieldName, + fieldType, + isAggregatable, isButton, isDraggable, onClick, @@ -259,6 +274,8 @@ const AddressLinksItemComponent: React.FC = ({ void; @@ -287,6 +306,8 @@ const AddressLinksComponent: React.FC = ({ contextId, eventId, fieldName, + fieldType, + isAggregatable, isButton, isDraggable, onClick, @@ -305,6 +326,8 @@ const AddressLinksComponent: React.FC = ({ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isButton={isButton} isDraggable={isDraggable} onClick={onClick} @@ -317,6 +340,8 @@ const AddressLinksComponent: React.FC = ({ contextId, eventId, fieldName, + fieldType, + isAggregatable, isButton, isDraggable, onClick, @@ -335,6 +360,8 @@ const AddressLinks = React.memo( prevProps.contextId === nextProps.contextId && prevProps.eventId === nextProps.eventId && prevProps.fieldName === nextProps.fieldName && + prevProps.isAggregatable === nextProps.isAggregatable && + prevProps.fieldType === nextProps.fieldType && prevProps.isDraggable === nextProps.isDraggable && prevProps.truncate === nextProps.truncate && deepEqual(prevProps.addresses, nextProps.addresses) @@ -345,6 +372,8 @@ const FormattedIpComponent: React.FC<{ contextId: string; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isButton?: boolean; isDraggable: boolean; onClick?: () => void; @@ -356,6 +385,8 @@ const FormattedIpComponent: React.FC<{ contextId, eventId, fieldName, + fieldType, + isAggregatable, isDraggable, isButton, onClick, @@ -374,6 +405,8 @@ const FormattedIpComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isButton={isButton} isDraggable={isDraggable} onClick={onClick} @@ -397,6 +430,8 @@ const FormattedIpComponent: React.FC<{ isDraggable={isDraggable} onClick={onClick} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} truncate={truncate} title={title} /> @@ -407,6 +442,8 @@ const FormattedIpComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} truncate={truncate} value={value} @@ -421,6 +458,8 @@ export const FormattedIp = React.memo( prevProps.contextId === nextProps.contextId && prevProps.eventId === nextProps.eventId && prevProps.fieldName === nextProps.fieldName && + prevProps.isAggregatable === nextProps.isAggregatable && + prevProps.fieldType === nextProps.fieldType && prevProps.isDraggable === nextProps.isDraggable && prevProps.truncate === nextProps.truncate && deepEqual(prevProps.value, nextProps.value) diff --git a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.tsx index 49ddc6dbe09bb..de566d3db5c18 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.tsx @@ -41,6 +41,8 @@ export const Ja3Fingerprint = React.memo<{ iconType="snowflake" isDraggable={isDraggable} value={value} + isAggregatable={true} + fieldType="keyword" > {i18n.JA3_FINGERPRINT_LABEL} diff --git a/x-pack/plugins/security_solution/public/timelines/components/netflow/netflow_columns/user_process.tsx b/x-pack/plugins/security_solution/public/timelines/components/netflow/netflow_columns/user_process.tsx index 72de537fee588..13da45664d0ec 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/netflow/netflow_columns/user_process.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/netflow/netflow_columns/user_process.tsx @@ -44,6 +44,8 @@ export const UserProcess = React.memo<{ isDraggable={isDraggable} value={user} iconType="user" + isAggregatable={true} + fieldType="keyword" /> )) @@ -60,6 +62,8 @@ export const UserProcess = React.memo<{ isDraggable={isDraggable} value={process} iconType="console" + isAggregatable={true} + fieldType="keyword" /> )) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/args.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/args.test.tsx.snap index 7017ffa6fd9f1..70a9236d6b0bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/args.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/args.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Args rendering it renders against shallow snapshot 1`] = ` contextId="context-123-args-0-arg1" eventId="event-123" field="process.args" + fieldType="keyword" + isAggregatable={true} value="arg1" /> @@ -23,6 +25,8 @@ exports[`Args rendering it renders against shallow snapshot 1`] = ` contextId="context-123-args-1-arg2" eventId="event-123" field="process.args" + fieldType="keyword" + isAggregatable={true} value="arg2" /> @@ -35,6 +39,8 @@ exports[`Args rendering it renders against shallow snapshot 1`] = ` contextId="context-123-args-2-arg3" eventId="event-123" field="process.args" + fieldType="keyword" + isAggregatable={true} value="arg3" /> @@ -46,6 +52,8 @@ exports[`Args rendering it renders against shallow snapshot 1`] = ` contextId="context-123" eventId="event-123" field="process.title" + fieldType="keyword" + isAggregatable={true} value="process-title-1" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap index 5c42306f563df..2143d3098ddfc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap @@ -8,6 +8,7 @@ exports[`get_column_renderer renders correctly against snapshot 1`] = ` fieldFormat="" fieldName="event.severity" fieldType="" + isAggregatable={false} isDraggable={true} key="plain-column-renderer-formatted-field-value-test-event.severity-1-message-3-0" value="3" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/host_working_dir.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/host_working_dir.test.tsx.snap index b2532cc871736..7fd9d00af1bd8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/host_working_dir.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/host_working_dir.test.tsx.snap @@ -10,6 +10,8 @@ exports[`HostWorkingDir renders correctly against snapshot 1`] = ` contextId="test" eventId="1" field="host.name" + fieldType="keyword" + isAggregatable={true} value="[hostname-123]" /> @@ -27,7 +29,9 @@ exports[`HostWorkingDir renders correctly against snapshot 1`] = ` contextId="test" eventId="1" field="process.working_directory" + fieldType="keyword" iconType="folderOpen" + isAggregatable={true} value="[working-directory-123]" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap index b9859fc5453b7..6a09567fbf41a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap @@ -8,6 +8,7 @@ exports[`plain_column_renderer rendering renders correctly against snapshot 1`] fieldFormat="" fieldName="event.category" fieldType="keyword" + isAggregatable={true} isDraggable={true} key="plain-column-renderer-formatted-field-value-test-event.category-1-event.category-Access-0" value="Access" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap index 84aea591337ee..d9819d258db95 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap @@ -12,7 +12,9 @@ exports[`ProcessDraggable rendering it renders against shallow snapshot 1`] = ` contextId="context-123" eventId="event-123" field="process.name" + fieldType="keyword" iconType="console" + isAggregatable={true} value="process-name-1" /> @@ -23,6 +25,8 @@ exports[`ProcessDraggable rendering it renders against shallow snapshot 1`] = ` contextId="context-123" eventId="event-123" field="process.pid" + fieldType="keyword" + isAggregatable={true} queryValue="123" value="(123)" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/user_host_working_dir.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/user_host_working_dir.test.tsx.snap index d01bb85f565dc..390701cc44451 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/user_host_working_dir.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/user_host_working_dir.test.tsx.snap @@ -10,7 +10,9 @@ exports[`UserHostWorkingDir rendering it renders against shallow snapshot 1`] = contextId="context-123" eventId="event-123" field="user.name" + fieldType="keyword" iconType="user" + isAggregatable={true} value="[userName-123]" /> @@ -29,6 +31,8 @@ exports[`UserHostWorkingDir rendering it renders against shallow snapshot 1`] = contextId="context-123" eventId="event-123" field="user.domain" + fieldType="keyword" + isAggregatable={true} value="[userDomain-123]" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index 44aeadce6e3b6..edc8faff1b5fc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -18,12 +18,16 @@ export const AgentStatuses = React.memo( fieldName, contextId, eventId, + fieldType, + isAggregatable, isDraggable, value, }: { fieldName: string; + fieldType: string; contextId: string; eventId: string; + isAggregatable: boolean; isDraggable: boolean; value: string; }) => { @@ -38,6 +42,8 @@ export const AgentStatuses = React.memo( ))} @@ -46,6 +48,8 @@ export const ArgsComponent = ({ args, contextId, eventId, processTitle, isDragga field="process.title" isDraggable={isDraggable} value={processTitle} + fieldType="keyword" + isAggregatable={true} /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.tsx index bfb4b8677d900..bcadb329c65ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.tsx @@ -106,6 +106,8 @@ export const AuditdGenericLine = React.memo( isDraggable={isDraggable} queryValue={result} value={result} + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.tsx index 5857dc1e30182..33a6502fa403b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.tsx @@ -37,6 +37,8 @@ export const PrimarySecondary = React.memo( isDraggable={isDraggable} value={primary} iconType="user" + isAggregatable={true} + fieldType="keyword" /> ); } else if (nilOrUnSet(primary) && !nilOrUnSet(secondary)) { @@ -48,6 +50,8 @@ export const PrimarySecondary = React.memo( isDraggable={isDraggable} value={secondary} iconType="user" + isAggregatable={true} + fieldType="keyword" /> ); } else if (primary === secondary) { @@ -59,6 +63,8 @@ export const PrimarySecondary = React.memo( isDraggable={isDraggable} value={secondary} iconType="user" + isAggregatable={true} + fieldType="keyword" /> ); } else { @@ -72,6 +78,8 @@ export const PrimarySecondary = React.memo( isDraggable={isDraggable} value={primary} iconType="user" + isAggregatable={true} + fieldType="keyword" /> @@ -85,6 +93,8 @@ export const PrimarySecondary = React.memo( isDraggable={isDraggable} value={secondary} iconType="user" + isAggregatable={true} + fieldType="keyword" /> @@ -123,6 +133,8 @@ export const PrimarySecondaryUserInfo = React.memo ); } else if (!nilOrUnSet(userName) && nilOrUnSet(primary) && nilOrUnSet(secondary)) { @@ -134,6 +146,8 @@ export const PrimarySecondaryUserInfo = React.memo ); } else { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.tsx index f90407b882fdf..bbee0d48d7f0d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.tsx @@ -50,6 +50,8 @@ export const SessionUserHostWorkingDir = React.memo( value={session} iconType="number" isDraggable={isDraggable} + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx index 8930a813cde6f..ef4365601e340 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx @@ -26,6 +26,8 @@ describe('Bytes', () => { contextId="test" eventId="abc" fieldName="network.bytes" + fieldType="string" + isAggregatable={true} isDraggable={true} value={`1234567`} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx index 8859c601ad56d..530782a4006e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx @@ -20,12 +20,16 @@ export const Bytes = React.memo<{ contextId: string; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; value?: string | null; -}>(({ contextId, eventId, fieldName, isDraggable, value }) => +}>(({ contextId, eventId, fieldName, fieldType, isAggregatable, isDraggable, value }) => isDraggable ? ( = ({ field={INDICATOR_MATCHED_TYPE} isDraggable={isDraggable} value={indicatorType} + isAggregatable={true} + fieldType={'keyword'} /> )} @@ -73,6 +75,8 @@ export const IndicatorDetails: React.FC = ({ field={FEED_NAME} isDraggable={isDraggable} value={feedName} + isAggregatable={true} + fieldType={'keyword'} /> @@ -90,6 +94,8 @@ export const IndicatorDetails: React.FC = ({ fieldName={INDICATOR_REFERENCE} isDraggable={isDraggable} value={indicatorReference} + isAggregatable={true} + fieldType={'keyword'} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/match_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/match_details.tsx index 4a29bc677d810..31df9dc26cd0c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/match_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/match_details.tsx @@ -44,6 +44,8 @@ export const MatchDetails: React.FC = ({ field={INDICATOR_MATCHED_FIELD} isDraggable={isDraggable} value={sourceField} + isAggregatable={true} + fieldType={'keyword'} /> @@ -62,6 +64,8 @@ export const MatchDetails: React.FC = ({ field={sourceField} isDraggable={isDraggable} value={sourceValue} + isAggregatable={true} + fieldType={'keyword'} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.tsx index ff85336bd47f8..d71d42ff65495 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.tsx @@ -76,6 +76,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="dns.question.name" isDraggable={isDraggable} value={dnsQuestionName} + isAggregatable={true} + fieldType="keyword" /> @@ -93,6 +95,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="dns.question.type" isDraggable={isDraggable} value={dnsQuestionType} + isAggregatable={true} + fieldType="keyword" /> @@ -110,6 +114,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="dns.resolved_ip" isDraggable={isDraggable} value={dnsResolvedIp} + isAggregatable={true} + fieldType="ip" /> @@ -130,6 +136,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="dns.response_code" isDraggable={isDraggable} value={dnsResponseCode} + isAggregatable={true} + fieldType="keyword" /> @@ -165,6 +173,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="event.code" isDraggable={isDraggable} value={eventCode} + isAggregatable={true} + fieldType="number" /> ) : ( @@ -176,6 +186,8 @@ export const DnsRequestEventDetailsLine = React.memo( field="winlog.event_id" isDraggable={isDraggable} value={winlogEventId} + isAggregatable={true} + fieldType="keyword" /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx index 7e5a6dd08765b..28b632d8c8f5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx @@ -122,6 +122,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( isDraggable={isDraggable} queryValue={String(endgameLogonType)} value={`${endgameLogonType} - ${getHumanReadableLogonType(endgameLogonType)}`} + isAggregatable={true} + fieldType="keyword" /> @@ -142,6 +144,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( field="endgame.target_logon_id" isDraggable={isDraggable} value={endgameTargetLogonId} + isAggregatable={true} + fieldType="keyword" /> @@ -185,6 +189,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( isDraggable={isDraggable} iconType="user" value={endgameSubjectUserName} + isAggregatable={true} + fieldType="keyword" /> @@ -206,6 +212,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( field="endgame.subject_domain_name" isDraggable={isDraggable} value={endgameSubjectDomainName} + isAggregatable={true} + fieldType="keyword" /> @@ -226,6 +234,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( field="endgame.subject_logon_id" isDraggable={isDraggable} value={endgameSubjectLogonId} + isAggregatable={true} + fieldType="keyword" /> @@ -243,6 +253,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( eventId={id} field="event.code" value={eventCode} + isAggregatable={true} + fieldType="keyword" /> ) : ( @@ -253,6 +265,8 @@ export const EndgameSecurityEventDetailsLine = React.memo( iconType="logoWindows" field="winlog.event_id" value={winlogEventId} + isAggregatable={true} + fieldType="keyword" /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx index 1f1862daa4e55..04b74d77c922a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx @@ -42,6 +42,8 @@ export const ExitCodeDraggable = React.memo( field="process.exit_code" isDraggable={isDraggable} value={`${processExitCode}`} + fieldType="number" + isAggregatable={true} /> )} @@ -54,6 +56,8 @@ export const ExitCodeDraggable = React.memo( field="endgame.exit_code" isDraggable={isDraggable} value={endgameExitCode} + fieldType="number" + isAggregatable={true} /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.tsx index 7ff5a0f73ab30..819ab310e4587 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.tsx @@ -57,6 +57,8 @@ export const FileDraggable = React.memo( isDraggable={isDraggable} value={fileName} iconType="document" + isAggregatable={true} + fieldType="keyword" /> ) : !isNillEmptyOrNotFinite(endgameFileName) ? ( @@ -68,6 +70,8 @@ export const FileDraggable = React.memo( isDraggable={isDraggable} value={endgameFileName} iconType="document" + isAggregatable={true} + fieldType="keyword" /> ) : null} @@ -87,6 +91,8 @@ export const FileDraggable = React.memo( isDraggable={isDraggable} value={filePath} iconType="document" + isAggregatable={true} + fieldType="keyword" /> ) : !isNillEmptyOrNotFinite(endgameFilePath) ? ( @@ -98,6 +104,8 @@ export const FileDraggable = React.memo( isDraggable={isDraggable} value={endgameFilePath} iconType="document" + isAggregatable={true} + fieldType="keyword" /> ) : null} @@ -115,6 +123,8 @@ export const FileDraggable = React.memo( isDraggable={isDraggable} value={fileExtOriginalPath} iconType="document" + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.tsx index 13b024e0a5359..8b2b0907c6814 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.tsx @@ -39,6 +39,8 @@ export const FileHash = React.memo(({ contextId, eventId, fileHashSha256, isDraggable={isDraggable} iconType="number" value={fileHashSha256} + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 0cc79c6936992..c95971f68c06e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -52,6 +52,7 @@ const FormattedFieldValueComponent: React.FC<{ Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; contextId: string; eventId: string; + isAggregatable?: boolean; isObjectArray?: boolean; fieldFormat?: string; fieldName: string; @@ -70,8 +71,9 @@ const FormattedFieldValueComponent: React.FC<{ contextId, eventId, fieldFormat, + isAggregatable = false, fieldName, - fieldType, + fieldType = '', isButton, isObjectArray = false, isDraggable = true, @@ -92,6 +94,8 @@ const FormattedFieldValueComponent: React.FC<{ eventId={eventId} contextId={contextId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isButton={isButton} isDraggable={isDraggable} value={!isNumber(value) ? value : String(value)} @@ -107,6 +111,8 @@ const FormattedFieldValueComponent: React.FC<{ return isDraggable ? ( @@ -145,6 +155,8 @@ const FormattedFieldValueComponent: React.FC<{ Component={Component} contextId={contextId} eventId={eventId} + fieldType={fieldType} + isAggregatable={isAggregatable} fieldName={fieldName} isDraggable={isDraggable} isButton={isButton} @@ -160,6 +172,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} isButton={isButton} onClick={onClick} @@ -173,6 +187,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} value={`${value}`} /> @@ -184,6 +200,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} isButton={isButton} onClick={onClick} @@ -198,6 +216,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId, eventId, fieldName, + fieldType, + isAggregatable, isDraggable, linkValue, truncate, @@ -209,6 +229,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} value={value} onClick={onClick} @@ -223,6 +245,8 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + fieldType={fieldType} + isAggregatable={isAggregatable} isDraggable={isDraggable} value={typeof value === 'string' ? value : ''} /> @@ -240,6 +264,8 @@ const FormattedFieldValueComponent: React.FC<{ Component, eventId, fieldName, + fieldType, + isAggregatable, isDraggable, truncate, title, @@ -274,6 +300,8 @@ const FormattedFieldValueComponent: React.FC<{ void; @@ -59,6 +61,8 @@ export const RenderRuleName: React.FC = ({ contextId, eventId, fieldName, + fieldType, + isAggregatable, isDraggable, isButton, onClick, @@ -148,6 +152,8 @@ export const RenderRuleName: React.FC = ({ return isDraggable ? ( = ({ return isDraggable ? ( { contextId: 'test-context-id', eventId: 'test-event-id', isDraggable: false, + fieldType: 'keyword', + isAggregatable: true, value: 'Mock Host', }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index 2673304d2deb8..991f82d2b916c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -27,6 +27,8 @@ interface Props { Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; isButton?: boolean; onClick?: () => void; @@ -36,6 +38,8 @@ interface Props { const HostNameComponent: React.FC = ({ fieldName, + fieldType, + isAggregatable, Component, contextId, eventId, @@ -103,6 +107,8 @@ const HostNameComponent: React.FC = ({ isDraggable ? ( ( field="host.name" value={hostName} isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> {workingDirectory != null && ( @@ -45,6 +47,8 @@ export const HostWorkingDir = React.memo( value={workingDirectory} iconType="folderOpen" isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx index 7b30b6c1f054d..3cfc071bd2c6c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx @@ -60,6 +60,8 @@ export const ParentProcessDraggable = React.memo( field="process.parent.name" isDraggable={isDraggable} value={processParentName} + fieldType="keyword" + isAggregatable={true} /> )} @@ -72,6 +74,8 @@ export const ParentProcessDraggable = React.memo( field="endgame.parent_process_name" isDraggable={isDraggable} value={endgameParentProcessName} + fieldType="keyword" + isAggregatable={true} /> )} @@ -85,6 +89,8 @@ export const ParentProcessDraggable = React.memo( isDraggable={isDraggable} queryValue={String(processParentPid)} value={`(${String(processParentPid)})`} + fieldType="keyword" + isAggregatable={true} /> )} @@ -98,6 +104,8 @@ export const ParentProcessDraggable = React.memo( isDraggable={isDraggable} queryValue={String(processPpid)} value={`(${String(processPpid)})`} + fieldType="keyword" + isAggregatable={true} /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx index 35e8647bc413d..a7531a57f4716 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx @@ -50,9 +50,10 @@ export const plainColumnRenderer: ColumnRenderer = { asPlainText={asPlainText} contextId={`plain-column-renderer-formatted-field-value-${timelineId}`} eventId={eventId} - fieldFormat={field.format || ''} + fieldFormat={field.format ?? ''} fieldName={columnName} - fieldType={field.type || ''} + isAggregatable={field.aggregatable ?? false} + fieldType={field.type ?? ''} isDraggable={isDraggable} key={`plain-column-renderer-formatted-field-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`} linkValue={head(linkValues)} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.tsx index db7e3ae6f06c9..5247535dbc743 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.tsx @@ -56,6 +56,8 @@ export const ProcessDraggable = React.memo( value={processName} iconType="console" isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> ) : !isNillEmptyOrNotFinite(processExecutable) ? ( @@ -67,6 +69,8 @@ export const ProcessDraggable = React.memo( value={processExecutable} iconType="console" isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> ) : !isNillEmptyOrNotFinite(endgameProcessName) ? ( @@ -78,6 +82,8 @@ export const ProcessDraggable = React.memo( value={endgameProcessName} iconType="console" isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> ) : null} @@ -91,6 +97,8 @@ export const ProcessDraggable = React.memo( queryValue={String(processPid)} value={`(${String(processPid)})`} isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> ) : !isNillEmptyOrNotFinite(endgamePid) ? ( @@ -102,6 +110,8 @@ export const ProcessDraggable = React.memo( queryValue={String(endgamePid)} value={`(${String(endgamePid)})`} isDraggable={isDraggable} + fieldType="keyword" + isAggregatable={true} /> ) : null} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.tsx index dd4f588a14bb7..bbf652d886e65 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.tsx @@ -40,6 +40,8 @@ export const ProcessHash = React.memo( iconType="number" isDraggable={isDraggable} value={processHashSha256} + fieldType="keyword" + isAggregatable={true} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.tsx index 8d9f52da88fdd..749c87419b8e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.tsx @@ -92,6 +92,8 @@ const RegistryEventDetailsLineComponent: React.FC = ({ isDraggable={isDraggable} tooltipContent={registryKeyTooltipContent} value={registryKey} + isAggregatable={true} + fieldType="keyword" /> @@ -110,6 +112,8 @@ const RegistryEventDetailsLineComponent: React.FC = ({ isDraggable={isDraggable} tooltipContent={registryPathTooltipContent} value={registryPath} + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx index 7f0d036812869..25cd1d1475b26 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx @@ -26,6 +26,8 @@ interface BaseProps { contextId: string; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; value: string | number | undefined | null; } @@ -37,6 +39,8 @@ const RuleStatusComponent: React.FC = ({ contextId, eventId, fieldName, + fieldType, + isAggregatable, isDraggable, value, onClick, @@ -61,6 +65,8 @@ const RuleStatusComponent: React.FC = ({ - + ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/auth_ssh.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/auth_ssh.test.tsx.snap index bb0671693978b..e235f359815c7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/auth_ssh.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/auth_ssh.test.tsx.snap @@ -10,7 +10,9 @@ exports[`AuthSsh rendering it renders against shallow snapshot 1`] = ` contextId="[context-123]" eventId="[event-123]" field="system.audit.package.name" + fieldType="keyword" iconType="document" + isAggregatable={true} value="[ssh-signature]" /> @@ -22,7 +24,9 @@ exports[`AuthSsh rendering it renders against shallow snapshot 1`] = ` contextId="[context-123]" eventId="[event-123]" field="system.audit.package.version" + fieldType="keyword" iconType="document" + isAggregatable={true} value="[ssh-method]" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/package.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/package.test.tsx.snap index a80bc3da8395c..c58553a7513e2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/package.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/__snapshots__/package.test.tsx.snap @@ -10,7 +10,9 @@ exports[`Package rendering it renders against shallow snapshot 1`] = ` contextId="[context-123]" eventId="[event-123]" field="system.audit.package.name" + fieldType="keyword" iconType="document" + isAggregatable={true} value="package-name-123" /> @@ -22,7 +24,9 @@ exports[`Package rendering it renders against shallow snapshot 1`] = ` contextId="[context-123]" eventId="[event-123]" field="system.audit.package.version" + fieldType="keyword" iconType="document" + isAggregatable={true} value="package-version-123" /> @@ -34,6 +38,8 @@ exports[`Package rendering it renders against shallow snapshot 1`] = ` contextId="[context-123]" eventId="[event-123]" field="system.audit.package.summary" + fieldType="keyword" + isAggregatable={true} value="package-summary-123" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.tsx index 7de03a2ae2356..4484a43119608 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.tsx @@ -30,6 +30,8 @@ export const AuthSsh = React.memo( isDraggable={isDraggable} value={sshSignature} iconType="document" + isAggregatable={true} + fieldType="keyword" /> )} @@ -42,6 +44,8 @@ export const AuthSsh = React.memo( isDraggable={isDraggable} value={sshMethod} iconType="document" + isAggregatable={true} + fieldType="keyword" /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.tsx index 10b2c39ab74ca..471a764042be0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.tsx @@ -103,6 +103,8 @@ export const SystemGenericLine = React.memo( isDraggable={isDraggable} queryValue={outcome} value={outcome} + isAggregatable={true} + fieldType="keyword" /> ( isDraggable={isDraggable} queryValue={outcome} value={outcome} + isAggregatable={true} + fieldType="keyword" /> ( isDraggable={isDraggable} value={packageName} iconType="document" + isAggregatable={true} + fieldType="keyword" /> @@ -42,6 +44,8 @@ export const Package = React.memo( isDraggable={isDraggable} value={packageVersion} iconType="document" + isAggregatable={true} + fieldType="keyword" /> @@ -51,6 +55,8 @@ export const Package = React.memo( field="system.audit.package.summary" isDraggable={isDraggable} value={packageSummary} + isAggregatable={true} + fieldType="keyword" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.tsx index 9e789cbd7aba2..dfbb7d18be373 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.tsx @@ -47,6 +47,8 @@ export const UserHostWorkingDir = React.memo( isDraggable={isDraggable} value={userName} iconType="user" + fieldType="keyword" + isAggregatable={true} /> @@ -66,6 +68,8 @@ export const UserHostWorkingDir = React.memo( field={userDomainField} isDraggable={isDraggable} value={userDomain} + fieldType="keyword" + isAggregatable={true} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx index 12d5e808f98fc..f9f1ba62b0eed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx @@ -54,6 +54,8 @@ jest.mock('../../../../store/timeline', () => { describe('UserName', () => { const props = { fieldName: 'user.name', + fieldType: 'keyword', + isAggregatable: true, contextId: 'test-context-id', eventId: 'test-event-id', isDraggable: false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx index 3d75835a54bac..dcf566c88862b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx @@ -27,6 +27,8 @@ interface Props { Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; eventId: string; fieldName: string; + fieldType: string; + isAggregatable: boolean; isDraggable: boolean; isButton?: boolean; onClick?: () => void; @@ -39,6 +41,8 @@ const UserNameComponent: React.FC = ({ Component, contextId, eventId, + fieldType, + isAggregatable, isDraggable, isButton, onClick, @@ -104,6 +108,8 @@ const UserNameComponent: React.FC = ({ - + ) : null; }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index 88de4a9c2c6df..63793e94137ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -24,7 +24,6 @@ const hasCellActions = (columnId?: string) => { }; export const DefaultCellRenderer: React.FC = ({ - browserFields, data, ecsData, eventId, @@ -73,10 +72,9 @@ export const DefaultCellRenderer: React.FC = ({ values, })} - {isDetails && browserFields && hasCellActions(header.id) && ( + {isDetails && hasCellActions(header.id) && ( [ attrName: 'user.name', idPrefix: `users-table-${name}-name`, render: (item) => , + isAggregatable: true, + fieldType: 'keyword', }) : getOrEmptyTagFromValue(name), }, @@ -96,6 +98,8 @@ const getUsersColumns = (): UsersTableColumns => [ rowItems: [domain], attrName: 'user.domain', idPrefix: `users-table-${domain}-domain`, + isAggregatable: true, + fieldType: 'keyword', }) : getOrEmptyTagFromValue(domain), }, diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx index 3ea4d6a14c247..f0c26ed31bb7a 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx @@ -59,6 +59,8 @@ export const getUserRiskScoreColumns = ({ ) } + isAggregatable={true} + fieldType={'keyword'} /> ); } diff --git a/x-pack/plugins/security_solution/public/users/links.ts b/x-pack/plugins/security_solution/public/users/links.ts index bd7bef4af8e82..a06eaa1b9f566 100644 --- a/x-pack/plugins/security_solution/public/users/links.ts +++ b/x-pack/plugins/security_solution/public/users/links.ts @@ -9,10 +9,16 @@ import { i18n } from '@kbn/i18n'; import { SecurityPageName, USERS_PATH } from '../../common/constants'; import { USERS } from '../app/translations'; import { LinkItem } from '../common/links/types'; +import userPageImg from '../common/images/users_page.png'; export const links: LinkItem = { id: SecurityPageName.users, title: USERS, + landingImage: userPageImg, + description: i18n.translate('xpack.securitySolution.appLinks.users.description', { + defaultMessage: + 'A comprehensive overview of user data that enables understanding of authentication and user behavior within your environment.', + }), path: USERS_PATH, globalNavEnabled: true, experimentalKey: 'usersEnabled', @@ -41,7 +47,7 @@ export const links: LinkItem = { { id: SecurityPageName.usersRisk, title: i18n.translate('xpack.securitySolution.appLinks.users.risk', { - defaultMessage: 'Users by risk', + defaultMessage: 'User risk', }), path: `${USERS_PATH}/userRisk`, experimentalKey: 'riskyUsersEnabled', diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.test.ts new file mode 100644 index 0000000000000..2680b604c6e28 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.test.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 { MatrixHistogramType } from '../../../../../common/search_strategy'; +import { getGenericData } from './helpers'; +import { stackedByBooleanField, stackedByTextField, result, textResult } from './mock_data'; + +describe('getGenericData', () => { + test('stack by a boolean field', () => { + const res = getGenericData(stackedByBooleanField, 'events.bucket'); + expect(res).toEqual(result); + }); + + test('stack by a text field', () => { + const res = getGenericData(stackedByTextField, 'events.bucket'); + expect(res).toEqual(textResult); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.ts index aa6b85d795443..73b90e7cc32ee 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/helpers.ts @@ -18,7 +18,8 @@ export const getGenericData = ( ): MatrixHistogramData[] => { let result: MatrixHistogramData[] = []; data.forEach((bucketData: unknown) => { - const group = get('key', bucketData); + // if key_as_string is present use it, else default to the existing key + const group = get('key_as_string', bucketData) ?? get('key', bucketData); const histData = getOr([], keyBucket, bucketData).map( // eslint-disable-next-line @typescript-eslint/naming-convention ({ key, doc_count }: MatrixHistogramBucket) => ({ diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/mock_data.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/mock_data.ts new file mode 100644 index 0000000000000..9a938846826a1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/mock_data.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. + */ + +export const stackedByBooleanField = [ + { + key: 1, + key_as_string: 'true', + doc_count: 7125, + events: { + bucket: [ + { key_as_string: '2022-05-10T15:34:48.075Z', key: 1652196888075, doc_count: 0 }, + { key_as_string: '2022-05-10T16:19:48.074Z', key: 1652199588074, doc_count: 774 }, + { key_as_string: '2022-05-10T17:04:48.073Z', key: 1652202288073, doc_count: 415 }, + ], + }, + }, +]; +export const result = [ + { x: 1652196888075, y: 0, g: 'true' }, + { x: 1652199588074, y: 774, g: 'true' }, + { x: 1652202288073, y: 415, g: 'true' }, +]; + +export const stackedByTextField = [ + { + key: 'MacBook-Pro.local', + doc_count: 7103, + events: { + bucket: [ + { key_as_string: '2022-05-10T15:34:48.075Z', key: 1652196888075, doc_count: 0 }, + { key_as_string: '2022-05-10T16:19:48.074Z', key: 1652199588074, doc_count: 774 }, + { key_as_string: '2022-05-10T17:04:48.073Z', key: 1652202288073, doc_count: 415 }, + ], + }, + }, +]; + +export const textResult = [ + { x: 1652196888075, y: 0, g: 'MacBook-Pro.local' }, + { x: 1652199588074, y: 774, g: 'MacBook-Pro.local' }, + { x: 1652202288073, y: 415, g: 'MacBook-Pro.local' }, +]; diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 98c27bd4f86e1..7a7c1c39cd467 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -14,34 +14,16 @@ export const PREVIEW_ALERTS_INDEX = '.preview.alerts-security.alerts-default'; export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ HH:mm:ss.SSS'; +export const ALERT_ORIGINAL_TIME_PROPERTY = 'kibana.alert.original_time'; export const ALERT_STATUS = { OPEN: 'open', ACKNOWLEDGED: 'acknowledged', CLOSED: 'closed', }; -// We fetch a large number of events per page to mitigate a few design caveats in session viewer -// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there -// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page -// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing -// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite -// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used -// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user. -// We may need to include this trick as part of this implementation as well. -// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser. -// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands -// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the -// search functionality will instead use a separate ES backend search to avoid this. -// 3. Fewer round trips to the backend! -export const PROCESS_EVENTS_PER_PAGE = 1000; - -// As an initial approach, we won't be implementing pagination for alerts. -// Instead we will load this fixed amount of alerts as a maximum for a session. -// This could cause an edge case, where a noisy rule that alerts on every process event -// causes a session to only list and highlight up to 1000 alerts, even though there could -// be far greater than this amount. UX should be added to let the end user know this is -// happening and to revise their rule to be more specific. -export const ALERTS_PER_PAGE = 501; +export const PROCESS_EVENTS_PER_PAGE = 200; +export const ALERTS_PER_PROCESS_EVENTS_PAGE = 600; +export const ALERTS_PER_PAGE = 100; // when showing the count of alerts in details panel tab, if the number // exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 500+ diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts index 76f954d1fe72e..1b8ffbea1f93d 100644 --- a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -1263,6 +1263,7 @@ export const childProcessMock: Process = { orphans: [], addEvent: (_) => undefined, addAlert: (_) => undefined, + addChild: (_) => undefined, clearSearch: () => undefined, getChildren: () => [], hasOutput: () => false, @@ -1348,6 +1349,7 @@ export const processMock: Process = { orphans: [], addEvent: (_) => undefined, addAlert: (_) => undefined, + addChild: (_) => undefined, clearSearch: () => undefined, getChildren: () => [], hasOutput: () => false, @@ -1553,6 +1555,7 @@ export const mockProcessMap = mockEvents.reduce( orphans: [], addEvent: (_) => undefined, addAlert: (_) => undefined, + addChild: (_) => undefined, clearSearch: () => undefined, getChildren: () => [], hasOutput: () => false, diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts index 239a9f34632cd..b80be0b690135 100644 --- a/x-pack/plugins/session_view/common/types/process_tree/index.ts +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -170,6 +170,7 @@ export interface Process { searchMatched: string | null; // either false, or set to searchQuery addEvent(event: ProcessEvent): void; addAlert(alert: ProcessEvent): void; + addChild(child: Process): void; clearSearch(): void; hasOutput(): boolean; hasAlerts(): boolean; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx index 562bd013ebd60..87d1af77da38b 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; import { ProcessEvent } from '../../../common/types/process_tree'; -import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; import { dataOrDash } from '../../utils/data_or_dash'; import { useStyles } from '../detail_panel_alert_list_item/styles'; import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; @@ -31,10 +30,7 @@ export const DetailPanelAlertGroupItem = ({ onShowAlertDetails, }: DetailPanelAlertsGroupItemDeps) => { const styles = useStyles(); - - const alertsCount = useMemo(() => { - return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; - }, [alerts]); + const alertsCount = alerts.length.toLocaleString(); if (!alerts[0].kibana) { return null; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx index 76f1cf41fb26a..7358e9e11e0b0 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx @@ -31,24 +31,26 @@ describe('DetailPanelAlertTab component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; - let mockOnJumpToEvent = jest.fn((process) => process); - let mockShowAlertDetails = jest.fn((alertId) => alertId); + + const props = { + alerts: mockAlerts, + onJumpToEvent: jest.fn((process) => process), + onShowAlertDetails: jest.fn((alertId) => alertId), + isFetchingAlerts: false, + hasNextPageAlerts: false, + fetchNextPageAlerts: jest.fn(() => true), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); - mockOnJumpToEvent = jest.fn((process) => process); - mockShowAlertDetails = jest.fn((alertId) => alertId); + props.onJumpToEvent.mockReset(); + props.onShowAlertDetails.mockReset(); + props.fetchNextPageAlerts.mockReset(); }); describe('When DetailPanelAlertTab is mounted', () => { it('renders a list of alerts for the session (defaulting to list view mode)', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy(); @@ -61,13 +63,7 @@ describe('DetailPanelAlertTab component', () => { }); it('renders a list of alerts grouped by rule when group-view clicked', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); @@ -82,13 +78,10 @@ describe('DetailPanelAlertTab component', () => { }); it('renders a sticky investigated alert (outside of main list) if one is set', async () => { + const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid; + renderResult = mockedContext.render( - + ); expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); @@ -99,13 +92,10 @@ describe('DetailPanelAlertTab component', () => { }); it('investigated alert should be collapsible', async () => { + const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid; + renderResult = mockedContext.render( - + ); expect( @@ -132,13 +122,7 @@ describe('DetailPanelAlertTab component', () => { }); it('non investigated alert should NOT be collapsible', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect( renderResult @@ -164,13 +148,7 @@ describe('DetailPanelAlertTab component', () => { }); it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); @@ -198,13 +176,7 @@ describe('DetailPanelAlertTab component', () => { }); it('each alert list item should show a timestamp and process arguments', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent( mockAlerts[0]['@timestamp']! @@ -216,13 +188,7 @@ describe('DetailPanelAlertTab component', () => { }); it('each alert group should show a rule title and alert count', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); @@ -233,15 +199,18 @@ describe('DetailPanelAlertTab component', () => { }); it('renders an empty state when there are no alerts', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy(); }); + + it('renders a load more button when there are more pages of alerts', async () => { + renderResult = mockedContext.render( + + ); + expect(renderResult.queryByTestId('alerts-details-load-more')).toBeTruthy(); + renderResult.queryByTestId('alerts-details-load-more')?.click(); + expect(props.fetchNextPageAlerts.mock.calls.length).toEqual(1); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx index c8d32a833d369..977aa236f5156 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { useState, useMemo } from 'react'; -import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { groupBy } from 'lodash'; @@ -20,6 +20,9 @@ export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode'; interface DetailPanelAlertTabDeps { alerts: ProcessEvent[]; + isFetchingAlerts: boolean; + hasNextPageAlerts?: boolean; + fetchNextPageAlerts: () => void; onJumpToEvent: (event: ProcessEvent) => void; onShowAlertDetails: (alertId: string) => void; investigatedAlertId?: string; @@ -33,6 +36,9 @@ const VIEW_MODE_GROUP = 'groupView'; */ export const DetailPanelAlertTab = ({ alerts, + isFetchingAlerts, + hasNextPageAlerts, + fetchNextPageAlerts, onJumpToEvent, onShowAlertDetails, investigatedAlertId, @@ -147,6 +153,21 @@ export const DetailPanelAlertTab = ({ ); } })} + + {hasNextPageAlerts && ( + + + + )} ); }; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts index 702d4f20f3554..78c0470c796b6 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts @@ -30,10 +30,16 @@ export const useStyles = () => { margin: size.base, }; + const loadMoreBtn: CSSObject = { + margin: size.m, + width: `calc(100% - ${size.m} * 2)`, + }; + return { container, stickyItem, viewMode, + loadMoreBtn, }; }, [euiTheme]); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts index 4417a5329a752..6acb5aba9dc08 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts @@ -141,7 +141,14 @@ export const getDetailPanelProcess = (process: Process | null): DetailPanelProce } }); if (!processData.executable.length) { - processData.executable = DEFAULT_PROCESS_DATA.executable; + // if there were no forks, execs (due to bad data), check if we at least have an executable for some event + const executable = process.getDetails().process?.executable; + + if (executable) { + processData.executable.push([executable]); + } else { + processData.executable = DEFAULT_PROCESS_DATA.executable; + } } processData.entryLeader = getDetailPanelProcessLeader(details?.process?.entry_leader); diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts index ee2670dad47d2..9a0834a015476 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import uuid from 'uuid'; import { sortProcesses } from '../../../common/utils/sort_processes'; import { AlertStatusEventEntityIdMap, @@ -11,9 +12,43 @@ import { Process, ProcessEvent, ProcessMap, + ProcessFields, } from '../../../common/types/process_tree'; import { ProcessImpl } from './hooks'; +// Creates an instance of Process, from a nested leader process fieldset +// This is used to ensure we always have a record for a session leader, as well as +// a parent record for potentially orphaned processes +export function inferProcessFromLeaderInfo(sourceEvent?: ProcessEvent, leader?: ProcessFields) { + const entityId = leader?.entity_id || uuid.v4(); + const process = new ProcessImpl(entityId); + + if (sourceEvent && leader) { + const event = { + ...sourceEvent, + process: { + ...sourceEvent.process, + ...leader, + }, + user: leader.user, + group: leader.group, + event: { + ...sourceEvent.event, + id: `fake-${entityId}`, + }, + }; + + // won't be accurate, so removing + if (sourceEvent.process?.parent === leader) { + delete event.process?.parent; + } + + process.addEvent(event); + } + + return process; +} + // if given event is an alert, and it exist in updatedAlertsStatus, update the alert's status // with the updated status value in updatedAlertsStatus Map export const updateAlertEventStatus = ( @@ -87,29 +122,36 @@ export const buildProcessTree = ( events.forEach((event) => { const { entity_id: id, parent } = event.process ?? {}; const process = processMap[id ?? '']; - const parentProcess = processMap[parent?.entity_id ?? '']; + let parentProcess = processMap[parent?.entity_id ?? '']; + // if either entity_id or parent does not exist, return - // if session leader, or process already has a parent, return - if (!id || !parent || process.id === sessionEntityId || process.parent) { + // if process already has a parent, return + if (!id || !parent || process.parent || id === sessionEntityId) { return; } - if (parentProcess) { - process.parent = parentProcess; // handy for recursive operations (like auto expand) + if (!parentProcess) { + // infer a fake process for the parent, incase we don't end up loading any parent events (due to filtering or jumpToCursor pagination) + const parentFields = event?.process?.parent; - if (backwardDirection) { - parentProcess.children.unshift(process); - } else { - parentProcess.children.push(process); - } - } else if (!orphans?.includes(process)) { - // if no parent process, process is probably orphaned - if (backwardDirection) { - orphans?.unshift(process); + if (parentFields?.entity_id && !processMap[parentFields.entity_id]) { + parentProcess = inferProcessFromLeaderInfo(event, parentFields); + processMap[parentProcess.id] = parentProcess; + + if (!orphans.includes(parentProcess)) { + orphans.push(parentProcess); + } } else { - orphans?.push(process); + if (!orphans.includes(process)) { + orphans.push(process); + } } } + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + parentProcess.addChild(process); + } }); const newOrphans: Process[] = []; @@ -120,13 +162,16 @@ export const buildProcessTree = ( if (parentProcessId) { const parentProcess = processMap[parentProcessId]; - process.parent = parentProcess; // handy for recursive operations (like auto expand) - if (parentProcess !== undefined) { - parentProcess.children.push(process); + + if (parentProcess) { + process.parent = parentProcess; + parentProcess.addChild(process); + + return; } - } else { - newOrphans.push(process); } + + newOrphans.push(process); }); return newOrphans; @@ -158,7 +203,7 @@ export const searchProcessTree = ( } const event = process.getDetails(); - const { working_directory: workingDirectory, args } = event.process || {}; + const { working_directory: workingDirectory, args } = event.process ?? {}; // TODO: the text we search is the same as what we render. // in future we may support KQL searches to match against any property diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index fe4c5c24afe78..bd753ab28c455 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -5,7 +5,8 @@ * 2.0. */ import memoizeOne from 'memoize-one'; -import { useState, useEffect } from 'react'; +import { sortedUniqBy } from 'lodash'; +import { useState, useEffect, useMemo } from 'react'; import { AlertStatusEventEntityIdMap, EventAction, @@ -16,18 +17,17 @@ import { ProcessEventsPage, } from '../../../common/types/process_tree'; import { + inferProcessFromLeaderInfo, updateAlertEventStatus, processNewEvents, searchProcessTree, autoExpandProcessTree, - updateProcessMap, } from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; - alerts: ProcessEvent[]; searchQuery?: string; updatedAlertsStatus: AlertStatusEventEntityIdMap; verboseMode: boolean; @@ -55,8 +55,6 @@ export class ProcessImpl implements Process { } addEvent(newEvent: ProcessEvent) { - // rather than push new events on the array, we return a new one - // this helps the below memoizeOne functions to behave correctly. const exists = this.events.find((event) => { return event.event?.id === newEvent.event?.id; }); @@ -67,7 +65,17 @@ export class ProcessImpl implements Process { } addAlert(alert: ProcessEvent) { - this.alerts = this.alerts.concat(alert); + const exists = this.alerts.find((event) => { + return event.event?.id === alert.event?.id; + }); + + if (!exists) { + this.alerts = this.alerts.concat(alert); + } + } + + addChild(newChild: Process) { + this.children = this.children.concat(newChild); } clearSearch() { @@ -75,11 +83,17 @@ export class ProcessImpl implements Process { } getChildren(verboseMode: boolean) { - let children = this.children; + return this.getChildrenMemo(this.children, this.orphans, verboseMode); + } + + getChildrenMemo = memoizeOne((children: Process[], orphans: Process[], verboseMode: boolean) => { + if (children.length === 0 && orphans.length === 0) { + return []; + } // if there are orphans, we just render them inline with the other child processes (currently only session leader does this) - if (this.orphans.length) { - children = [...children, ...this.orphans]; + if (orphans.length) { + children = [...children, ...orphans]; } // When verboseMode is false, we filter out noise via a few techniques. // This option is driven by the "verbose mode" toggle in SessionView/index.tsx @@ -102,8 +116,8 @@ export class ProcessImpl implements Process { }); } - return children.sort(sortProcesses); - } + return sortedUniqBy(children.sort(sortProcesses), (child) => child.id); + }); isVerbose() { const { @@ -235,6 +249,12 @@ export class ProcessImpl implements Process { return actionsToFind.includes(processEvent.event?.action); }); + // there are some anomalous processes which are omitting event.action + // we return whatever we have regardless so we at least render something in process tree + if (filtered.length === 0 && events.length > 0) { + return events[events.length - 1]; + } + // because events is already ordered by @timestamp we take the last event // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) @@ -259,29 +279,17 @@ export class ProcessImpl implements Process { export const useProcessTree = ({ sessionEntityId, data, - alerts, searchQuery, updatedAlertsStatus, verboseMode, jumpToEntityId, }: UseProcessTreeDeps) => { - // initialize map, as well as a placeholder for session leader process - // we add a fake session leader event, sourced from wide event data. - // this is because we might not always have a session leader event - // especially if we are paging in reverse from deep within a large session - const fakeLeaderEvent = data[0].events?.find?.((event) => event.event?.kind === EventKind.event); - const sessionLeaderProcess = new ProcessImpl(sessionEntityId); - - if (fakeLeaderEvent) { - fakeLeaderEvent.user = fakeLeaderEvent?.process?.entry_leader?.user; - fakeLeaderEvent.group = fakeLeaderEvent?.process?.entry_leader?.group; - fakeLeaderEvent.process = { - ...fakeLeaderEvent.process, - ...fakeLeaderEvent.process?.entry_leader, - parent: fakeLeaderEvent.process?.parent, - }; - sessionLeaderProcess.events.push(fakeLeaderEvent); - } + const firstEvent = data[0]?.events?.[0]; + const sessionLeaderProcess = useMemo(() => { + const entryLeader = firstEvent?.process?.entry_leader; + + return inferProcessFromLeaderInfo(firstEvent, entryLeader); + }, [firstEvent]); const initializedProcessMap: ProcessMap = { [sessionEntityId]: sessionLeaderProcess, @@ -289,7 +297,6 @@ export const useProcessTree = ({ const [processMap, setProcessMap] = useState(initializedProcessMap); const [processedPages, setProcessedPages] = useState([]); - const [alertsProcessed, setAlertsProcessed] = useState(false); const [searchResults, setSearchResults] = useState([]); const [orphans, setOrphans] = useState([]); @@ -327,16 +334,6 @@ export const useProcessTree = ({ } }, [data, processMap, orphans, processedPages, sessionEntityId, jumpToEntityId]); - useEffect(() => { - // currently we are loading a single page of alerts, with no pagination - // so we only need to add these alert events to processMap once. - if (!alertsProcessed) { - const updatedProcessMap = updateProcessMap(processMap, alerts); - setProcessMap({ ...updatedProcessMap }); - setAlertsProcessed(true); - } - }, [processMap, alerts, alertsProcessed]); - useEffect(() => { setSearchResults(searchProcessTree(processMap, searchQuery, verboseMode)); }, [searchQuery, processMap, verboseMode]); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index bdc07b3850987..c540463ce733b 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mockData, - mockAlerts, nullMockData, deepNullMockData, } from '../../../common/mocks/constants/session_view_process.mock'; @@ -24,7 +23,6 @@ describe('ProcessTree component', () => { const props: ProcessTreeDeps = { sessionEntityId: sessionLeader.process!.entity_id!, data: mockData, - alerts: mockAlerts, isFetching: false, fetchNextPage: jest.fn(), hasNextPage: false, diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 8a06ec769b7a9..0c4872487a376 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -14,7 +14,6 @@ import { AlertStatusEventEntityIdMap, Process, ProcessEventsPage, - ProcessEvent, } from '../../../common/types/process_tree'; import { useScroll } from '../../hooks/use_scroll'; import { useStyles } from './styles'; @@ -41,7 +40,6 @@ export interface ProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; - alerts: ProcessEvent[]; jumpToEntityId?: string; investigatedAlertId?: string; @@ -69,7 +67,6 @@ export interface ProcessTreeDeps { export const ProcessTree = ({ sessionEntityId, data, - alerts, jumpToEntityId, investigatedAlertId, isFetching, @@ -93,7 +90,6 @@ export const ProcessTree = ({ const { sessionLeader, processMap, searchResults } = useProcessTree({ sessionEntityId, data, - alerts, searchQuery, updatedAlertsStatus, verboseMode, diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/__snapshots__/index.test.tsx.snap b/x-pack/plugins/session_view/public/components/process_tree_alert/__snapshots__/index.test.tsx.snap index b26778c8f8eea..898da00b7852d 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/__snapshots__/index.test.tsx.snap @@ -6,93 +6,171 @@ Object { "baseElement":
- -
- cmd test alert -
- - + +
+
+ +
+
+
+ cmd test alert +
+
+
- open + + + open + + - - +
+
+ + + + exec + + + +
+
, "container":
- -
- cmd test alert -
- - + +
+
+ +
+
+
+ cmd test alert +
+
+
+ + + + open + + + +
+
- open + + + exec + + - - +
+
, "debug": [Function], diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 7ed1783e2da3f..9dbbda2a2733c 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -40,6 +40,7 @@ describe('ProcessTreeAlerts component', () => { expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy(); expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy(); expect(renderResult.queryByText(ALERT_STATUS!)).toBeTruthy(); + expect(renderResult).toMatchSnapshot(); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index f01f08f8e3095..77fce97b5167c 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, useCallback } from 'react'; -import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui'; import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree'; import { dataOrDash } from '../../utils/data_or_dash'; import { getBadgeColorFromAlertStatus } from './helpers'; @@ -31,6 +31,7 @@ export const ProcessTreeAlert = ({ }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); + const { event } = alert; const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {}; useEffect(() => { @@ -58,27 +59,39 @@ export const ProcessTreeAlert = ({ const { name } = rule; return ( - - - - - {dataOrDash(name)} - - - {dataOrDash(status)} - - +
+ + + + + + + + + + {dataOrDash(name)} + + + + + {dataOrDash(status)} + + + + {event?.action} + + +
); }; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts index e8d3b0374c978..6659028df8867 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts @@ -43,11 +43,7 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => { const alert: CSSObject = { fontFamily: font.family, - display: 'flex', - gap: size.s, - alignItems: 'center', - padding: `0 ${size.base}`, - boxSizing: 'content-box', + padding: `0 ${size.m}`, cursor: 'pointer', '&:not(:last-child)': { marginBottom: size.s, @@ -58,11 +54,15 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => { }, '&& button': { flexShrink: 0, - marginRight: size.s, + marginRight: size.xs, '&:hover, &:focus, &:focus-within': { backgroundColor: transparentize(euiVars.buttonsBackgroundNormalDefaultPrimary, 0.2), }, }, + '&& .euiFlexItem': { + marginTop: size.xxs, + marginBottom: size.xxs, + }, }; const alertStatus: CSSObject = { @@ -70,14 +70,18 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => { }; const alertName: CSSObject = { - padding: `${size.xs} 0`, color: colors.title, }; + const actionBadge: CSSObject = { + textTransform: 'capitalize', + }; + return { alert, alertStatus, alertName, + actionBadge, }; }, [euiTheme, isInvestigated, isSelected, euiVars]); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index db81734c65937..03d252d96c4c8 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -202,17 +202,7 @@ export function ProcessTreeNode({ } }, [hasExec, process.parent]); - const children = useMemo(() => { - if (searchResults) { - // noop - // Only used to break cache on this memo when search changes. We need this ref - // to avoid complaints from the useEffect dependency eslint rule. - // This fixes an issue when verbose mode is OFF and there are matching results on - // hidden processes. - } - - return process.getChildren(verboseMode); - }, [process, verboseMode, searchResults]); + const children = process.getChildren(verboseMode); if (!processDetails?.process) { return null; @@ -229,7 +219,7 @@ export function ProcessTreeNode({ user, } = processDetails.process; - const shouldRenderChildren = childrenExpanded && children?.length > 0; + const shouldRenderChildren = isSessionLeader || (childrenExpanded && children?.length > 0); const childrenTreeDepth = depth + 1; const showUserEscalation = !isSessionLeader && !!user?.name && user.name !== parent?.user?.name; diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index 3beb664153e0c..02efaa6055dc2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -18,6 +18,7 @@ import { ALERTS_ROUTE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, + ALERTS_PER_PAGE, ALERT_STATUS_ROUTE, QUERY_KEY_PROCESS_EVENTS, QUERY_KEY_ALERTS, @@ -58,7 +59,7 @@ export const useFetchSessionViewProcessEvents = ( }, { getNextPageParam: (lastPage) => { - if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { + if (lastPage.events.length >= PROCESS_EVENTS_PER_PAGE) { return { cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], forward: true, @@ -95,27 +96,45 @@ export const useFetchSessionViewProcessEvents = ( return query; }; -export const useFetchSessionViewAlerts = (sessionEntityId: string) => { +export const useFetchSessionViewAlerts = ( + sessionEntityId: string, + investigatedAlertId?: string +) => { const { http } = useKibana().services; - const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId]; - const query = useQuery( + const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId, investigatedAlertId]; + + const query = useInfiniteQuery( cachingKeys, - async () => { + async ({ pageParam = {} }) => { + const { cursor } = pageParam; + const res = await http.get(ALERTS_ROUTE, { query: { sessionEntityId, + investigatedAlertId, + cursor, }, }); const events = res.events?.map((event: any) => event._source as ProcessEvent) ?? []; - return events; + return { + events, + cursor, + total: res.total, + }; }, { + getNextPageParam: (lastPage) => { + if (lastPage.events.length >= ALERTS_PER_PAGE) { + return { + cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + }; + } + }, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, - cacheTime: 0, } ); diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index 5fe3e2365cc58..036485915cce8 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -118,12 +118,34 @@ export const SessionView = ({ hasPreviousPage, } = useFetchSessionViewProcessEvents(sessionEntityId, currentJumpToCursor); - const alertsQuery = useFetchSessionViewAlerts(sessionEntityId); - const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery; + const { + data: alertsData, + fetchNextPage: fetchNextPageAlerts, + isFetching: isFetchingAlerts, + hasNextPage: hasNextPageAlerts, + error: alertsError, + } = useFetchSessionViewAlerts(sessionEntityId, investigatedAlertId); + + const alerts = useMemo(() => { + let events: ProcessEvent[] = []; + + if (alertsData) { + alertsData.pages.forEach((page) => { + events = events.concat(page.events); + }); + } + + return events; + }, [alertsData]); + + const alertsCount = useMemo(() => { + return alertsData?.pages?.[0].total || 0; + }, [alertsData]); - const hasData = alerts && data && data.pages?.[0].events.length > 0; const hasError = error || alertsError; - const renderIsLoading = (isFetching || alertsFetching) && !(data && alerts); + const dataLoaded = data && data.pages?.length > (jumpToCursor ? 1 : 0); + const renderIsLoading = isFetching && !dataLoaded; + const hasData = dataLoaded && data.pages[0].events.length > 0; const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( updatedAlertsStatus, fetchAlertStatus[0] ?? '' @@ -276,7 +298,6 @@ export const SessionView = ({ key={sessionEntityId + currentJumpToCursor} sessionEntityId={sessionEntityId} data={data.pages} - alerts={alerts} searchQuery={searchQuery} selectedProcess={selectedProcess} onProcessSelected={onProcessSelected} @@ -307,6 +328,10 @@ export const SessionView = ({ > { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; - let mockOnJumpToEvent = jest.fn((process) => process); - let mockShowAlertDetails = jest.fn((alertId) => alertId); + + const props = { + alerts: [], + alertsCount: 0, + selectedProcess: sessionViewBasicProcessMock, + isFetchingAlerts: false, + hasNextPageAlerts: false, + fetchNextPageAlerts: jest.fn(() => true), + onJumpToEvent: jest.fn((process) => process), + onShowAlertDetails: jest.fn((alertId) => alertId), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); - mockOnJumpToEvent = jest.fn((process) => process); - mockShowAlertDetails = jest.fn((alertId) => alertId); + props.onJumpToEvent.mockReset(); + props.onShowAlertDetails.mockReset(); + props.fetchNextPageAlerts.mockReset(); }); describe('When SessionViewDetailPanel is mounted', () => { it('shows process detail by default', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); }); it('should should default state with selectedProcess null', async () => { renderResult = mockedContext.render( - + ); expect(renderResult.queryAllByText('entity_id').length).toBe(5); }); it('can switch tabs to show host details', async () => { - renderResult = mockedContext.render( - - ); - + renderResult = mockedContext.render(); renderResult.queryByText('Metadata')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); @@ -65,27 +58,13 @@ describe('SessionView component', () => { it('can switch tabs to show alert details', async () => { renderResult = mockedContext.render( - + ); - renderResult.queryByText('Alerts')?.click(); expect(renderResult.queryByText('List view')).toBeVisible(); }); it('alert tab disabled when no alerts', async () => { - renderResult = mockedContext.render( - - ); - + renderResult = mockedContext.render(); renderResult.queryByText('Alerts')?.click(); expect(renderResult.queryByText('List view')).toBeFalsy(); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index cd52bc5ff23bb..b26b08bca8529 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -19,6 +19,10 @@ import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; interface SessionViewDetailPanelDeps { selectedProcess: Process | null; alerts?: ProcessEvent[]; + alertsCount: number; + isFetchingAlerts: boolean; + hasNextPageAlerts?: boolean; + fetchNextPageAlerts: () => void; investigatedAlertId?: string; onJumpToEvent: (event: ProcessEvent) => void; onShowAlertDetails: (alertId: string) => void; @@ -29,6 +33,10 @@ interface SessionViewDetailPanelDeps { */ export const SessionViewDetailPanel = ({ alerts, + alertsCount, + isFetchingAlerts, + hasNextPageAlerts, + fetchNextPageAlerts, selectedProcess, investigatedAlertId, onJumpToEvent, @@ -36,13 +44,9 @@ export const SessionViewDetailPanel = ({ }: SessionViewDetailPanelDeps) => { const [selectedTabId, setSelectedTabId] = useState('process'); - const alertsCount = useMemo(() => { - if (!alerts) { - return 0; - } - - return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; - }, [alerts]); + const alertsCountStr = useMemo(() => { + return alertsCount >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alertsCount + ''; + }, [alertsCount]); const tabs: EuiTabProps[] = useMemo(() => { const hasAlerts = !!alerts?.length; @@ -75,12 +79,15 @@ export const SessionViewDetailPanel = ({ }), append: hasAlerts && ( - {alertsCount} + {alertsCountStr} ), content: alerts && ( { return { @@ -65,57 +51,16 @@ const getResponse = async () => { }; }; -const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); - -const alertsClientParams: jest.Mocked = { - logger: loggingSystemMock.create().get(), - authorization: alertingAuthMock, - auditLogger, - ruleDataService: ruleDataServiceMock.create(), - esClient: esClientMock, -}; - describe('alert_status_route.ts', () => { beforeEach(() => { jest.resetAllMocks(); - - alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); - // @ts-expect-error - alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => - Promise.resolve({ filter: [] }) - ); - // @ts-expect-error - alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { - const authorizedRuleTypes = new Set(); - authorizedRuleTypes.add({ producer: 'apm' }); - return Promise.resolve({ authorizedRuleTypes }); - }); - - alertingAuthMock.ensureAuthorized.mockImplementation( - // @ts-expect-error - async ({ - ruleTypeId, - consumer, - operation, - entity, - }: { - ruleTypeId: string; - consumer: string; - operation: string; - entity: typeof AlertingAuthorizationEntity.Alert; - }) => { - if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { - return Promise.resolve(); - } - return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); - } - ); + resetAlertingAuthMock(); }); describe('searchAlertByUuid(client, alertUuid)', () => { it('should return an empty events array for a non existant alert uuid', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); - const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const alertsClient = getAlertsClientMockInstance(esClient); const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!); expect(body.events.length).toBe(0); @@ -123,7 +68,7 @@ describe('alert_status_route.ts', () => { it('returns results for a particular alert uuid', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); - const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const alertsClient = getAlertsClientMockInstance(esClient); const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!); expect(body.events.length).toBe(1); diff --git a/x-pack/plugins/session_view/server/routes/alerts_client_mock.test.ts b/x-pack/plugins/session_view/server/routes/alerts_client_mock.test.ts new file mode 100644 index 0000000000000..27dded458906c --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_client_mock.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { + AlertsClient, + ConstructorOptions, +} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server'; +import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import { + ALERT_RULE_CONSUMER, + ALERT_RULE_TYPE_ID, + SPACE_IDS, + ALERT_WORKFLOW_STATUS, +} from '@kbn/rule-data-utils'; +import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock'; + +export const alertingAuthMock = alertingAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const DEFAULT_SPACE = 'test_default_space_id'; + +const getResponse = async () => { + return { + hits: { + total: mockAlerts.length, + hits: mockAlerts.map((event) => { + return { + found: true, + _type: 'alert', + _index: '.alerts-security', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + ...event, + }, + }; + }), + }, + }; +}; + +const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + auditLogger, + ruleDataService: ruleDataServiceMock.create(), + esClient: esClientMock, +}; + +export function getAlertsClientMockInstance(esClient?: ElasticsearchClient) { + esClient = esClient || elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + + return alertsClient; +} + +export function resetAlertingAuthMock() { + alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); + // @ts-expect-error + alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => + Promise.resolve({ filter: [] }) + ); + // @ts-expect-error + alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { + const authorizedRuleTypes = new Set(); + authorizedRuleTypes.add({ producer: 'apm' }); + return Promise.resolve({ authorizedRuleTypes }); + }); + + alertingAuthMock.ensureAuthorized.mockImplementation( + // @ts-expect-error + async ({ + ruleTypeId, + consumer, + operation, + entity, + }: { + ruleTypeId: string; + consumer: string; + operation: string; + entity: typeof AlertingAuthorizationEntity.Alert; + }) => { + if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { + return Promise.resolve(); + } + return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); + } + ); +} + +// this is only here because the above imports complain if they aren't declared as part of a test file. +describe('alerts_route_mock.test.ts', () => { + it('does nothing', () => undefined); +}); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts index d3b50b366362e..12300b8946a78 100644 --- a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts +++ b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts @@ -4,30 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - ALERT_RULE_CONSUMER, - ALERT_RULE_TYPE_ID, - SPACE_IDS, - ALERT_WORKFLOW_STATUS, -} from '@kbn/rule-data-utils'; import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import { doSearch } from './alerts_route'; -import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; - -import { - AlertsClient, - ConstructorOptions, -} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock'; -import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server'; -import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock'; - -const alertingAuthMock = alertingAuthorizationMock.create(); -const auditLogger = auditLoggerMock.create(); - -const DEFAULT_SPACE = 'test_default_space_id'; +import { searchAlerts } from './alerts_route'; +import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock'; +import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test'; const getEmptyResponse = async () => { return { @@ -38,96 +18,46 @@ const getEmptyResponse = async () => { }; }; -const getResponse = async () => { - return { - hits: { - total: mockEvents.length, - hits: mockEvents.map((event) => { - return { - found: true, - _type: 'alert', - _index: '.alerts-security', - _id: 'NoxgpHkBqbdrfX07MqXV', - _version: 1, - _seq_no: 362, - _primary_term: 2, - _source: { - [ALERT_RULE_TYPE_ID]: 'apm.error_rate', - message: 'hello world 1', - [ALERT_RULE_CONSUMER]: 'apm', - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['test_default_space_id'], - ...event, - }, - }; - }), - }, - }; -}; - -const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); - -const alertsClientParams: jest.Mocked = { - logger: loggingSystemMock.create().get(), - authorization: alertingAuthMock, - auditLogger, - ruleDataService: ruleDataServiceMock.create(), - esClient: esClientMock, -}; - describe('alerts_route.ts', () => { beforeEach(() => { jest.resetAllMocks(); - alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); - // @ts-expect-error - alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => - Promise.resolve({ filter: [] }) - ); - // @ts-expect-error - alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { - const authorizedRuleTypes = new Set(); - authorizedRuleTypes.add({ producer: 'apm' }); - return Promise.resolve({ authorizedRuleTypes }); - }); - - alertingAuthMock.ensureAuthorized.mockImplementation( - // @ts-expect-error - async ({ - ruleTypeId, - consumer, - operation, - entity, - }: { - ruleTypeId: string; - consumer: string; - operation: string; - entity: typeof AlertingAuthorizationEntity.Alert; - }) => { - if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { - return Promise.resolve(); - } - return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); - } - ); + resetAlertingAuthMock(); }); - describe('doSearch(client, sessionEntityId)', () => { + describe('searchAlerts(client, sessionEntityId)', () => { it('should return an empty events array for a non existant entity_id', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); - const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); - const body = await doSearch(alertsClient, 'asdf'); + const alertsClient = getAlertsClientMockInstance(esClient); + const body = await searchAlerts(alertsClient, 'asdf', 100); expect(body.events.length).toBe(0); }); it('returns results for a particular session entity_id', async () => { - const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); - const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const alertsClient = getAlertsClientMockInstance(); + + const body = await searchAlerts(alertsClient, 'asdf', 100); + + expect(body.events.length).toBe(mockAlerts.length); + }); + + it('takes an investigatedAlertId', async () => { + const alertsClient = getAlertsClientMockInstance(); + + const body = await searchAlerts(alertsClient, 'asdf', 100, mockAlerts[0].kibana?.alert?.uuid); + + expect(body.events.length).toBe(mockAlerts.length + 1); + }); + + it('takes a range', async () => { + const alertsClient = getAlertsClientMockInstance(); - const body = await doSearch(alertsClient, 'asdf'); + const start = '2021-11-23T15:25:04.210Z'; + const end = '2021-20-23T15:25:04.210Z'; + const body = await searchAlerts(alertsClient, 'asdf', 100, undefined, [start, end]); - expect(body.events.length).toBe(mockEvents.length); + expect(body.events.length).toBe(mockAlerts.length); }); }); }); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.ts b/x-pack/plugins/session_view/server/routes/alerts_route.ts index f2b1c3b7c809d..a51d9a55f4399 100644 --- a/x-pack/plugins/session_view/server/routes/alerts_route.ts +++ b/x-pack/plugins/session_view/server/routes/alerts_route.ts @@ -14,8 +14,11 @@ import { ALERTS_ROUTE, ALERTS_PER_PAGE, ENTRY_SESSION_ENTITY_ID_PROPERTY, + ALERT_UUID_PROPERTY, + ALERT_ORIGINAL_TIME_PROPERTY, PREVIEW_ALERTS_INDEX, } from '../../common/constants'; + import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerAlertsRoute = ( @@ -28,20 +31,37 @@ export const registerAlertsRoute = ( validate: { query: schema.object({ sessionEntityId: schema.string(), + investigatedAlertId: schema.maybe(schema.string()), + cursor: schema.maybe(schema.string()), + range: schema.maybe(schema.arrayOf(schema.string())), }), }, }, async (_context, request, response) => { const client = await ruleRegistry.getRacClientWithRequest(request); - const { sessionEntityId } = request.query; - const body = await doSearch(client, sessionEntityId); + const { sessionEntityId, investigatedAlertId, range, cursor } = request.query; + const body = await searchAlerts( + client, + sessionEntityId, + ALERTS_PER_PAGE, + investigatedAlertId, + range, + cursor + ); return response.ok({ body }); } ); }; -export const doSearch = async (client: AlertsClient, sessionEntityId: string) => { +export const searchAlerts = async ( + client: AlertsClient, + sessionEntityId: string, + size: number, + investigatedAlertId?: string, + range?: string[], + cursor?: string +) => { const indices = (await client.getAuthorizedAlertsIndices(['siem']))?.filter( (index) => index !== PREVIEW_ALERTS_INDEX ); @@ -52,15 +72,49 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) => const results = await client.find({ query: { - match: { - [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + bool: { + must: [ + { + term: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + range && { + range: { + [ALERT_ORIGINAL_TIME_PROPERTY]: { + gte: range[0], + lte: range[1], + }, + }, + }, + ].filter((item) => !!item), }, }, - track_total_hits: false, - size: ALERTS_PER_PAGE, + track_total_hits: true, + size, index: indices.join(','), + sort: [{ '@timestamp': 'asc' }], + search_after: cursor ? [cursor] : undefined, }); + // if an alert is being investigated, fetch it on it's own, as it's not guaranteed to come back in the above request. + // we only need to do this for the first page of alerts. + if (!cursor && investigatedAlertId) { + const investigatedAlertSearch = await client.find({ + query: { + match: { + [ALERT_UUID_PROPERTY]: investigatedAlertId, + }, + }, + size: 1, + index: indices.join(','), + }); + + if (investigatedAlertSearch.hits.hits.length > 0) { + results.hits.hits.unshift(investigatedAlertSearch.hits.hits[0]); + } + } + const events = results.hits.hits.map((hit: any) => { // the alert indexes flattens many properties. this util unflattens them as session view expects structured json. hit._source = expandDottedObject(hit._source); @@ -68,5 +122,8 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) => return hit; }); - return { events }; + const total = + typeof results.hits.total === 'number' ? results.hits.total : results.hits.total?.value; + + return { total, events }; }; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index b981b31f33978..2955ccdd39327 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -9,11 +9,9 @@ import { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/serve import { registerProcessEventsRoute } from './process_events_route'; import { registerAlertsRoute } from './alerts_route'; import { registerAlertStatusRoute } from './alert_status_route'; -import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => { - registerProcessEventsRoute(router); - sessionEntryLeadersRoute(router); + registerProcessEventsRoute(router, ruleRegistry); registerAlertsRoute(router, ruleRegistry); registerAlertStatusRoute(router, ruleRegistry); }; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.test.ts b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts index fdf706c0ede25..b4fb197234240 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.test.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts @@ -5,8 +5,10 @@ * 2.0. */ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import { doSearch } from './process_events_route'; -import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; +import { fetchEventsAndScopedAlerts } from './process_events_route'; +import { mockEvents, mockAlerts } from '../../common/mocks/constants/session_view_process.mock'; +import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test'; +import { EventKind, ProcessEvent } from '../../common/types/process_tree'; const getEmptyResponse = async () => { return { @@ -29,11 +31,18 @@ const getResponse = async () => { }; describe('process_events_route.ts', () => { - describe('doSearch(client, entityId, cursor, forward)', () => { + beforeEach(() => { + jest.resetAllMocks(); + + resetAlertingAuthMock(); + }); + + describe('fetchEventsAndScopedAlerts(client, entityId, cursor, forward)', () => { it('should return an empty events array for a non existant entity_id', async () => { const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + const alertsClient = getAlertsClientMockInstance(client); - const body = await doSearch(client, 'asdf', undefined); + const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'asdf', undefined); expect(body.events.length).toBe(0); expect(body.total).toBe(0); @@ -41,17 +50,34 @@ describe('process_events_route.ts', () => { it('returns results for a particular session entity_id', async () => { const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = getAlertsClientMockInstance(); + + const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'mockId', undefined); - const body = await doSearch(client, 'mockId', undefined); + expect(body.events.length).toBe(mockEvents.length + mockAlerts.length); - expect(body.events.length).toBe(mockEvents.length); - expect(body.total).toBe(body.events.length); + const eventsOnly = body.events.filter( + (event) => (event._source as ProcessEvent)?.event?.kind === EventKind.event + ); + const alertsOnly = body.events.filter( + (event) => (event._source as ProcessEvent)?.event?.kind === EventKind.signal + ); + expect(eventsOnly.length).toBe(mockEvents.length); + expect(alertsOnly.length).toBe(mockAlerts.length); + expect(body.total).toBe(mockEvents.length); }); it('returns hits in reverse order when paginating backwards', async () => { const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = getAlertsClientMockInstance(); - const body = await doSearch(client, 'mockId', undefined, false); + const body = await fetchEventsAndScopedAlerts( + client, + alertsClient, + 'mockId', + undefined, + false + ); expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]); }); diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 7341f8238f9af..d101fe3728b79 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -5,16 +5,27 @@ * 2.0. */ import { schema } from '@kbn/config-schema'; +import _ from 'lodash'; import type { ElasticsearchClient } from '@kbn/core/server'; import { IRouter } from '@kbn/core/server'; +import type { + AlertsClient, + RuleRegistryPluginStartContract, +} from '@kbn/rule-registry-plugin/server'; import { + ALERTS_PER_PROCESS_EVENTS_PAGE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, PROCESS_EVENTS_INDEX, ENTRY_SESSION_ENTITY_ID_PROPERTY, } from '../../common/constants'; +import { ProcessEvent } from '../../common/types/process_tree'; +import { searchAlerts } from './alerts_route'; -export const registerProcessEventsRoute = (router: IRouter) => { +export const registerProcessEventsRoute = ( + router: IRouter, + ruleRegistry: RuleRegistryPluginStartContract +) => { router.get( { path: PROCESS_EVENTS_ROUTE, @@ -28,20 +39,30 @@ export const registerProcessEventsRoute = (router: IRouter) => { }, async (context, request, response) => { const client = (await context.core).elasticsearch.client.asCurrentUser; + const alertsClient = await ruleRegistry.getRacClientWithRequest(request); const { sessionEntityId, cursor, forward = true } = request.query; - const body = await doSearch(client, sessionEntityId, cursor, forward); + const body = await fetchEventsAndScopedAlerts( + client, + alertsClient, + sessionEntityId, + cursor, + forward + ); return response.ok({ body }); } ); }; -export const doSearch = async ( +export const fetchEventsAndScopedAlerts = async ( client: ElasticsearchClient, + alertsClient: AlertsClient, sessionEntityId: string, cursor: string | undefined, forward = true ) => { + const cursorMillis = cursor && new Date(cursor).getTime() + (forward ? -1 : 1); + const search = await client.search({ index: [PROCESS_EVENTS_INDEX], body: { @@ -52,11 +73,11 @@ export const doSearch = async ( }, size: PROCESS_EVENTS_PER_PAGE, sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], - search_after: cursor ? [cursor] : undefined, + search_after: cursorMillis ? [cursorMillis] : undefined, }, }); - const events = search.hits.hits; + let events = search.hits.hits; if (!forward) { events.reverse(); @@ -65,6 +86,28 @@ export const doSearch = async ( const total = typeof search.hits.total === 'number' ? search.hits.total : search.hits.total?.value; + if (events.length > 0) { + // go grab any alerts which happened in this page of events. + const firstEvent = _.first(events)?._source as ProcessEvent; + const lastEvent = _.last(events)?._source as ProcessEvent; + + let range; + + if (firstEvent?.['@timestamp'] && lastEvent?.['@timestamp']) { + range = [firstEvent['@timestamp'], lastEvent['@timestamp']]; + } + + const alertsBody = await searchAlerts( + alertsClient, + sessionEntityId, + ALERTS_PER_PROCESS_EVENTS_PAGE, + undefined, + range + ); + + events = [...events, ...alertsBody.events]; + } + return { total, events, diff --git a/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts deleted file mode 100644 index c8fd28334f18e..0000000000000 --- a/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts +++ /dev/null @@ -1,37 +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 } from '@kbn/config-schema'; -import { IRouter } from '@kbn/core/server'; -import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants'; - -export const sessionEntryLeadersRoute = (router: IRouter) => { - router.get( - { - path: SESSION_ENTRY_LEADERS_ROUTE, - validate: { - query: schema.object({ - id: schema.string(), - }), - }, - }, - async (context, request, response) => { - const client = (await context.core).elasticsearch.client.asCurrentUser; - const { id } = request.query; - - const result = await client.get({ - index: PROCESS_EVENTS_INDEX, - id, - }); - - return response.ok({ - body: { - session_entry_leader: result?._source, - }, - }); - } - ); -}; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.test.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.test.ts index df1ceabaaadab..060a9bee15efb 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.test.ts @@ -18,6 +18,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { MONITOR_UPDATE_CHANNEL } from './constants'; import { TelemetryEventsSender } from './sender'; +import { LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; jest.mock('axios', () => { return { @@ -25,6 +26,23 @@ jest.mock('axios', () => { }; }); +const licenseMock: LicenseGetResponse = { + license: { + status: 'active', + uid: '1d34eb9f-e66f-47d1-8d24-cd60d187587a', + type: 'trial', + issue_date: '2022-05-05T14:25:00.732Z', + issue_date_in_millis: 165176070074432, + expiry_date: '2022-06-04T14:25:00.732Z', + expiry_date_in_millis: 165435270073332, + max_nodes: 1000, + max_resource_units: null, + issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', + issuer: 'elasticsearch', + start_date_in_millis: -1, + }, +}; + describe('TelemetryEventsSender', () => { let logger: ReturnType; let sender: TelemetryEventsSender; @@ -42,6 +60,10 @@ describe('TelemetryEventsSender', () => { beforeEach(() => { logger = loggingSystemMock.createLogger(); sender = new TelemetryEventsSender(logger); + sender['fetchLicenseInfo'] = jest.fn(async () => { + return licenseMock as LicenseGetResponse; + }); + sender['fetchClusterInfo'] = jest.fn(async () => { return { cluster_uuid: '1', @@ -79,7 +101,6 @@ describe('TelemetryEventsSender', () => { expect(sender['sendEvents']).toHaveBeenCalledWith( `https://telemetry-staging.elastic.co/v3-dev/send/${MONITOR_UPDATE_CHANNEL}`, - { cluster_name: 'name', cluster_uuid: '1', version: { number: '8.0.0' } }, expect.anything() ); }); @@ -134,14 +155,17 @@ describe('TelemetryEventsSender', () => { 'X-Elastic-Stack-Version': '8.0.0', }, }; + const event1 = { 'event.kind': '1', ...licenseMock }; + const event2 = { 'event.kind': '2', ...licenseMock }; + const event3 = { 'event.kind': '3', ...licenseMock }; expect(axios.post).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel', - '{"event.kind":"1"}\n{"event.kind":"2"}\n', + `${JSON.stringify(event1)}\n${JSON.stringify(event2)}\n`, headers ); expect(axios.post).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel2', - '{"event.kind":"3"}\n', + `${JSON.stringify(event3)}\n`, headers ); }); diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.ts index 5130ea034f94d..4c6b9a33e4eff 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/telemetry/sender.ts @@ -12,7 +12,7 @@ import { cloneDeep } from 'lodash'; import axios from 'axios'; -import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { InfoResponse, LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; import { TelemetryQueue } from './queue'; @@ -35,6 +35,7 @@ export class TelemetryEventsSender { private isOptedIn?: boolean = true; // Assume true until the first check private esClient?: ElasticsearchClient; private clusterInfo?: InfoResponse; + private licenseInfo?: LicenseGetResponse; constructor(logger: Logger) { this.logger = logger; @@ -48,6 +49,7 @@ export class TelemetryEventsSender { this.telemetryStart = telemetryStart; this.esClient = core?.elasticsearch.client.asInternalUser; this.clusterInfo = await this.fetchClusterInfo(); + this.licenseInfo = await this.fetchLicenseInfo(); this.logger.debug(`Starting local task`); setTimeout(() => { @@ -95,11 +97,7 @@ export class TelemetryEventsSender { } for (const channel of Object.keys(this.queuesPerChannel)) { - await this.sendEvents( - await this.fetchTelemetryUrl(channel), - this.clusterInfo, - this.queuesPerChannel[channel] - ); + await this.sendEvents(await this.fetchTelemetryUrl(channel), this.queuesPerChannel[channel]); } this.isSending = false; @@ -107,22 +105,28 @@ export class TelemetryEventsSender { private async fetchClusterInfo(): Promise { if (this.esClient === undefined || this.esClient === null) { - throw Error('elasticsearch client is unavailable: cannot retrieve cluster infomation'); + throw Error('elasticsearch client is unavailable: cannot retrieve cluster information'); } return await this.esClient.info(); } - public async sendEvents( - telemetryUrl: string, - clusterInfo: InfoResponse | undefined, - queue: TelemetryQueue - ) { - const events = queue.getEvents(); + private async fetchLicenseInfo() { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve license information'); + } + + return await this.esClient.license.get(); + } + + public async sendEvents(telemetryUrl: string, queue: TelemetryQueue) { + let events = queue.getEvents(); if (events.length === 0) { return; } + events = events.map((event) => ({ ...event, license: this.licenseInfo?.license })); + try { this.logger.debug(`Telemetry URL: ${telemetryUrl}`); @@ -130,13 +134,7 @@ export class TelemetryEventsSender { this.logger.debug(JSON.stringify(events)); - await this.send( - events, - telemetryUrl, - clusterInfo?.cluster_uuid, - clusterInfo?.version?.number, - clusterInfo?.cluster_name - ); + await this.send(events, telemetryUrl); } catch (err) { this.logger.debug(`Error sending telemetry events data: ${err}`); queue.clearEvents(); @@ -159,13 +157,13 @@ export class TelemetryEventsSender { return telemetryUrl.toString(); } - private async send( - events: unknown[], - telemetryUrl: string, - clusterUuid: string | undefined, - clusterVersionNumber: string | undefined, - clusterName: string | undefined - ) { + private async send(events: unknown[], telemetryUrl: string) { + const { + cluster_name: clusterName, + cluster_uuid: clusterUuid, + version: clusterVersion, + } = this.clusterInfo ?? {}; + // using ndjson so that each line will be wrapped in json envelope on server side // see https://github.com/elastic/infra/blob/master/docs/telemetry/telemetry-next-dataflow.md#json-envelope const ndjson = this.transformDataToNdjson(events); @@ -176,7 +174,7 @@ export class TelemetryEventsSender { 'Content-Type': 'application/x-ndjson', ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), ...(clusterName ? { 'X-Elastic-Cluster-Name': clusterName } : undefined), - 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '8.2.0', + 'X-Elastic-Stack-Version': clusterVersion?.number ? clusterVersion.number : '8.2.0', }, }); this.logger.debug(`Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}`); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 1e6e51d8084c2..4eb01dedb3a46 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3454,7 +3454,6 @@ "expressionXY.dataLayer.xAccessor.help": "Axe X", "expressionXY.dataLayer.xScaleType.help": "Type dโ€™รฉchelle de lโ€™axeย x", "expressionXY.dataLayer.yConfig.help": "Configuration supplรฉmentaire pour les axesย y", - "expressionXY.dataLayer.yScaleType.help": "Type dโ€™รฉchelle des axesย y", "expressionXY.gridlinesConfig.help": "Configurer lโ€™aspect du quadrillage du graphiqueย xy", "expressionXY.gridlinesConfig.x.help": "Spรฉcifie si le quadrillage de l'axeย X est visible ou non.", "expressionXY.gridlinesConfig.yLeft.help": "Spรฉcifie si le quadrillage de l'axeย Y de gauche est visible ou non.", @@ -5370,12 +5369,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intรฉgration n'est pas encore activรฉe. Votre administrateur possรจde les autorisations requises pour lโ€™activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elasticย Agent", - "sharedUXComponents.noDataViews.dataViewExplanation": "Kibana a besoin d'une vue de donnรฉes pour identifier les flux de donnรฉes, les index et les alias d'index que vous souhaitez explorer. Une vue de donnรฉes peut pointer vers un index spรฉcifique, par exemple, vos donnรฉes de log de la veille ou tous les index contenant vos donnรฉes de log.", "sharedUXComponents.noDataViews.learnMore": "Envie d'en savoir plusย ?", - "sharedUXComponents.noDataViews.nowCreate": "Crรฉez ร  prรฉsent une vue de donnรฉes.", "sharedUXComponents.noDataViews.readDocumentation": "Lisez les documents", - "sharedUXComponents.noDataViews.youHaveData": "Vous avez des donnรฉes dans Elasticsearch.", - "sharedUXComponents.noDataViewsPage.addDataViewText": "Crรฉer une vue de donnรฉes", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de donnรฉes", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothรจque", "telemetry.callout.appliesSettingTitle": "Les modifications apportรฉes ร  ce paramรจtre s'appliquent dans {allOfKibanaText} et sont enregistrรฉes automatiquement.", @@ -12656,7 +12651,7 @@ "xpack.fleet.epm.assetTitles.maps": "Cartes", "xpack.fleet.epm.assetTitles.mlModels": "Modรจlesย ML", "xpack.fleet.epm.assetTitles.mlModules": "Modulesย ML", - "xpack.fleet.epm.assetTitles.osqueryPackAsset": "Packs Osquery", + "xpack.fleet.epm.assetTitles.osqueryPackAssets": "Packs Osquery", "xpack.fleet.epm.assetTitles.savedSearches": "Recherches enregistrรฉes", "xpack.fleet.epm.assetTitles.securityRules": "Rรจgles de sรฉcuritรฉ", "xpack.fleet.epm.assetTitles.tag": "Balise", @@ -16767,7 +16762,6 @@ "xpack.maps.layerPanel.join.deleteJoinAriaLabel": "Supprimer la liaison", "xpack.maps.layerPanel.join.deleteJoinTitle": "Supprimer la liaison", "xpack.maps.layerPanel.joinEditor.termJoinsTitle": "Liaisons de terme", - "xpack.maps.layerPanel.joinEditor.termJoinTooltip": "Utilisez les liaisons de terme pour ajouter ร  ce calque les propriรฉtรฉs de style basรฉes sur les donnรฉes.", "xpack.maps.layerPanel.joinExpression.helpText": "Configurez la clรฉ partagรฉe.", "xpack.maps.layerPanel.joinExpression.joinPopoverTitle": "Liaison", "xpack.maps.layerPanel.joinExpression.leftFieldLabel": "Champ gauche", @@ -25832,7 +25826,6 @@ "xpack.securitySolution.hosts.navigation.authenticationsTitle": "Authentifications", "xpack.securitySolution.hosts.navigation.dns.histogram.errorFetchingDnsData": "Impossible d'interroger les donnรฉes DNS", "xpack.securitySolution.hosts.navigation.eventsTitle": "ร‰vรฉnements", - "xpack.securitySolution.hosts.navigation.hostRisk": "Hรดtes par risque", "xpack.securitySolution.hosts.navigation.sessionsTitle": "Sessions", "xpack.securitySolution.hosts.navigation.uncommonProcessesTitle": "Processus inhabituels", "xpack.securitySolution.hosts.navigaton.eventsUnit": "{totalCount, plural, =1 {รฉvรฉnement} other {รฉvรฉnements}}", @@ -25854,7 +25847,6 @@ "xpack.securitySolution.hostsRiskTable.hostRiskScoreTitle": "Score de risque de l'hรดte", "xpack.securitySolution.hostsRiskTable.hostRiskToolTip": "La classification des risques de l'hรดte est dรฉterminรฉe par score de risque de l'hรดte. Les hรดtes classรฉs comme รฉtant Critique ou ร‰levรฉ sont indiquรฉs comme รฉtant \"ร  risque\".", "xpack.securitySolution.hostsRiskTable.hostsTableTitle": "Le tableau des risques de l'hรดte n'est pas affectรฉ par la plage temporelle KQL. Ce tableau montre le dernier score de risque enregistrรฉ pour chaque hรดte.", - "xpack.securitySolution.hostsRiskTable.hostsTitle": "Hรดtes par risque", "xpack.securitySolution.hostsRiskTable.riskTitle": "Classification de risque de l'hรดte", "xpack.securitySolution.hostsRiskTable.tableTitle": "Risque de l'hรดte", "xpack.securitySolution.hostsRiskTable.usersTableTitle": "Le tableau des risques de l'utilisateur n'est pas affectรฉ par la plage temporelle KQL. Ce tableau montre le dernier score de risque enregistrรฉ pour chaque utilisateur.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d8d83466fdb09..9f8fb342189f0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2901,9 +2901,6 @@ "discover.docExplorerCallout.learnMore": "่ฉณ็ดฐ", "discover.docExplorerCallout.tryDocumentExplorer": "ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚จใ‚ฏใ‚นใƒ—ใƒญใƒผใƒฉใƒผใ‚’่ฉฆใ™", "discover.docExplorerUpdateCallout.closeButtonAriaLabel": "้–‰ใ˜ใ‚‹", - "discover.docExplorerUpdateCallout.documentExplorer": "ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚จใ‚ฏใ‚นใƒ—ใƒญใƒผใƒฉใƒผ", - "discover.docExplorerUpdateCallout.fieldStatistics": "ใƒ•ใ‚ฃใƒผใƒซใƒ‰็ตฑ่จˆๆƒ…ๅ ฑ", - "discover.docExplorerUpdateCallout.headerMessage": "ใ‚ˆใ‚ŠๅŠน็Ž‡็š„ใชๆŽข็ดขๆ–นๆณ•", "discover.docTable.documentsNavigation": "ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใƒŠใƒ“ใ‚ฒใƒผใ‚ทใƒงใƒณ", "discover.docTable.limitedSearchResultLabel": "{resultCount}ไปถใฎ็ตๆžœใฎใฟใŒ่กจ็คบใ•ใ‚Œใพใ™ใ€‚ๆคœ็ดข็ตๆžœใ‚’็ตžใ‚Š่พผใฟใพใ™ใ€‚", "discover.docTable.noResultsTitle": "็ตๆžœใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใงใ—ใŸ", @@ -3551,7 +3548,6 @@ "expressionXY.dataLayer.xAccessor.help": "X ่ปธ", "expressionXY.dataLayer.xScaleType.help": "x่ปธใฎ็›ฎ็››ใ‚ฟใ‚คใƒ—", "expressionXY.dataLayer.yConfig.help": "y่ปธใฎ่ฉณ็ดฐๆง‹ๆˆ", - "expressionXY.dataLayer.yScaleType.help": "y่ปธใฎ็›ฎ็››ใ‚ฟใ‚คใƒ—", "expressionXY.gridlinesConfig.help": "xyใ‚ฐใƒฉใƒ•ใฎใ‚ฐใƒชใƒƒใƒ‰็ทš่กจ็คบใ‚’ๆง‹ๆˆ", "expressionXY.gridlinesConfig.x.help": "x ่ปธใฎใ‚ฐใƒชใƒƒใƒ‰็ทšใ‚’่กจ็คบใ™ใ‚‹ใ‹ใฉใ†ใ‹ใ‚’ๆŒ‡ๅฎšใ—ใพใ™ใ€‚", "expressionXY.gridlinesConfig.yLeft.help": "ๅทฆy่ปธใฎใ‚ฐใƒชใƒƒใƒ‰็ทšใ‚’่กจ็คบใ™ใ‚‹ใ‹ใฉใ†ใ‹ใ‚’ๆŒ‡ๅฎšใ—ใพใ™ใ€‚", @@ -5475,12 +5471,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "ใ“ใฎ็ตฑๅˆใฏใพใ ๆœ‰ๅŠนใงใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚็ฎก็†่€…ใซใฏใ‚ชใƒณใซใ™ใ‚‹ใŸใ‚ใซๅฟ…่ฆใชใ‚ขใ‚ฏใ‚ปใ‚นๆจฉใŒใ‚ใ‚Šใพใ™ใ€‚", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "็ฎก็†่€…ใซใŠๅ•ใ„ๅˆใ‚ใ›ใใ ใ•ใ„", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใฎ่ฟฝๅŠ ", - "sharedUXComponents.noDataViews.dataViewExplanation": "Kibanaใงใฏใ€ๆŽข็ดขใ™ใ‚‹ใƒ‡ใƒผใ‚ฟใ‚นใƒˆใƒชใƒผใƒ ใ€ใ‚คใƒณใƒ‡ใƒƒใ‚ฏใ‚นใ€ใ‚คใƒณใƒ‡ใƒƒใ‚ฏใ‚นใ‚จใ‚คใƒชใ‚ขใ‚นใ‚’็‰นๅฎšใ™ใ‚‹ใŸใ‚ใซใƒ‡ใƒผใ‚ฟใƒ“ใƒฅใƒผใŒๅฟ…่ฆใงใ™ใ€‚ใƒ‡ใƒผใ‚ฟใƒ“ใƒฅใƒผใฏใ€ๆ˜จๆ—ฅใฎใƒญใ‚ฐใƒ‡ใƒผใ‚ฟใชใฉ็‰นๅฎšใฎใ‚คใƒณใƒ‡ใƒƒใ‚ฏใ‚นใ€ใพใŸใฏใƒญใ‚ฐใƒ‡ใƒผใ‚ฟใ‚’ๅซใ‚€ใ™ในใฆใฎใ‚คใƒณใƒ‡ใƒƒใ‚ฏใ‚นใ‚’ๅ‚็…งใงใใพใ™ใ€‚", "sharedUXComponents.noDataViews.learnMore": "่ฉณ็ดฐใซใคใ„ใฆ", - "sharedUXComponents.noDataViews.nowCreate": "ใ“ใ“ใงใƒ‡ใƒผใ‚ฟใƒ“ใƒฅใƒผใ‚’ไฝœๆˆใ—ใพใ™ใ€‚", "sharedUXComponents.noDataViews.readDocumentation": "ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚’่ชญใ‚€", - "sharedUXComponents.noDataViews.youHaveData": "Elasticsearchใซใƒ‡ใƒผใ‚ฟใŒใ‚ใ‚Šใพใ™ใ€‚", - "sharedUXComponents.noDataViewsPage.addDataViewText": "ใƒ‡ใƒผใ‚ฟใƒ“ใƒฅใƒผใ‚’ไฝœๆˆ", "sharedUXComponents.pageTemplate.noDataCard.description": "ใƒ‡ใƒผใ‚ฟใ‚’ๅŽ้›†ใ›ใšใซ็ถš่กŒ", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ใƒฉใ‚คใƒ–ใƒฉใƒชใ‹ใ‚‰่ฟฝๅŠ ", "telemetry.callout.appliesSettingTitle": "ใ“ใฎ่จญๅฎšใซๅŠ ใˆใŸๅค‰ๆ›ดใฏ {allOfKibanaText} ใซ้ฉ็”จใ•ใ‚Œใ€่‡ชๅ‹•็š„ใซไฟๅญ˜ใ•ใ‚Œใพใ™ใ€‚", @@ -12775,7 +12767,7 @@ "xpack.fleet.epm.assetTitles.maps": "ใƒžใƒƒใƒ—", "xpack.fleet.epm.assetTitles.mlModels": "MLใƒขใƒ‡ใƒซ", "xpack.fleet.epm.assetTitles.mlModules": "ๆฉŸๆขฐๅญฆ็ฟ’ใƒขใ‚ธใƒฅใƒผใƒซ", - "xpack.fleet.epm.assetTitles.osqueryPackAsset": "Osqueryใƒ‘ใƒƒใ‚ฏ", + "xpack.fleet.epm.assetTitles.osqueryPackAssets": "Osqueryใƒ‘ใƒƒใ‚ฏ", "xpack.fleet.epm.assetTitles.savedSearches": "ไฟๅญ˜ใ•ใ‚ŒใŸๆคœ็ดข", "xpack.fleet.epm.assetTitles.securityRules": "ใ‚ปใ‚ญใƒฅใƒชใƒ†ใ‚ฃใƒซใƒผใƒซ", "xpack.fleet.epm.assetTitles.tag": "ใ‚ฟใ‚ฐ", @@ -16901,7 +16893,6 @@ "xpack.maps.layerPanel.join.deleteJoinAriaLabel": "ใ‚ธใƒงใƒ–ใฎๅ‰Š้™ค", "xpack.maps.layerPanel.join.deleteJoinTitle": "ใ‚ธใƒงใƒ–ใฎๅ‰Š้™ค", "xpack.maps.layerPanel.joinEditor.termJoinsTitle": "็”จ่ชž็ตๅˆ", - "xpack.maps.layerPanel.joinEditor.termJoinTooltip": "็”จ่ชž็ตๅˆใ‚’ไฝฟ็”จใ™ใ‚‹ใจใ€ใƒ‡ใƒผใ‚ฟใซๅŸบใฅใใ‚นใ‚ฟใ‚คใƒซ่จญๅฎšใฎใƒ—ใƒญใƒ‘ใƒ†ใ‚ฃใงใ“ใฎใƒฌใ‚คใƒคใƒผใ‚’ๅผทๅŒ–ใ—ใพใ™ใ€‚", "xpack.maps.layerPanel.joinExpression.helpText": "ๅ…ฑๆœ‰ใ‚ญใƒผใ‚’ๆง‹ๆˆใ—ใพใ™ใ€‚", "xpack.maps.layerPanel.joinExpression.joinPopoverTitle": "็ตๅˆ", "xpack.maps.layerPanel.joinExpression.leftFieldLabel": "ๅทฆใฎใƒ•ใ‚ฃใƒผใƒซใƒ‰", @@ -25995,7 +25986,6 @@ "xpack.securitySolution.hosts.navigation.authenticationsTitle": "่ช่จผ", "xpack.securitySolution.hosts.navigation.dns.histogram.errorFetchingDnsData": "DNSใƒ‡ใƒผใ‚ฟใ‚’ใ‚ฏใ‚จใƒชใงใใพใ›ใ‚“ใงใ—ใŸ", "xpack.securitySolution.hosts.navigation.eventsTitle": "ใ‚คใƒ™ใƒณใƒˆ", - "xpack.securitySolution.hosts.navigation.hostRisk": "ใƒชใ‚นใ‚ฏๅˆฅใƒ›ใ‚นใƒˆ", "xpack.securitySolution.hosts.navigation.sessionsTitle": "ใ‚ปใƒƒใ‚ทใƒงใƒณ", "xpack.securitySolution.hosts.navigation.uncommonProcessesTitle": "้žๅ…ฑ้€šใƒ—ใƒญใ‚ปใ‚น", "xpack.securitySolution.hosts.navigaton.eventsUnit": "{totalCount, plural, other {ใ‚คใƒ™ใƒณใƒˆ}}", @@ -26017,7 +26007,6 @@ "xpack.securitySolution.hostsRiskTable.hostRiskScoreTitle": "ใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏใ‚นใ‚ณใ‚ข", "xpack.securitySolution.hostsRiskTable.hostRiskToolTip": "ใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏๅˆ†้กžใฏใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏใ‚นใ‚ณใ‚ขใงๆฑบใพใ‚Šใพใ™ใ€‚ใ€Œ้‡ๅคงใ€ใพใŸใฏใ€Œ้ซ˜ใ€ใซๅˆ†้กžใ•ใ‚ŒใŸใƒ›ใ‚นใƒˆใฏใƒชใ‚นใ‚ฏใŒ้ซ˜ใ„ใ“ใจใŒ็คบใ•ใ‚Œใพใ™ใ€‚", "xpack.securitySolution.hostsRiskTable.hostsTableTitle": "ใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏ่กจใฏKQLๆ™‚้–“็ฏ„ๅ›ฒใฎๅฝฑ้Ÿฟใ‚’ๅ—ใ‘ใพใ›ใ‚“ใ€‚ใ“ใฎ่กจใฏใ€ๅ„ใƒ›ใ‚นใƒˆใฎๆœ€ๅพŒใซ่จ˜้Œฒใ•ใ‚ŒใŸใƒชใ‚นใ‚ฏใ‚นใ‚ณใ‚ขใ‚’็คบใ—ใพใ™ใ€‚", - "xpack.securitySolution.hostsRiskTable.hostsTitle": "ใƒชใ‚นใ‚ฏๅˆฅใƒ›ใ‚นใƒˆ", "xpack.securitySolution.hostsRiskTable.riskTitle": "ใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏๅˆ†้กž", "xpack.securitySolution.hostsRiskTable.tableTitle": "ใƒ›ใ‚นใƒˆใƒชใ‚นใ‚ฏ", "xpack.securitySolution.hostsRiskTable.usersTableTitle": "ใƒฆใƒผใ‚ถใƒผใƒชใ‚นใ‚ฏ่กจใฏKQLๆ™‚้–“็ฏ„ๅ›ฒใฎๅฝฑ้Ÿฟใ‚’ๅ—ใ‘ใพใ›ใ‚“ใ€‚ใ“ใฎ่กจใฏใ€ๅ„ใƒฆใƒผใ‚ถใƒผใฎๆœ€ๅพŒใซ่จ˜้Œฒใ•ใ‚ŒใŸใƒชใ‚นใ‚ฏใ‚นใ‚ณใ‚ขใ‚’็คบใ—ใพใ™ใ€‚", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 48011eaebd3cd..11de6fba16ad0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2909,11 +2909,7 @@ "discover.docExplorerCallout.headerMessage": "ๆ›ดๅฅฝ็š„ๆต่งˆๆ–นๅผ", "discover.docExplorerCallout.learnMore": "ไบ†่งฃ่ฏฆๆƒ…", "discover.docExplorerCallout.tryDocumentExplorer": "่ฏ•็”จ Document Explorer", - "discover.docExplorerUpdateCallout.bodyMessage": "ไฝ“้ชŒๆ–ฐ็š„ {documentExplorer}ใ€‚้€š่ฟ‡ {fieldStatistics} ไบ†่งฃๆ‚จๆ•ฐๆฎ็š„ๅฝข็Šถใ€‚", "discover.docExplorerUpdateCallout.closeButtonAriaLabel": "ๅ…ณ้—ญ", - "discover.docExplorerUpdateCallout.documentExplorer": "Document Explorer", - "discover.docExplorerUpdateCallout.fieldStatistics": "ๅญ—ๆฎต็ปŸ่ฎกไฟกๆฏ", - "discover.docExplorerUpdateCallout.headerMessage": "ๆ›ดๅฅฝ็š„ๆต่งˆๆ–นๅผ", "discover.docTable.documentsNavigation": "ๆ–‡ๆกฃๅฏผ่ˆช", "discover.docTable.limitedSearchResultLabel": "ไป…้™ไบŽ {resultCount} ไธช็ป“ๆžœใ€‚ไผ˜ๅŒ–ๆ‚จ็š„ๆœ็ดขใ€‚", "discover.docTable.noResultsTitle": "ๆ‰พไธๅˆฐ็ป“ๆžœ", @@ -3562,7 +3558,6 @@ "expressionXY.dataLayer.xAccessor.help": "X ่ฝด", "expressionXY.dataLayer.xScaleType.help": "x ่ฝด็š„็ผฉๆ”พ็ฑปๅž‹", "expressionXY.dataLayer.yConfig.help": "y ่ฝด็š„ๅ…ถไป–้…็ฝฎ", - "expressionXY.dataLayer.yScaleType.help": "y ่ฝด็š„็ผฉๆ”พ็ฑปๅž‹", "expressionXY.gridlinesConfig.help": "้…็ฝฎ xy ๅ›พ่กจ็š„็ฝ‘ๆ ผ็บฟๅค–่ง‚", "expressionXY.gridlinesConfig.x.help": "ๆŒ‡ๅฎš x ่ฝด็š„็ฝ‘ๆ ผ็บฟๆ˜ฏๅฆๅฏ่งใ€‚", "expressionXY.gridlinesConfig.yLeft.help": "ๆŒ‡ๅฎšๅทฆไพง y ่ฝด็š„็ฝ‘ๆ ผ็บฟๆ˜ฏๅฆๅฏ่งใ€‚", @@ -5487,12 +5482,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "ๅฐšๆœชๅฏ็”จๆญค้›†ๆˆใ€‚ๆ‚จ็š„็ฎก็†ๅ‘˜ๅ…ทๆœ‰ๆ‰“ๅผ€ๅฎƒๆ‰€้œ€็š„ๆƒ้™ใ€‚", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "่ฏท่”็ณปๆ‚จ็š„็ฎก็†ๅ‘˜", "sharedUXComponents.noDataPage.elasticAgentCard.title": "ๆทปๅŠ  Elastic ไปฃ็†", - "sharedUXComponents.noDataViews.dataViewExplanation": "Kibana ้œ€่ฆๆ•ฐๆฎ่ง†ๅ›พๆฅ่ฏ†ๅˆซๆ‚จ่ฆๆต่งˆ็š„ๆ•ฐๆฎๆตใ€็ดขๅผ•ๅ’Œ็ดขๅผ•ๅˆซๅใ€‚ๆ•ฐๆฎ่ง†ๅ›พๅฏไปฅๆŒ‡ๅ‘็‰นๅฎš็ดขๅผ•๏ผˆไพ‹ๅฆ‚ๆ˜จๅคฉ็š„ๆ—ฅๅฟ—ๆ•ฐๆฎ๏ผ‰๏ผŒๆˆ–ๅŒ…ๅซๆ—ฅๅฟ—ๆ•ฐๆฎ็š„ๆ‰€ๆœ‰็ดขๅผ•ใ€‚", "sharedUXComponents.noDataViews.learnMore": "ๅธŒๆœ›ไบ†่งฃ่ฏฆๆƒ…๏ผŸ", - "sharedUXComponents.noDataViews.nowCreate": "็Žฐๅœจ๏ผŒๅˆ›ๅปบๆ•ฐๆฎ่ง†ๅ›พใ€‚", "sharedUXComponents.noDataViews.readDocumentation": "้˜…่ฏปๆ–‡ๆกฃ", - "sharedUXComponents.noDataViews.youHaveData": "ๆ‚จๅœจ Elasticsearch ไธญๆœ‰ๆ•ฐๆฎใ€‚", - "sharedUXComponents.noDataViewsPage.addDataViewText": "ๅˆ›ๅปบๆ•ฐๆฎ่ง†ๅ›พ", "sharedUXComponents.pageTemplate.noDataCard.description": "็ปง็ปญ๏ผŒ่€Œไธๆ”ถ้›†ๆ•ฐๆฎ", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ไปŽๅบ“ไธญๆทปๅŠ ", "telemetry.callout.appliesSettingTitle": "ๅฏนๆญค่ฎพ็ฝฎ็š„ๆ›ดๆ”นๅฐ†ๅบ”็”จๅˆฐ{allOfKibanaText} ไธ”ไผš่‡ชๅŠจไฟๅญ˜ใ€‚", @@ -12800,7 +12791,7 @@ "xpack.fleet.epm.assetTitles.maps": "Maps", "xpack.fleet.epm.assetTitles.mlModels": "ML ๆจกๅž‹", "xpack.fleet.epm.assetTitles.mlModules": "ML ๆจกๅ—", - "xpack.fleet.epm.assetTitles.osqueryPackAsset": "Osquery ๅŒ…", + "xpack.fleet.epm.assetTitles.osqueryPackAssets": "Osquery ๅŒ…", "xpack.fleet.epm.assetTitles.savedSearches": "ๅทฒไฟๅญ˜็š„ๆœ็ดข", "xpack.fleet.epm.assetTitles.securityRules": "ๅฎ‰ๅ…จ่ง„ๅˆ™", "xpack.fleet.epm.assetTitles.tag": "ๆ ‡็ญพ", @@ -16927,7 +16918,6 @@ "xpack.maps.layerPanel.join.deleteJoinAriaLabel": "ๅˆ ้™ค่”ๆŽฅ", "xpack.maps.layerPanel.join.deleteJoinTitle": "ๅˆ ้™ค่”ๆŽฅ", "xpack.maps.layerPanel.joinEditor.termJoinsTitle": "่ฏ่”ๆŽฅ", - "xpack.maps.layerPanel.joinEditor.termJoinTooltip": "ไฝฟ็”จ่ฏ่”ๆŽฅๅŠๅฑžๆ€งๅขžๅผบๆญคๅ›พๅฑ‚๏ผŒไปฅๅฎž็Žฐๆ•ฐๆฎ้ฉฑๅŠจ็š„ๆ ทๅผใ€‚", "xpack.maps.layerPanel.joinExpression.helpText": "้…็ฝฎๅ…ฑไบซๅฏ†้’ฅใ€‚", "xpack.maps.layerPanel.joinExpression.joinPopoverTitle": "่”ๆŽฅ", "xpack.maps.layerPanel.joinExpression.leftFieldLabel": "ๅทฆๅญ—ๆฎต", @@ -26029,7 +26019,6 @@ "xpack.securitySolution.hosts.navigation.authenticationsTitle": "่บซไปฝ้ชŒ่ฏ", "xpack.securitySolution.hosts.navigation.dns.histogram.errorFetchingDnsData": "ๆ— ๆณ•ๆŸฅ่ฏข DNS ๆ•ฐๆฎ", "xpack.securitySolution.hosts.navigation.eventsTitle": "ไบ‹ไปถ", - "xpack.securitySolution.hosts.navigation.hostRisk": "ไธปๆœบ๏ผˆๆŒ‰้ฃŽ้™ฉๆŽ’ๅˆ—๏ผ‰", "xpack.securitySolution.hosts.navigation.sessionsTitle": "ไผš่ฏ", "xpack.securitySolution.hosts.navigation.uncommonProcessesTitle": "ไธๅธธ่ง่ฟ›็จ‹", "xpack.securitySolution.hosts.navigaton.eventsUnit": "{totalCount, plural, other {ไธชไบ‹ไปถ}}", @@ -26051,7 +26040,6 @@ "xpack.securitySolution.hostsRiskTable.hostRiskScoreTitle": "ไธปๆœบ้ฃŽ้™ฉๅˆ†ๆ•ฐ", "xpack.securitySolution.hostsRiskTable.hostRiskToolTip": "ไธปๆœบ้ฃŽ้™ฉๅˆ†็ฑป็”ฑไธปๆœบ้ฃŽ้™ฉๅˆ†ๆ•ฐๅ†ณๅฎšใ€‚ๅˆ†็ฑปไธบ็ดงๆ€ฅๆˆ–้ซ˜็š„ไธปๆœบๅณ่กจ็คบๅญ˜ๅœจ้ฃŽ้™ฉใ€‚", "xpack.securitySolution.hostsRiskTable.hostsTableTitle": "ไธปๆœบ้ฃŽ้™ฉ่กจไธๅ— KQL ๆ—ถ้—ด่Œƒๅ›ดๅฝฑๅ“ใ€‚ๆœฌ่กจๆ˜พ็คบๆฏๅฐไธปๆœบๆœ€ๆ–ฐ่ฎฐๅฝ•็š„้ฃŽ้™ฉๅˆ†ๆ•ฐใ€‚", - "xpack.securitySolution.hostsRiskTable.hostsTitle": "ไธปๆœบ๏ผˆๆŒ‰้ฃŽ้™ฉๆŽ’ๅˆ—๏ผ‰", "xpack.securitySolution.hostsRiskTable.riskTitle": "ไธปๆœบ้ฃŽ้™ฉๅˆ†็ฑป", "xpack.securitySolution.hostsRiskTable.tableTitle": "ไธปๆœบ้ฃŽ้™ฉ", "xpack.securitySolution.hostsRiskTable.usersTableTitle": "็”จๆˆท้ฃŽ้™ฉ่กจไธๅ— KQL ๆ—ถ้—ด่Œƒๅ›ดๅฝฑๅ“ใ€‚ๆœฌ่กจๆ˜พ็คบๆฏไธช็”จๆˆทๆœ€ๆ–ฐ่ฎฐๅฝ•็š„้ฃŽ้™ฉๅˆ†ๆ•ฐใ€‚", diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index ad753a1708c3d..d328cbf303d61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -31,6 +31,11 @@ export type { RuleTypeParams, AsApiContract, RuleTableItem, + AlertsTableProps, + AlertsData, + BulkActionsObjectProp, + RuleSummary, + AlertStatus, AlertsTableConfigurationRegistryContract, } from './types'; @@ -54,6 +59,8 @@ export { Plugin }; export * from './plugin'; // TODO remove this import when we expose the Rules tables as a component export { loadRules } from './application/lib/rule_api/rules'; +export { loadRuleTypes } from './application/lib/rule_api'; +export { loadRuleSummary } from './application/lib/rule_api/rule_summary'; export { deleteRules } from './application/lib/rule_api/delete'; export { enableRule } from './application/lib/rule_api/enable'; export { disableRule } from './application/lib/rule_api/disable'; @@ -63,6 +70,8 @@ export { snoozeRule } from './application/lib/rule_api/snooze'; export { unsnoozeRule } from './application/lib/rule_api/unsnooze'; export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/aggregate'; export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; +export { loadRule } from './application/lib/rule_api/get_rule'; +export { loadAllActions } from './application/lib/action_connector_api'; export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; diff --git a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json index 008e4c41ea1c2..da0deda5b6454 100644 --- a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json +++ b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json @@ -1,6 +1,7 @@ [ { "cluster_uuid": "__standalone_cluster__", + "version": "", "license": {}, "elasticsearch": { "cluster_stats": { diff --git a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json index 26ff6b9e67ea7..1850e0567548f 100644 --- a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json +++ b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json @@ -112,6 +112,7 @@ { "isSupported": true, "cluster_uuid": "__standalone_cluster__", + "version": "", "license": {}, "elasticsearch": { "cluster_stats": { diff --git a/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json b/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json index 2d05717fa5725..3167ad3f5a6a0 100644 --- a/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json +++ b/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_mappings_only_8.0.0/mappings.json @@ -628,8 +628,7 @@ { "type": "index", "value": { - "aliases": { - }, + "aliases": {}, "index": ".ml-config", "mappings": { "_meta": { @@ -15510,6 +15509,26 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "links": { + "properties": { + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } } } }, @@ -20620,6 +20639,26 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "links": { + "properties": { + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } } } }, diff --git a/x-pack/test/apm_api_integration/tests/span_links/data_generator.ts b/x-pack/test/apm_api_integration/tests/span_links/data_generator.ts new file mode 100644 index 0000000000000..e9e9e5cffaa53 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/span_links/data_generator.ts @@ -0,0 +1,300 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import { SpanLink } from '@kbn/apm-plugin/typings/es_schemas/raw/fields/span_links'; +import uuid from 'uuid'; + +function getProducerInternalOnly() { + const producerInternalOnlyInstance = apm + .service('producer-internal-only', 'production', 'go') + .instance('instance a'); + + const events = timerange( + new Date('2022-01-01T00:00:00.000Z'), + new Date('2022-01-01T00:01:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerInternalOnlyInstance + .transaction(`Transaction A`) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerInternalOnlyInstance + .span(`Span A`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionA = apmFields.find((item) => item['processor.event'] === 'transaction'); + const spanA = apmFields.find((item) => item['processor.event'] === 'span'); + + const ids = { + transactionAId: transactionA?.['transaction.id']!, + traceId: spanA?.['trace.id']!, + spanAId: spanA?.['span.id']!, + }; + const spanASpanLink = { + trace: { id: spanA?.['trace.id']! }, + span: { id: spanA?.['span.id']! }, + }; + + return { + ids, + spanASpanLink, + apmFields, + }; +} + +function getProducerExternalOnly() { + const producerExternalOnlyInstance = apm + .service('producer-external-only', 'production', 'java') + .instance('instance b'); + + const events = timerange( + new Date('2022-01-01T00:02:00.000Z'), + new Date('2022-01-01T00:03:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerExternalOnlyInstance + .transaction(`Transaction B`) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerExternalOnlyInstance + .span(`Span B`, 'external', 'http') + .defaults({ + 'span.links': [{ trace: { id: 'trace#1' }, span: { id: 'span#1' } }], + }) + .timestamp(timestamp + 50) + .duration(100) + .success(), + producerExternalOnlyInstance + .span(`Span B.1`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionB = apmFields.find((item) => item['processor.event'] === 'transaction'); + const spanB = apmFields.find( + (item) => item['processor.event'] === 'span' && item['span.name'] === 'Span B' + ); + const ids = { + traceId: spanB?.['trace.id']!, + transactionBId: transactionB?.['transaction.id']!, + spanBId: spanB?.['span.id']!, + }; + + const spanBSpanLink = { + trace: { id: spanB?.['trace.id']! }, + span: { id: spanB?.['span.id']! }, + }; + + const transactionBSpanLink = { + trace: { id: transactionB?.['trace.id']! }, + span: { id: transactionB?.['transaction.id']! }, + }; + + return { + ids, + spanBSpanLink, + transactionBSpanLink, + apmFields, + }; +} + +function getProducerConsumer({ + producerInternalOnlySpanASpanLink, + producerExternalOnlySpanBLink, + producerExternalOnlyTransactionBLink, +}: { + producerInternalOnlySpanASpanLink: SpanLink; + producerExternalOnlySpanBLink: SpanLink; + producerExternalOnlyTransactionBLink: SpanLink; +}) { + const externalTraceId = uuid.v4(); + + const producerConsumerInstance = apm + .service('producer-consumer', 'production', 'ruby') + .instance('instance c'); + + const events = timerange( + new Date('2022-01-01T00:04:00.000Z'), + new Date('2022-01-01T00:05:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return producerConsumerInstance + .transaction(`Transaction C`) + .defaults({ + 'span.links': [ + producerInternalOnlySpanASpanLink, + producerExternalOnlyTransactionBLink, + { trace: { id: externalTraceId }, span: { id: producerExternalOnlySpanBLink.span.id } }, + ], + }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + producerConsumerInstance + .span(`Span C`, 'external', 'http') + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + + const apmFields = events.toArray(); + const transactionC = apmFields.find((item) => item['processor.event'] === 'transaction'); + const transactionCSpanLink = { + trace: { id: transactionC?.['trace.id']! }, + span: { id: transactionC?.['transaction.id']! }, + }; + const spanC = apmFields.find( + (item) => item['processor.event'] === 'span' || item['span.name'] === 'Span C' + ); + const spanCSpanLink = { + trace: { id: spanC?.['trace.id']! }, + span: { id: spanC?.['span.id']! }, + }; + const ids = { + traceId: transactionC?.['trace.id']!, + transactionCId: transactionC?.['transaction.id']!, + spanCId: spanC?.['span.id']!, + externalTraceId, + }; + return { + transactionCSpanLink, + spanCSpanLink, + ids, + apmFields, + }; +} + +function getConsumerMultiple({ + producerInternalOnlySpanALink, + producerExternalOnlySpanBLink, + producerConsumerSpanCLink, + producerConsumerTransactionCLink, +}: { + producerInternalOnlySpanALink: SpanLink; + producerExternalOnlySpanBLink: SpanLink; + producerConsumerSpanCLink: SpanLink; + producerConsumerTransactionCLink: SpanLink; +}) { + const consumerMultipleInstance = apm + .service('consumer-multiple', 'production', 'nodejs') + .instance('instance d'); + + const events = timerange( + new Date('2022-01-01T00:06:00.000Z'), + new Date('2022-01-01T00:07:00.000Z') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return consumerMultipleInstance + .transaction(`Transaction D`) + .defaults({ 'span.links': [producerInternalOnlySpanALink, producerConsumerSpanCLink] }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + consumerMultipleInstance + .span(`Span E`, 'external', 'http') + .defaults({ + 'span.links': [producerExternalOnlySpanBLink, producerConsumerTransactionCLink], + }) + .timestamp(timestamp + 50) + .duration(100) + .success() + ); + }); + const apmFields = events.toArray(); + const transactionD = apmFields.find((item) => item['processor.event'] === 'transaction'); + const spanE = apmFields.find((item) => item['processor.event'] === 'span'); + + const ids = { + traceId: transactionD?.['trace.id']!, + transactionDId: transactionD?.['transaction.id']!, + spanEId: spanE?.['span.id']!, + }; + + return { + ids, + apmFields, + }; +} + +/** + * Data ingestion summary: + * + * producer-internal-only (go) + * --Transaction A + * ----Span A + * + * producer-external-only (java) + * --Transaction B + * ----Span B + * ------span.links=external link + * ----Span B1 + * + * producer-consumer (ruby) + * --Transaction C + * ------span.links=Service A / Span A + * ------span.links=Service B / Transaction B + * ------span.links=External ID / Span B + * ----Span C + * + * consumer-multiple (nodejs) + * --Transaction D + * ------span.links= Service C / Span C | Service A / Span A + * ----Span E + * ------span.links= Service B / Span B | Service C / Transaction C + */ +export function generateSpanLinksData() { + const producerInternalOnly = getProducerInternalOnly(); + const producerExternalOnly = getProducerExternalOnly(); + const producerConsumer = getProducerConsumer({ + producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink, + producerExternalOnlySpanBLink: producerExternalOnly.spanBSpanLink, + producerExternalOnlyTransactionBLink: producerExternalOnly.transactionBSpanLink, + }); + const producerMultiple = getConsumerMultiple({ + producerInternalOnlySpanALink: producerInternalOnly.spanASpanLink, + producerExternalOnlySpanBLink: producerExternalOnly.spanBSpanLink, + producerConsumerSpanCLink: producerConsumer.spanCSpanLink, + producerConsumerTransactionCLink: producerConsumer.transactionCSpanLink, + }); + return { + apmFields: { + producerInternalOnly: producerInternalOnly.apmFields, + producerExternalOnly: producerExternalOnly.apmFields, + producerConsumer: producerConsumer.apmFields, + producerMultiple: producerMultiple.apmFields, + }, + ids: { + producerInternalOnly: producerInternalOnly.ids, + producerExternalOnly: producerExternalOnly.ids, + producerConsumer: producerConsumer.ids, + producerMultiple: producerMultiple.ids, + }, + }; +} diff --git a/x-pack/test/apm_api_integration/tests/span_links/span_links.spec.ts b/x-pack/test/apm_api_integration/tests/span_links/span_links.spec.ts new file mode 100644 index 0000000000000..e42c9e0fb00cd --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/span_links/span_links.spec.ts @@ -0,0 +1,496 @@ +/* + * 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 { EntityArrayIterable } from '@elastic/apm-synthtrace'; +import { ProcessorEvent } from '@kbn/apm-plugin/common/processor_event'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateSpanLinksData } from './data_generator'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2022-01-01T00:00:00.000Z').getTime(); + const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1; + + registry.when( + 'contains linked children', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + let ids: ReturnType['ids']; + + before(async () => { + const spanLinksData = generateSpanLinksData(); + + ids = spanLinksData.ids; + + await synthtraceEsClient.index( + new EntityArrayIterable(spanLinksData.apmFields.producerInternalOnly).merge( + new EntityArrayIterable(spanLinksData.apmFields.producerExternalOnly), + new EntityArrayIterable(spanLinksData.apmFields.producerConsumer), + new EntityArrayIterable(spanLinksData.apmFields.producerMultiple) + ) + ); + }); + + after(() => synthtraceEsClient.clean()); + + describe('Span links count on traces', () => { + async function fetchTraces({ traceId }: { traceId: string }) { + return await apmApiClient.readUser({ + endpoint: `GET /internal/apm/traces/{traceId}`, + params: { + path: { traceId }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); + } + + describe('producer-internal-only trace', () => { + let traces: Awaited>['body']; + before(async () => { + const tracesResponse = await fetchTraces({ traceId: ids.producerInternalOnly.traceId }); + traces = tracesResponse.body; + }); + + it('contains two children link on Span A', () => { + expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(1); + expect( + traces.linkedChildrenOfSpanCountBySpanId[ids.producerInternalOnly.spanAId] + ).to.equal(2); + }); + }); + + describe('producer-external-only trace', () => { + let traces: Awaited>['body']; + before(async () => { + const tracesResponse = await fetchTraces({ traceId: ids.producerExternalOnly.traceId }); + traces = tracesResponse.body; + }); + + it('contains two children link on Span B', () => { + expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(2); + expect( + traces.linkedChildrenOfSpanCountBySpanId[ids.producerExternalOnly.spanBId] + ).to.equal(1); + expect( + traces.linkedChildrenOfSpanCountBySpanId[ids.producerExternalOnly.transactionBId] + ).to.equal(1); + }); + }); + + describe('producer-consumer trace', () => { + let traces: Awaited>['body']; + before(async () => { + const tracesResponse = await fetchTraces({ traceId: ids.producerConsumer.traceId }); + traces = tracesResponse.body; + }); + + it('contains one children link on transaction C and two on span C', () => { + expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(2); + expect( + traces.linkedChildrenOfSpanCountBySpanId[ids.producerConsumer.transactionCId] + ).to.equal(1); + expect(traces.linkedChildrenOfSpanCountBySpanId[ids.producerConsumer.spanCId]).to.equal( + 1 + ); + }); + }); + + describe('consumer-multiple trace', () => { + let traces: Awaited>['body']; + before(async () => { + const tracesResponse = await fetchTraces({ traceId: ids.producerMultiple.traceId }); + traces = tracesResponse.body; + }); + + it('contains no children', () => { + expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(0); + expect( + traces.linkedChildrenOfSpanCountBySpanId[ids.producerMultiple.transactionDId] + ).to.equal(undefined); + expect(traces.linkedChildrenOfSpanCountBySpanId[ids.producerMultiple.spanEId]).to.equal( + undefined + ); + }); + }); + }); + + describe('Span links details', () => { + async function fetchChildrenAndParentsDetails({ + kuery, + traceId, + spanId, + processorEvent, + }: { + kuery: string; + traceId: string; + spanId: string; + processorEvent: ProcessorEvent; + }) { + const [childrenLinksResponse, parentsLinksResponse] = await Promise.all([ + await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children', + params: { + path: { traceId, spanId }, + query: { + kuery, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }), + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents', + params: { + path: { traceId, spanId }, + query: { + kuery, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + processorEvent, + }, + }, + }), + ]); + + return { + childrenLinks: childrenLinksResponse.body, + parentsLinks: parentsLinksResponse.body, + }; + } + + describe('producer-internal-only span links details', () => { + let transactionALinksDetails: Awaited>; + let spanALinksDetails: Awaited>; + before(async () => { + const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all( + [ + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerInternalOnly.traceId, + spanId: ids.producerInternalOnly.transactionAId, + processorEvent: ProcessorEvent.transaction, + }), + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerInternalOnly.traceId, + spanId: ids.producerInternalOnly.spanAId, + processorEvent: ProcessorEvent.span, + }), + ] + ); + transactionALinksDetails = transactionALinksDetailsResponse; + spanALinksDetails = spanALinksDetailsResponse; + }); + + it('returns no links for transaction A', () => { + expect(transactionALinksDetails.childrenLinks.spanLinksDetails).to.eql([]); + expect(transactionALinksDetails.parentsLinks.spanLinksDetails).to.eql([]); + }); + + it('returns no parents on Span A', () => { + expect(spanALinksDetails.parentsLinks.spanLinksDetails).to.eql([]); + }); + + it('returns two children on Span A', () => { + expect(spanALinksDetails.childrenLinks.spanLinksDetails.length).to.eql(2); + const serviceCDetails = spanALinksDetails.childrenLinks.spanLinksDetails.find( + (childDetails) => { + return ( + childDetails.traceId === ids.producerConsumer.traceId && + childDetails.spanId === ids.producerConsumer.transactionCId + ); + } + ); + expect(serviceCDetails?.details).to.eql({ + serviceName: 'producer-consumer', + agentName: 'ruby', + transactionId: ids.producerConsumer.transactionCId, + spanName: 'Transaction C', + duration: 1000000, + }); + + const serviceDDetails = spanALinksDetails.childrenLinks.spanLinksDetails.find( + (childDetails) => { + return ( + childDetails.traceId === ids.producerMultiple.traceId && + childDetails.spanId === ids.producerMultiple.transactionDId + ); + } + ); + expect(serviceDDetails?.details).to.eql({ + serviceName: 'consumer-multiple', + agentName: 'nodejs', + transactionId: ids.producerMultiple.transactionDId, + spanName: 'Transaction D', + duration: 1000000, + }); + }); + }); + + describe('producer-external-only span links details', () => { + let transactionBLinksDetails: Awaited>; + let spanBLinksDetails: Awaited>; + before(async () => { + const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all( + [ + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerExternalOnly.traceId, + spanId: ids.producerExternalOnly.transactionBId, + processorEvent: ProcessorEvent.transaction, + }), + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerExternalOnly.traceId, + spanId: ids.producerExternalOnly.spanBId, + processorEvent: ProcessorEvent.span, + }), + ] + ); + transactionBLinksDetails = transactionALinksDetailsResponse; + spanBLinksDetails = spanALinksDetailsResponse; + }); + + it('returns producer-consumer as children of transaction B', () => { + expect(transactionBLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1); + }); + + it('returns no parent for transaction B', () => { + expect(transactionBLinksDetails.parentsLinks.spanLinksDetails).to.eql([]); + }); + + it('returns external parent on Span B', () => { + expect(spanBLinksDetails.parentsLinks.spanLinksDetails.length).to.be(1); + expect(spanBLinksDetails.parentsLinks.spanLinksDetails).to.eql([ + { traceId: 'trace#1', spanId: 'span#1' }, + ]); + }); + + it('returns consumer-multiple as child on Span B', () => { + expect(spanBLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1); + expect(spanBLinksDetails.childrenLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerMultiple.traceId, + spanId: ids.producerMultiple.spanEId, + details: { + serviceName: 'consumer-multiple', + agentName: 'nodejs', + transactionId: ids.producerMultiple.transactionDId, + spanName: 'Span E', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + ]); + }); + }); + + describe('producer-consumer span links details', () => { + let transactionCLinksDetails: Awaited>; + let spanCLinksDetails: Awaited>; + before(async () => { + const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all( + [ + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerConsumer.traceId, + spanId: ids.producerConsumer.transactionCId, + processorEvent: ProcessorEvent.transaction, + }), + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerConsumer.traceId, + spanId: ids.producerConsumer.spanCId, + processorEvent: ProcessorEvent.span, + }), + ] + ); + transactionCLinksDetails = transactionALinksDetailsResponse; + spanCLinksDetails = spanALinksDetailsResponse; + }); + + it('returns producer-internal-only Span A, producer-external-only Transaction B, and External link as parents of Transaction C', () => { + expect(transactionCLinksDetails.parentsLinks.spanLinksDetails.length).to.be(3); + expect(transactionCLinksDetails.parentsLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerInternalOnly.traceId, + spanId: ids.producerInternalOnly.spanAId, + details: { + serviceName: 'producer-internal-only', + agentName: 'go', + transactionId: ids.producerInternalOnly.transactionAId, + spanName: 'Span A', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + { + traceId: ids.producerExternalOnly.traceId, + spanId: ids.producerExternalOnly.transactionBId, + details: { + serviceName: 'producer-external-only', + agentName: 'java', + transactionId: ids.producerExternalOnly.transactionBId, + duration: 1000000, + spanName: 'Transaction B', + }, + }, + { + traceId: ids.producerConsumer.externalTraceId, + spanId: ids.producerExternalOnly.spanBId, + }, + ]); + }); + + it('returns consumer-multiple Span E as child of Transaction C', () => { + expect(transactionCLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1); + expect(transactionCLinksDetails.childrenLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerMultiple.traceId, + spanId: ids.producerMultiple.spanEId, + details: { + serviceName: 'consumer-multiple', + agentName: 'nodejs', + transactionId: ids.producerMultiple.transactionDId, + spanName: 'Span E', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + ]); + }); + + it('returns no child on Span C', () => { + expect(spanCLinksDetails.parentsLinks.spanLinksDetails.length).to.be(0); + }); + + it('returns consumer-multiple as Child on producer-consumer', () => { + expect(spanCLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1); + expect(spanCLinksDetails.childrenLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerMultiple.traceId, + spanId: ids.producerMultiple.transactionDId, + details: { + serviceName: 'consumer-multiple', + agentName: 'nodejs', + transactionId: ids.producerMultiple.transactionDId, + spanName: 'Transaction D', + duration: 1000000, + }, + }, + ]); + }); + }); + + describe('consumer-multiple span links details', () => { + let transactionDLinksDetails: Awaited>; + let spanELinksDetails: Awaited>; + before(async () => { + const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all( + [ + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerMultiple.traceId, + spanId: ids.producerMultiple.transactionDId, + processorEvent: ProcessorEvent.transaction, + }), + fetchChildrenAndParentsDetails({ + kuery: '', + traceId: ids.producerMultiple.traceId, + spanId: ids.producerMultiple.spanEId, + processorEvent: ProcessorEvent.span, + }), + ] + ); + transactionDLinksDetails = transactionALinksDetailsResponse; + spanELinksDetails = spanALinksDetailsResponse; + }); + + it('returns producer-internal-only Span A and producer-consumer Span C as parents of Transaction D', () => { + expect(transactionDLinksDetails.parentsLinks.spanLinksDetails.length).to.be(2); + expect(transactionDLinksDetails.parentsLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerInternalOnly.traceId, + spanId: ids.producerInternalOnly.spanAId, + details: { + serviceName: 'producer-internal-only', + agentName: 'go', + transactionId: ids.producerInternalOnly.transactionAId, + spanName: 'Span A', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + { + traceId: ids.producerConsumer.traceId, + spanId: ids.producerConsumer.spanCId, + details: { + serviceName: 'producer-consumer', + agentName: 'ruby', + transactionId: ids.producerConsumer.transactionCId, + spanName: 'Span C', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + ]); + }); + + it('returns no children on Transaction D', () => { + expect(transactionDLinksDetails.childrenLinks.spanLinksDetails.length).to.be(0); + }); + + it('returns producer-external-only Span B and producer-consumer Transaction C as parents of Span E', () => { + expect(spanELinksDetails.parentsLinks.spanLinksDetails.length).to.be(2); + + expect(spanELinksDetails.parentsLinks.spanLinksDetails).to.eql([ + { + traceId: ids.producerExternalOnly.traceId, + spanId: ids.producerExternalOnly.spanBId, + details: { + serviceName: 'producer-external-only', + agentName: 'java', + transactionId: ids.producerExternalOnly.transactionBId, + spanName: 'Span B', + duration: 100000, + spanSubtype: 'http', + spanType: 'external', + }, + }, + { + traceId: ids.producerConsumer.traceId, + spanId: ids.producerConsumer.transactionCId, + details: { + serviceName: 'producer-consumer', + agentName: 'ruby', + transactionId: ids.producerConsumer.transactionCId, + spanName: 'Transaction C', + duration: 1000000, + }, + }, + ]); + }); + + it('returns no children on Span E', () => { + expect(spanELinksDetails.childrenLinks.spanLinksDetails.length).to.be(0); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/traces/trace_by_id.spec.ts b/x-pack/test/apm_api_integration/tests/traces/trace_by_id.spec.ts index 97fcb49d854dc..43f52bed594b2 100644 --- a/x-pack/test/apm_api_integration/tests/traces/trace_by_id.spec.ts +++ b/x-pack/test/apm_api_integration/tests/traces/trace_by_id.spec.ts @@ -4,84 +4,120 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { apm, EntityArrayIterable, timerange } from '@elastic/apm-synthtrace'; import expect from '@kbn/expect'; -import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { SupertestReturnType } from '../../common/apm_api_supertest'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2022-01-01T00:00:00.000Z').getTime(); + const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1; - const archiveName = 'apm_8.0.0'; - const metadata = archives_metadata[archiveName]; - const { start, end } = metadata; + async function fetchTraces({ + traceId, + query, + }: { + traceId: string; + query: { start: string; end: string; _inspect?: boolean }; + }) { + return await apmApiClient.readUser({ + endpoint: `GET /internal/apm/traces/{traceId}`, + params: { + path: { traceId }, + query, + }, + }); + } registry.when('Trace does not exist', { config: 'basic', archives: [] }, () => { it('handles empty state', async () => { - const response = await apmApiClient.readUser({ - endpoint: `GET /internal/apm/traces/{traceId}`, - params: { - path: { traceId: 'foo' }, - query: { start, end }, + const response = await fetchTraces({ + traceId: 'foo', + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), }, }); expect(response.status).to.be(200); - expect(response.body).to.eql({ exceedsMax: false, traceDocs: [], errorDocs: [] }); + expect(response.body).to.eql({ + exceedsMax: false, + traceDocs: [], + errorDocs: [], + linkedChildrenOfSpanCountBySpanId: {}, + }); }); }); - registry.when('Trace exists', { config: 'basic', archives: [archiveName] }, () => { - let response: SupertestReturnType<`GET /internal/apm/traces/{traceId}`>; + registry.when('Trace exists', { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, () => { + let serviceATraceId: string; before(async () => { - response = await apmApiClient.readUser({ - endpoint: `GET /internal/apm/traces/{traceId}`, - params: { - path: { traceId: '64d0014f7530df24e549dd17cc0a8895' }, - query: { start, end }, - }, - }); - }); + const instanceJava = apm.service('synth-apple', 'production', 'java').instance('instance-b'); + const events = timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => { + return [ + instanceJava + .transaction('GET /apple ๐Ÿ') + .timestamp(timestamp) + .duration(1000) + .failure() + .errors( + instanceJava + .error('[ResponseError] index_not_found_exception') + .timestamp(timestamp + 50) + ) + .children( + instanceJava + .span('get_green_apple_๐Ÿ', 'db', 'elasticsearch') + .timestamp(timestamp + 50) + .duration(900) + .success() + ), + ]; + }); + const entities = events.toArray(); + serviceATraceId = entities.slice(0, 1)[0]['trace.id']!; - it('returns the correct status code', async () => { - expect(response.status).to.be(200); + await synthtraceEsClient.index(new EntityArrayIterable(entities)); }); - it('returns the correct number of buckets', async () => { - expectSnapshot(response.body.errorDocs.map((doc) => doc.error?.exception?.[0]?.message)) - .toMatchInline(` - Array [ - "Test CaptureError", - "Uncaught Error: Test Error in dashboard", - ] - `); - expectSnapshot( - response.body.traceDocs.map((doc) => - 'span' in doc ? `${doc.span.name} (span)` : `${doc.transaction.name} (transaction)` - ) - ).toMatchInline(` - Array [ - "/dashboard (transaction)", - "GET /api/stats (transaction)", - "APIRestController#topProducts (transaction)", - "Parsing the document, executing sync. scripts (span)", - "GET /api/products/top (span)", - "GET /api/stats (span)", - "Requesting and receiving the document (span)", - "SELECT FROM customers (span)", - "SELECT FROM order_lines (span)", - "http://opbeans-frontend:3000/static/css/main.7bd7c5e8.css (span)", - "SELECT FROM products (span)", - "SELECT FROM orders (span)", - "SELECT FROM order_lines (span)", - "Making a connection to the server (span)", - "Fire \\"load\\" event (span)", - "empty query (span)", - ] - `); - expectSnapshot(response.body.exceedsMax).toMatchInline(`false`); + after(() => synthtraceEsClient.clean()); + + describe('return trace', () => { + let traces: Awaited>['body']; + before(async () => { + const response = await fetchTraces({ + traceId: serviceATraceId, + query: { start: new Date(start).toISOString(), end: new Date(end).toISOString() }, + }); + expect(response.status).to.eql(200); + traces = response.body; + }); + it('returns some errors', () => { + expect(traces.errorDocs.length).to.be.greaterThan(0); + expect(traces.errorDocs[0].error.exception?.[0].message).to.eql( + '[ResponseError] index_not_found_exception' + ); + }); + + it('returns some trace docs', () => { + expect(traces.traceDocs.length).to.be.greaterThan(0); + expect( + traces.traceDocs.map((item) => { + if (item.span && 'name' in item.span) { + return item.span.name; + } + if (item.transaction && 'name' in item.transaction) { + return item.transaction.name; + } + }) + ).to.eql(['GET /apple ๐Ÿ', 'get_green_apple_๐Ÿ']); + }); }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/actions.ts b/x-pack/test/fleet_api_integration/apis/agents/actions.ts index 00bb00eb9c68c..55768720f3ce9 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/actions.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/actions.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import uuid from 'uuid/v4'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { testUsers } from '../test_users'; @@ -13,6 +14,7 @@ export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); + const es = getService('es'); const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('fleet_agents_actions', () => { @@ -23,95 +25,142 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); }); - it('should return a 200 if this a valid actions request', async () => { - const { body: apiResponse } = await supertest - .post(`/api/fleet/agents/agent1/actions`) - .set('kbn-xsrf', 'xx') - .send({ - action: { - type: 'POLICY_CHANGE', - data: { data: 'action_data' }, - }, - }) - .expect(200); + describe('POST /agents/{agentId}/actions', () => { + it('should return a 200 if this a valid actions request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'POLICY_CHANGE', + data: { data: 'action_data' }, + }, + }) + .expect(200); - expect(apiResponse.item.type).to.eql('POLICY_CHANGE'); - expect(apiResponse.item.data).to.eql({ data: 'action_data' }); - }); + expect(apiResponse.item.type).to.eql('POLICY_CHANGE'); + expect(apiResponse.item.data).to.eql({ data: 'action_data' }); + }); - it('should return a 200 if this a valid SETTINGS action request', async () => { - const { body: apiResponse } = await supertest - .post(`/api/fleet/agents/agent1/actions`) - .set('kbn-xsrf', 'xx') - .send({ - action: { - type: 'SETTINGS', - data: { log_level: 'debug' }, - }, - }) - .expect(200); + it('should return a 200 if this a valid SETTINGS action request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'SETTINGS', + data: { log_level: 'debug' }, + }, + }) + .expect(200); - expect(apiResponse.item.type).to.eql('SETTINGS'); - expect(apiResponse.item.data).to.eql({ log_level: 'debug' }); - }); + expect(apiResponse.item.type).to.eql('SETTINGS'); + expect(apiResponse.item.data).to.eql({ log_level: 'debug' }); + }); - it('should return a 400 if this a invalid SETTINGS action request', async () => { - const { body: apiResponse } = await supertest - .post(`/api/fleet/agents/agent1/actions`) - .set('kbn-xsrf', 'xx') - .send({ - action: { - type: 'SETTINGS', - data: { log_level: 'thisnotavalidloglevel' }, - }, - }) - .expect(400); + it('should return a 400 if this a invalid SETTINGS action request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'SETTINGS', + data: { log_level: 'thisnotavalidloglevel' }, + }, + }) + .expect(400); - expect(apiResponse.message).to.match( - /\[request body.action\.[0-9]*\.data\.log_level]: types that failed validation/ - ); - }); + expect(apiResponse.message).to.match( + /\[request body.action\.[0-9]*\.data\.log_level]: types that failed validation/ + ); + }); - it('should return a 400 when request does not have type information', async () => { - const { body: apiResponse } = await supertest - .post(`/api/fleet/agents/agent1/actions`) - .set('kbn-xsrf', 'xx') - .send({ - action: { - data: { data: 'action_data' }, - }, - }) - .expect(400); - expect(apiResponse.message).to.match( - /\[request body.action\.[0-9]*\.type]: expected at least one defined value but got \[undefined]/ - ); + it('should return a 400 when request does not have type information', async () => { + const { body: apiResponse } = await supertest + .post(`/api/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + data: { data: 'action_data' }, + }, + }) + .expect(400); + expect(apiResponse.message).to.match( + /\[request body.action\.[0-9]*\.type]: expected at least one defined value but got \[undefined]/ + ); + }); + + it('should return a 404 when agent does not exist', async () => { + await supertest + .post(`/api/fleet/agents/agent100/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'POLICY_CHANGE', + data: { data: 'action_data' }, + }, + }) + .expect(404); + }); + + it('should return a 403 if user lacks fleet all permissions', async () => { + await supertestWithoutAuth + .post(`/api/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .auth(testUsers.fleet_no_access.username, testUsers.fleet_no_access.password) + .send({ + action: { + type: 'POLICY_CHANGE', + data: { data: 'action_data' }, + }, + }) + .expect(403); + }); }); - it('should return a 404 when agent does not exist', async () => { - await supertest - .post(`/api/fleet/agents/agent100/actions`) - .set('kbn-xsrf', 'xx') - .send({ - action: { - type: 'POLICY_CHANGE', - data: { data: 'action_data' }, + describe('POST /agents/actions/{actionId}/cancel', () => { + it('should return a 404 if the action do not exists', async () => { + await supertest + .post(`/api/fleet/agents/actions/i-do-not-exists/cancel`) + .set('kbn-xsrf', 'xx') + .expect(404); + }); + + it('should return a 200 and create a CANCEL action if the action exists', async () => { + const actionId = uuid(); + await es.create({ + index: '.fleet-actions', + refresh: 'wait_for', + id: uuid(), + body: { + '@timestamp': new Date().toISOString(), + expiration: new Date().toISOString(), + agents: ['agent1', 'agent2'], + action_id: actionId, + data: {}, + type: 'UPGRADE', }, - }) - .expect(404); - }); + }); + await supertest + .post(`/api/fleet/agents/actions/${actionId}/cancel`) + .set('kbn-xsrf', 'xx') + .expect(200); - it('should return a 403 if user lacks fleet all permissions', async () => { - await supertestWithoutAuth - .post(`/api/fleet/agents/agent1/actions`) - .set('kbn-xsrf', 'xx') - .auth(testUsers.fleet_no_access.username, testUsers.fleet_no_access.password) - .send({ - action: { - type: 'POLICY_CHANGE', - data: { data: 'action_data' }, + const actionsRes = await es.search({ + index: '.fleet-actions', + body: { + sort: [{ '@timestamp': { order: 'desc' } }], }, - }) - .expect(403); + }); + + const action: any = actionsRes.hits.hits[0]._source; + + expect(action).to.have.keys('agents', 'type'); + expect(action.agents).contain('agent1'); + expect(action.agents).contain('agent2'); + expect(action.type).equal('CANCEL'); + expect(action.data).eql({ target_id: actionId }); + }); }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts b/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts new file mode 100644 index 0000000000000..f1a5666875e5a --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import moment from 'moment'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from './services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('Agent current upgrades API', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents'); + }); + setupFleetAndAgents(providerContext); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); + }); + + describe('GET /api/fleet/agents/current_upgrades', () => { + before(async () => { + await es.deleteByQuery({ + index: AGENT_ACTIONS_INDEX, + q: '*', + }); + // Action 1 non expired and non complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + + // action 2 non expired and non complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action2', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action2', + agents: ['agent4', 'agent5'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + // Action 3 complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action3', + agents: ['agent1', 'agent2'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + await es.index( + { + refresh: 'wait_for', + index: AGENT_ACTIONS_RESULTS_INDEX, + document: { + action_id: 'action3', + '@timestamp': new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + }, + ES_INDEX_OPTIONS + ); + await es.index( + { + refresh: 'wait_for', + index: AGENT_ACTIONS_RESULTS_INDEX, + document: { + action_id: 'action3', + '@timestamp': new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + }, + ES_INDEX_OPTIONS + ); + + // Action 4 expired + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action4', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().subtract(1, 'day').toISOString(), + }, + }); + + // Action 5 cancelled + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action5', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'CANCEL', + action_id: 'cancelaction1', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + data: { + target_id: 'action5', + }, + }, + }); + }); + it('should respond 200 and the current upgrades', async () => { + const res = await supertest.get(`/api/fleet/agents/current_upgrades`).expect(200); + const actionIds = res.body.items.map((item: any) => item.actionId); + expect(actionIds).length(2); + expect(actionIds).contain('action1'); + expect(actionIds).contain('action2'); + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agents/index.js b/x-pack/test/fleet_api_integration/apis/agents/index.js new file mode 100644 index 0000000000000..dbfcbf66928d9 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export default function loadTests({ loadTestFile }) { + describe('Fleet Endpoints', () => { + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./list')); + loadTestFile(require.resolve('./unenroll')); + loadTestFile(require.resolve('./actions')); + loadTestFile(require.resolve('./upgrade')); + loadTestFile(require.resolve('./current_upgrades')); + loadTestFile(require.resolve('./reassign')); + loadTestFile(require.resolve('./status')); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index f5ecce2b0396d..141c4fa3dc478 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -7,16 +7,12 @@ import expect from '@kbn/expect'; import semver from 'semver'; -import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import { AGENTS_INDEX, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; -import { skipIfNoDockerRegistry } from '../../helpers'; +import { skipIfNoDockerRegistry, generateAgent, makeSnapshotVersion } from '../../helpers'; import { testUsers } from '../test_users'; -const makeSnapshotVersion = (version: string) => { - return version.endsWith('-SNAPSHOT') ? version : `${version}-SNAPSHOT`; -}; - export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); @@ -25,7 +21,7 @@ export default function (providerContext: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - describe('fleet upgrade', () => { + describe('Agents upgrade', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents'); @@ -282,8 +278,58 @@ export default function (providerContext: FtrProviderContext) { }); describe('multiple agents', () => { + const fleetServerVersion = '7.16.0'; + + beforeEach(async () => { + await supertest.post(`/api/fleet/agent_policies`).set('kbn-xsrf', 'kibana').send({ + name: 'Fleet Server policy 1', + policy_id: 'fleet-server-policy', + namespace: 'default', + has_fleet_server: true, + }); + + await kibanaServer.savedObjects.create({ + id: `package-policy-test`, + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + overwrite: true, + attributes: { + policy_id: 'fleet-server-policy', + name: 'Fleet Server', + output_id: 'default', + package: { + name: 'fleet_server', + }, + }, + }); + await generateAgent( + providerContext, + 'healthy', + 'agentWithFS', + 'fleet-server-policy', + fleetServerVersion + ); + }); + + beforeEach(async () => { + es.updateByQuery({ + index: '.fleet-agents', + body: { + script: "ctx._source.remove('upgrade_started_at')", + query: { + bool: { + must: [ + { + exists: { + field: 'upgrade_started_at', + }, + }, + ], + }, + }, + }, + }); + }); it('should respond 200 to bulk upgrade upgradeable agents and update the agent SOs', async () => { - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -302,7 +348,7 @@ export default function (providerContext: FtrProviderContext) { doc: { local_metadata: { elastic: { - agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + agent: { upgradeable: false, version: '0.0.0' }, }, }, }, @@ -312,7 +358,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/agents/bulk_upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: kibanaVersion, + version: fleetServerVersion, agents: ['agent1', 'agent2'], }) .expect(200); @@ -326,7 +372,6 @@ export default function (providerContext: FtrProviderContext) { }); it('should create a .fleet-actions document with the agents, version, and upgrade window', async () => { - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -351,7 +396,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/agents/bulk_upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: kibanaVersion, + version: fleetServerVersion, agents: ['agent1', 'agent2'], rollout_duration_seconds: 6000, }) @@ -377,7 +422,6 @@ export default function (providerContext: FtrProviderContext) { }); it('should allow to upgrade multiple upgradeable agents by kuery', async () => { - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -396,9 +440,10 @@ export default function (providerContext: FtrProviderContext) { doc: { local_metadata: { elastic: { - agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + agent: { upgradeable: false, version: '0.0.0' }, }, }, + upgrade_started_at: undefined, }, }, }); @@ -407,7 +452,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: 'active:true', - version: kibanaVersion, + version: fleetServerVersion, }) .expect(200); const [agent1data, agent2data] = await Promise.all([ @@ -419,7 +464,6 @@ export default function (providerContext: FtrProviderContext) { }); it('should not upgrade an unenrolling agent during bulk_upgrade', async () => { - const kibanaVersion = await kibanaServer.version.get(); await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').send({ revoke: true, }); @@ -448,7 +492,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: ['agent1', 'agent2'], - version: kibanaVersion, + version: fleetServerVersion, }); const [agent1data, agent2data] = await Promise.all([ supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), @@ -458,7 +502,6 @@ export default function (providerContext: FtrProviderContext) { expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); }); it('should not upgrade an unenrolled agent during bulk_upgrade', async () => { - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -487,7 +530,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: ['agent1', 'agent2'], - version: kibanaVersion, + version: fleetServerVersion, }); const [agent1data, agent2data] = await Promise.all([ supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), @@ -496,7 +539,7 @@ export default function (providerContext: FtrProviderContext) { expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); }); - it('should not upgrade an non upgradeable agent during bulk_upgrade', async () => { + it('should not upgrade a non-upgradeable agent during bulk_upgrade', async () => { const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', @@ -537,7 +580,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: ['agent1', 'agent2', 'agent3'], - version: kibanaVersion, + version: fleetServerVersion, }); const [agent1data, agent2data, agent3data] = await Promise.all([ supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), @@ -549,7 +592,6 @@ export default function (providerContext: FtrProviderContext) { expect(typeof agent3data.body.item.upgrade_started_at).to.be('undefined'); }); it('should upgrade a non upgradeable agent during bulk_upgrade with force flag', async () => { - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -568,7 +610,7 @@ export default function (providerContext: FtrProviderContext) { doc: { local_metadata: { elastic: { - agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + agent: { upgradeable: true, version: semver.inc(fleetServerVersion, 'patch') }, }, }, }, @@ -589,7 +631,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: ['agent1', 'agent2', 'agent3'], - version: kibanaVersion, + version: fleetServerVersion, force: true, }); const [agent1data, agent2data, agent3data] = await Promise.all([ @@ -601,13 +643,49 @@ export default function (providerContext: FtrProviderContext) { expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); expect(typeof agent3data.body.item.upgrade_started_at).to.be('string'); }); - it('should respond 400 if trying to bulk upgrade to a version that does not match installed kibana version', async () => { + it('should respond 400 if trying to bulk upgrade to a version that is higher than the latest installed kibana version', async () => { + const kibanaVersion = await kibanaServer.version.get(); + const higherVersion = semver.inc(kibanaVersion, 'patch'); + await es.update({ + id: 'agent1', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_id: `agent-policy-1`, + local_metadata: { elastic: { agent: { upgradeable: true, version: '6.0.0' } } }, + }, + }, + }); + await es.update({ + id: 'agent2', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_id: `agent-policy-2`, + local_metadata: { elastic: { agent: { upgradeable: true, version: '6.0.0' } } }, + }, + }, + }); + await supertest + .post(`/api/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2'], + version: higherVersion, + }) + .expect(400); + }); + it('should respond 400 if trying to bulk upgrade to a version that is higher than the latest fleet server version', async () => { + const higherVersion = semver.inc(fleetServerVersion, 'patch'); await es.update({ id: 'agent1', refresh: 'wait_for', index: AGENTS_INDEX, body: { doc: { + policy_id: `agent-policy-1`, local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, }, }, @@ -618,6 +696,7 @@ export default function (providerContext: FtrProviderContext) { index: AGENTS_INDEX, body: { doc: { + policy_id: `agent-policy-2`, local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, }, }, @@ -627,13 +706,45 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ agents: ['agent1', 'agent2'], - version: '1.0.0', - force: true, + version: higherVersion, }) .expect(400); }); + it('should respond 200 if trying to bulk upgrade to a version higher than the latest fleet server version if the agents include fleet server agents', async () => { + const higherVersion = semver.inc(fleetServerVersion, 'patch'); + await es.update({ + id: 'agent1', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_id: `agent-policy-1`, + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }, + }); + await es.update({ + id: 'agent2', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_id: `agent-policy-2`, + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }, + }); + await supertest + .post(`/api/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2', 'agentWithFS'], + version: higherVersion, + }) + .expect(200); + }); - it('should respond 400 if trying to bulk upgrade to a version that does not match installed kibana version', async () => { + it('should throw an error if source_uri parameter is passed', async () => { const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', @@ -682,7 +793,6 @@ export default function (providerContext: FtrProviderContext) { is_managed: true, }); - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -712,7 +822,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/agents/bulk_upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: kibanaVersion, + version: fleetServerVersion, agents: ['agent1', 'agent2'], }) .expect(200); @@ -743,7 +853,6 @@ export default function (providerContext: FtrProviderContext) { is_managed: true, }); - const kibanaVersion = await kibanaServer.version.get(); await es.update({ id: 'agent1', refresh: 'wait_for', @@ -762,7 +871,7 @@ export default function (providerContext: FtrProviderContext) { doc: { local_metadata: { elastic: { - agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + agent: { upgradeable: true, version: fleetServerVersion }, }, }, }, @@ -773,7 +882,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/agents/bulk_upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: kibanaVersion, + version: fleetServerVersion, agents: ['agent1', 'agent2'], force: true, }); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 16f8fc04aa92f..b206e584b94b3 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -251,6 +251,16 @@ export default function (providerContext: FtrProviderContext) { resOsqueryPackAsset = err; } expect(resOsqueryPackAsset.response.data.statusCode).equal(404); + let resOsquerySavedQuery; + try { + resOsquerySavedQuery = await kibanaServer.savedObjects.get({ + type: 'osquery-saved-query', + id: 'sample_osquery_saved_query', + }); + } catch (err) { + resOsquerySavedQuery = err; + } + expect(resOsquerySavedQuery.response.data.statusCode).equal(404); }); it('should have removed the saved object', async function () { let res; @@ -443,6 +453,11 @@ const expectAssetsInstalled = ({ id: 'sample_osquery_pack_asset', }); expect(resOsqueryPackAsset.id).equal('sample_osquery_pack_asset'); + const resOsquerySavedObject = await kibanaServer.savedObjects.get({ + type: 'osquery-saved-query', + id: 'sample_osquery_saved_query', + }); + expect(resOsquerySavedObject.id).equal('sample_osquery_saved_query'); const resCloudSecurityPostureRuleTemplate = await kibanaServer.savedObjects.get({ type: 'csp-rule-template', id: 'sample_csp_rule_template', @@ -526,6 +541,10 @@ const expectAssetsInstalled = ({ id: 'sample_osquery_pack_asset', type: 'osquery-pack-asset', }, + { + id: 'sample_osquery_saved_query', + type: 'osquery-saved-query', + }, { id: 'sample_search', type: 'search', @@ -687,6 +706,10 @@ const expectAssetsInstalled = ({ id: '313ddb31-e70a-59e8-8287-310d4652a9b7', type: 'epm-packages-assets', }, + { + id: '24a74223-5fdb-52ca-9cb5-b2cdd2a42b07', + type: 'epm-packages-assets', + }, { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets', diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 9758107cee83d..6cbedf68da567 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -361,6 +361,7 @@ export default function (providerContext: FtrProviderContext) { type: 'epm-packages', id: 'all_assets', }); + expect(res.attributes).eql({ installed_kibana_space_id: 'default', installed_kibana: [ @@ -400,6 +401,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_osquery_pack_asset', type: 'osquery-pack-asset', }, + { + id: 'sample_osquery_saved_query', + type: 'osquery-saved-query', + }, ], installed_es: [ { @@ -484,6 +489,7 @@ export default function (providerContext: FtrProviderContext) { { id: '7f4c5aca-b4f5-5f0a-95af-051da37513fc', type: 'epm-packages-assets' }, { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, { id: 'cb0bbdd7-e043-508b-91c0-09e4cc0f5a3c', type: 'epm-packages-assets' }, + { id: '6a87d1a5-adf8-5a30-82c4-4c3b8298272b', type: 'epm-packages-assets' }, { id: '2e56f08b-1d06-55ed-abee-4708e1ccf0aa', type: 'epm-packages-assets' }, { id: '4035007b-9c33-5227-9803-2de8a17523b5', type: 'epm-packages-assets' }, { id: 'e6ae7d31-6920-5408-9219-91ef1662044b', type: 'epm-packages-assets' }, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/osquery_saved_query/sample_osquery_saved_query.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/osquery_saved_query/sample_osquery_saved_query.json new file mode 100644 index 0000000000000..d0a647ff5e0cf --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/osquery_saved_query/sample_osquery_saved_query.json @@ -0,0 +1,24 @@ +{ + "attributes": { + "created_at": "2021-12-21T08:54:07.802Z", + "created_by": "elastic", + "description": "Test saved query description", + "ecs_mapping": [ + { + "key": "labels", + "value": { + "field": "hours" + } + } + ], + "id": "Saved-Query-Id", + "interval": "3600", + "query": "select * from uptime;", + "platform": "linux,darwin", + "version": 1 + }, + "coreMigrationVersion": "8.1.0", + "references": [], + "id": "sample_osquery_saved_query", + "type": "osquery-saved-query" +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/osquery_saved_query/sample_osquery_saved_query.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/osquery_saved_query/sample_osquery_saved_query.json new file mode 100644 index 0000000000000..d0a647ff5e0cf --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/osquery_saved_query/sample_osquery_saved_query.json @@ -0,0 +1,24 @@ +{ + "attributes": { + "created_at": "2021-12-21T08:54:07.802Z", + "created_by": "elastic", + "description": "Test saved query description", + "ecs_mapping": [ + { + "key": "labels", + "value": { + "field": "hours" + } + } + ], + "id": "Saved-Query-Id", + "interval": "3600", + "query": "select * from uptime;", + "platform": "linux,darwin", + "version": 1 + }, + "coreMigrationVersion": "8.1.0", + "references": [], + "id": "sample_osquery_saved_query", + "type": "osquery-saved-query" +} diff --git a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts index 16350114358c4..9592a24557de0 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts @@ -7,53 +7,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; -import { skipIfNoDockerRegistry } from '../helpers'; +import { skipIfNoDockerRegistry, generateAgent } from '../helpers'; import { setupFleetAndAgents } from './agents/services'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); - const es = getService('es'); const esArchiver = getService('esArchiver'); let agentCount = 0; - async function generateAgent(status: string, policyId: string) { - let data: any = {}; - - switch (status) { - case 'error': - data = { last_checkin_status: 'error' }; - break; - case 'degraded': - data = { last_checkin_status: 'degraded' }; - break; - case 'offline': - data = { last_checkin: '2017-06-07T18:59:04.498Z' }; - break; - // Agent with last checkin status as error and currently unenrolling => should displayd updating status - case 'error-unenrolling': - data = { - last_checkin_status: 'error', - unenrollment_started_at: '2017-06-07T18:59:04.498Z', - }; - break; - default: - data = { last_checkin: new Date().toISOString() }; - } - - await es.index({ - index: '.fleet-agents', - body: { - id: `agent-${++agentCount}`, - active: true, - last_checkin: new Date().toISOString(), - policy_id: policyId, - policy_revision: 1, - ...data, - }, - refresh: 'wait_for', - }); - } describe('fleet_telemetry', () => { skipIfNoDockerRegistry(providerContext); @@ -108,16 +70,31 @@ export default function (providerContext: FtrProviderContext) { .expect(200); // Default Fleet Server - await generateAgent('healthy', fleetServerPolicy.id); - await generateAgent('healthy', fleetServerPolicy.id); - await generateAgent('error', fleetServerPolicy.id); + await generateAgent( + providerContext, + 'healthy', + `agent-${++agentCount}`, + fleetServerPolicy.id + ); + await generateAgent( + providerContext, + 'healthy', + `agent-${++agentCount}`, + fleetServerPolicy.id + ); + await generateAgent(providerContext, 'error', `agent-${++agentCount}`, fleetServerPolicy.id); // Default policy - await generateAgent('healthy', agentPolicy.id); - await generateAgent('offline', agentPolicy.id); - await generateAgent('error', agentPolicy.id); - await generateAgent('degraded', agentPolicy.id); - await generateAgent('error-unenrolling', agentPolicy.id); + await generateAgent(providerContext, 'healthy', `agent-${++agentCount}`, agentPolicy.id); + await generateAgent(providerContext, 'offline', `agent-${++agentCount}`, agentPolicy.id); + await generateAgent(providerContext, 'error', `agent-${++agentCount}`, agentPolicy.id); + await generateAgent(providerContext, 'degraded', `agent-${++agentCount}`, agentPolicy.i); + await generateAgent( + providerContext, + 'error-unenrolling', + `agent-${++agentCount}`, + agentPolicy.id + ); }); it('should return the correct telemetry values for fleet', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/index.js b/x-pack/test/fleet_api_integration/apis/index.js index 1c528e719e2e8..4f9d2026bc531 100644 --- a/x-pack/test/fleet_api_integration/apis/index.js +++ b/x-pack/test/fleet_api_integration/apis/index.js @@ -20,13 +20,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./fleet_setup')); // Agents - loadTestFile(require.resolve('./agents/delete')); - loadTestFile(require.resolve('./agents/list')); - loadTestFile(require.resolve('./agents/unenroll')); - loadTestFile(require.resolve('./agents/actions')); - loadTestFile(require.resolve('./agents/upgrade')); - loadTestFile(require.resolve('./agents/reassign')); - loadTestFile(require.resolve('./agents/status')); + loadTestFile(require.resolve('./agents')); // Enrollment API keys loadTestFile(require.resolve('./enrollment_api_keys/crud')); diff --git a/x-pack/test/fleet_api_integration/helpers.ts b/x-pack/test/fleet_api_integration/helpers.ts index 42b83ea90a47d..6e1a2edaf9f75 100644 --- a/x-pack/test/fleet_api_integration/helpers.ts +++ b/x-pack/test/fleet_api_integration/helpers.ts @@ -29,3 +29,60 @@ export function skipIfNoDockerRegistry(providerContext: FtrProviderContext) { } }); } + +export const makeSnapshotVersion = (version: string) => { + return version.endsWith('-SNAPSHOT') ? version : `${version}-SNAPSHOT`; +}; + +export async function generateAgent( + providerContext: FtrProviderContext, + status: string, + id: string, + policyId: string, + version?: string +) { + let data: any = {}; + const { getService } = providerContext; + const es = getService('es'); + + switch (status) { + case 'error': + data = { last_checkin_status: 'error' }; + break; + case 'degraded': + data = { last_checkin_status: 'degraded' }; + break; + case 'offline': + data = { last_checkin: '2017-06-07T18:59:04.498Z' }; + break; + // Agent with last checkin status as error and currently unenrolling => should displayd updating status + case 'error-unenrolling': + data = { + last_checkin_status: 'error', + unenrollment_started_at: '2017-06-07T18:59:04.498Z', + }; + break; + default: + data = { last_checkin: new Date().toISOString() }; + } + + await es.index({ + index: '.fleet-agents', + body: { + id, + active: true, + last_checkin: new Date().toISOString(), + policy_id: policyId, + policy_revision: 1, + local_metadata: { + elastic: { + agent: { + version, + }, + }, + }, + ...data, + }, + refresh: 'wait_for', + }); +} diff --git a/x-pack/test/functional/es_archives/rule_registry/alerts/data.json b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json index 9c8d233e02074..65719c78a864e 100644 --- a/x-pack/test/functional/es_archives/rule_registry/alerts/data.json +++ b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json @@ -39,7 +39,7 @@ "id": "space2alert", "source": { "event.kind" : "signal", - "@timestamp": "2020-12-16T15:16:18.570Z", + "@timestamp": "2020-12-16T15:16:19.570Z", "kibana.alert.rule.rule_type_id": "apm.error_rate", "message": "hello world 1", "kibana.alert.rule.consumer": "apm", @@ -56,7 +56,7 @@ "id": "020202", "source": { "event.kind" : "signal", - "@timestamp": "2020-12-16T15:16:18.570Z", + "@timestamp": "2020-12-16T15:16:20.570Z", "kibana.alert.rule.rule_type_id": "siem.queryRule", "message": "hello world security", "kibana.alert.rule.consumer": "siem", @@ -73,7 +73,7 @@ "id": "020204", "source": { "event.kind" : "signal", - "@timestamp": "2020-12-16T15:16:18.570Z", + "@timestamp": "2020-12-16T15:16:21.570Z", "kibana.alert.rule.rule_type_id": "siem.queryRule", "message": "hello world security", "kibana.alert.rule.consumer": "siem", @@ -89,7 +89,7 @@ "index": ".alerts-security.alerts", "id": "space1securityalert", "source": { - "@timestamp": "2020-12-16T15:16:18.570Z", + "@timestamp": "2020-12-16T15:16:22.570Z", "kibana.alert.rule.rule_type_id": "siem.queryRule", "message": "hello world security", "kibana.alert.rule.consumer": "siem", @@ -105,7 +105,7 @@ "index": ".alerts-security.alerts", "id": "space2securityalert", "source": { - "@timestamp": "2020-12-16T15:16:18.570Z", + "@timestamp": "2020-12-16T15:16:23.570Z", "kibana.alert.rule.rule_type_id": "siem.queryRule", "message": "hello world security", "kibana.alert.rule.consumer": "siem", diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/find_alerts.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/find_alerts.ts index 35461b47f6def..cbaf32cc51a86 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/find_alerts.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/find_alerts.ts @@ -158,6 +158,93 @@ export default ({ getService }: FtrProviderContext) => { expect(found.body.hits.total.value).to.be.above(0); }); + it(`${superUser.username} should allow a custom sort and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => { + const found = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } }, + sort: [ + { + '@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that. + }, + ], + index: SECURITY_SOLUTION_ALERT_INDEX, + }); + expect(found.statusCode).to.eql(200); + expect(found.body.hits.total.value).to.be.above(0); + + let lastSort = Infinity; + + found.body.hits.hits.forEach((hit: any) => { + expect(hit.sort).to.be.above(0); + + if (hit.sort > lastSort) { + throw new Error('sort by timestamp desc failed.'); + } + + lastSort = hit.sort; + }); + }); + + it(`${superUser.username} should handle an invalid custom sort`, async () => { + const found = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } }, + sort: [ + { + asdf: 'invalid', + }, + ], + index: SECURITY_SOLUTION_ALERT_INDEX, + }); + expect(found.statusCode).to.eql(404); + }); + + it(`${superUser.username} should allow a custom sort (using search_after) and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => { + const firstSearch = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } }, + sort: [ + { + '@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that. + }, + ], + index: SECURITY_SOLUTION_ALERT_INDEX, + }); + + // grab second to last event cursor + const hits = firstSearch.body.hits.hits; + const cursor = hits[hits.length - 2].sort[0]; + + const secondSearch = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } }, + sort: [ + { + '@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that. + }, + ], + search_after: [cursor], + index: SECURITY_SOLUTION_ALERT_INDEX, + }); + + expect(secondSearch.body.hits.hits.length).equal(1); + + // there should only be one result, as we are searching after the second to last record of the first search + expect(secondSearch.body.hits.hits[0].sort).to.be.below(cursor); // below since we are paging backwards in time + }); + it(`${superUser.username} should allow cardinality aggs in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => { const found = await supertestWithoutAuth .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`) diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 4c6f4fce0b5b6..bff67c0e3d5bc 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -50,9 +50,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { const expected = mapValues( uiCapabilities.value!.catalogue, (enabled, catalogueId) => - catalogueId !== 'monitoring' && - catalogueId !== 'osquery' && - !esFeatureExceptions.includes(catalogueId) + catalogueId !== 'monitoring' && !esFeatureExceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; @@ -69,7 +67,6 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'appSearch', 'workplaceSearch', 'spaces', - 'osquery', ...esFeatureExceptions, ]; const expected = mapValues( @@ -94,7 +91,6 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'appSearch', 'workplaceSearch', 'spaces', - 'osquery', ...esFeatureExceptions, ]; const expected = mapValues( diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index bd990e7434fc8..74f1150965c5e 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -41,9 +41,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { case 'dual_privileges_all at everything_space': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); - expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('monitoring', 'osquery') - ); + expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.except('monitoring')); break; case 'everything_space_all at everything_space': case 'global_read at everything_space': @@ -57,8 +55,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { 'enterpriseSearch', 'enterpriseSearchContent', 'appSearch', - 'workplaceSearch', - 'osquery' + 'workplaceSearch' ) ); break; diff --git a/yarn.lock b/yarn.lock index 19882f1516155..ef13d76303550 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1396,29 +1396,29 @@ dependencies: tslib "^2.0.0" -"@elastic/apm-rum-core@^5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.15.0.tgz#f1067078080be1b7167c72ae6d155b0ed5cdf6c6" - integrity sha512-T5/1hZPskmU6N3Xo2CRNi5tX2ht8R5nLmh5t0I1v8RxkwbQms47AR1f0ZVvXN7W2FCDPadyQXC3f9do3k5A6OA== +"@elastic/apm-rum-core@^5.16.0": + version "5.16.0" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.16.0.tgz#c3f6aaaee005717f10578877a3d1c7e6894eb63f" + integrity sha512-5aCwlKdmitM5Jk8wR7WcCtJzejIlSaUUHOGWANvq79GDtcCIjE/yD44pft8UAYQpiI28WXCLAFvJvIQiUzl/nw== dependencies: error-stack-parser "^1.3.5" opentracing "^0.14.3" promise-polyfill "^8.1.3" -"@elastic/apm-rum-react@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum-react/-/apm-rum-react-1.4.0.tgz#f36453e54d2ebdedb0d6d0c4f6cc76e16304b118" - integrity sha512-YIBuEJN6fkiB1M/o84PF4lQheAjrd3PQCm6t8pP4dKuWN1cWZnSsojnuGacx2bJn1kWWZxVDQ7wTjPJutkIy2A== +"@elastic/apm-rum-react@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum-react/-/apm-rum-react-1.4.1.tgz#d46913f1c3aa7f5e54d1898b644ffd7e0b75129a" + integrity sha512-bRyqVxe9QY40imv5u0p7q4WaXUDMs2gHewPuADC2LGiX8piNfpRXA7jj3KPD4P/045dlDsmvVjV0AELLyNipuQ== dependencies: - "@elastic/apm-rum" "^5.11.0" + "@elastic/apm-rum" "^5.11.1" hoist-non-react-statics "^3.3.0" -"@elastic/apm-rum@^5.11.0": - version "5.11.0" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum/-/apm-rum-5.11.0.tgz#97dbc426d0ec27b46e78a649f73d9e4a198ae258" - integrity sha512-+98NLG4NDa7o1DCtkhXeGmKW5riDPHSpgy2UxzLK4j02ZPBOccOUjIw5F8yZAUsrPUpQmk39x13IJl0mFyzjyA== +"@elastic/apm-rum@^5.11.1": + version "5.11.1" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum/-/apm-rum-5.11.1.tgz#ecbef3935ded2e9da338e6a749ab03b26e40c222" + integrity sha512-d6IxNvCxufb6JvVEH6TI7stwabJSIIBJsZVnRNaromZAgVOc5woI8mB35AH5glEPUb95KuL9CkCObHlqGpAt5w== dependencies: - "@elastic/apm-rum-core" "^5.15.0" + "@elastic/apm-rum-core" "^5.16.0" "@elastic/apm-synthtrace@link:bazel-bin/packages/elastic-apm-synthtrace": version "0.0.0" @@ -1499,10 +1499,6 @@ semver "^7.3.2" topojson-client "^3.1.0" -"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana": - version "0.0.0" - uid "" - "@elastic/eslint-plugin-eui@0.0.2": version "0.0.2" resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" @@ -3017,6 +3013,10 @@ version "0.0.0" uid "" +"@kbn/eslint-config@link:bazel-bin/packages/kbn-eslint-config": + version "0.0.0" + uid "" + "@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint": version "0.0.0" uid ""