diff --git a/.buildkite/pipelines/code_coverage/daily.yml b/.buildkite/pipelines/code_coverage/daily.yml index a2b501a39b088..c9bb675ab40cf 100644 --- a/.buildkite/pipelines/code_coverage/daily.yml +++ b/.buildkite/pipelines/code_coverage/daily.yml @@ -13,7 +13,8 @@ steps: queue: kibana-default env: FTR_CONFIGS_DEPS: '' - LIMIT_CONFIG_TYPE: 'unit,functional,integration' +# LIMIT_CONFIG_TYPE: 'unit,functional,integration' + LIMIT_CONFIG_TYPE: 'unit,integration' JEST_UNIT_SCRIPT: '.buildkite/scripts/steps/code_coverage/jest.sh' JEST_INTEGRATION_SCRIPT: '.buildkite/scripts/steps/code_coverage/jest_integration.sh' FTR_CONFIGS_SCRIPT: '.buildkite/scripts/steps/code_coverage/ftr_configs.sh' @@ -25,6 +26,6 @@ steps: depends_on: - jest - jest-integration - - ftr-configs +# - ftr-configs timeout_in_minutes: 30 key: ingest diff --git a/.buildkite/scripts/steps/code_coverage/ingest.sh b/.buildkite/scripts/steps/code_coverage/ingest.sh index a2f1b572252a5..a39097f706262 100755 --- a/.buildkite/scripts/steps/code_coverage/ingest.sh +++ b/.buildkite/scripts/steps/code_coverage/ingest.sh @@ -27,7 +27,7 @@ echo "--- Upload new git sha" echo "--- Download coverage artifacts" buildkite-agent artifact download target/kibana-coverage/jest/* . -buildkite-agent artifact download target/kibana-coverage/functional/* . +#buildkite-agent artifact download target/kibana-coverage/functional/* . buildkite-agent artifact download target/ran_files/* . ls -l target/ran_files/* || echo "### No ran-files found" @@ -42,20 +42,20 @@ echo "--- Jest: Reset file paths prefix, merge coverage files, and generate the replacePaths "$KIBANA_DIR/target/kibana-coverage/jest" "CC_REPLACEMENT_ANCHOR" "$KIBANA_DIR" yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.jest.config.js -echo "--- Functional: Reset file paths prefix, merge coverage files, and generate the final combined report" +#echo "--- Functional: Reset file paths prefix, merge coverage files, and generate the final combined report" # Functional: Reset file paths prefix to Kibana Dir of final worker -set +e -sed -ie "s|CC_REPLACEMENT_ANCHOR|${KIBANA_DIR}|g" target/kibana-coverage/functional/*.json -echo "--- Begin Split and Merge for Functional" -splitCoverage target/kibana-coverage/functional -splitMerge -set -e +#set +e +#sed -ie "s|CC_REPLACEMENT_ANCHOR|${KIBANA_DIR}|g" target/kibana-coverage/functional/*.json +#echo "--- Begin Split and Merge for Functional" +#splitCoverage target/kibana-coverage/functional +#splitMerge +#set -e echo "--- Archive and upload combined reports" collectAndUpload target/kibana-coverage/jest/kibana-jest-coverage.tar.gz \ target/kibana-coverage/jest-combined -collectAndUpload target/kibana-coverage/functional/kibana-functional-coverage.tar.gz \ - target/kibana-coverage/functional-combined +#collectAndUpload target/kibana-coverage/functional/kibana-functional-coverage.tar.gz \ +# target/kibana-coverage/functional-combined echo "--- Upload coverage static site" .buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh diff --git a/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh b/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh index c1df5ff4b39cf..de006352d0b09 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh @@ -38,14 +38,20 @@ echo "### Generate Team Assignments" CI_STATS_DISABLED=true node scripts/generate_team_assignments.js \ --verbose --src '.github/CODEOWNERS' --dest $TEAM_ASSIGN_PATH -for x in functional jest; do - echo "### Ingesting coverage for ${x}" - COVERAGE_SUMMARY_FILE="target/kibana-coverage/${x}-combined/coverage-summary.json" - - CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ - --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH & -done -wait +#for x in functional jest; do +# echo "### Ingesting coverage for ${x}" +# COVERAGE_SUMMARY_FILE="target/kibana-coverage/${x}-combined/coverage-summary.json" +# +# CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ +# --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH & +#done +#wait + +echo "### Ingesting coverage for JEST" +COVERAGE_SUMMARY_FILE="target/kibana-coverage/jest-combined/coverage-summary.json" + +CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ + --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH echo "--- Ingesting Code Coverage - Complete" echo "" diff --git a/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh b/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh index f982ee5c581ce..0b6d0ce8ea105 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh @@ -4,7 +4,6 @@ set -euo pipefail cat << EOF > src/dev/code_coverage/www/index_partial_2.html Latest Jest - Latest FTR @@ -26,4 +25,4 @@ cat << EOF > src/dev/code_coverage/www/index_partial_2.html EOF cat src/dev/code_coverage/www/index_partial.html > src/dev/code_coverage/www/index.html -cat src/dev/code_coverage/www/index_partial_2.html >> src/dev/code_coverage/www/index.html \ No newline at end of file +cat src/dev/code_coverage/www/index_partial_2.html >> src/dev/code_coverage/www/index.html diff --git a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh index 4d3fb92cf2b43..dcb0b03b16d7c 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh @@ -11,8 +11,10 @@ for x in 'src/dev/code_coverage/www/index.html' 'src/dev/code_coverage/www/404.h gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefix} done -gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +#gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +# +#for x in 'target/kibana-coverage/functional-combined' 'target/kibana-coverage/jest-combined'; do +# gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +#done -for x in 'target/kibana-coverage/functional-combined' 'target/kibana-coverage/jest-combined'; do - gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} -done +gsutil -m -q cp -r -a public-read -z js,css,html 'target/kibana-coverage/jest-combined' ${uploadPrefixWithTimeStamp} diff --git a/dev_docs/getting_started/troubleshooting.mdx b/dev_docs/getting_started/troubleshooting.mdx index db52830bbae4f..b224a3200eefb 100644 --- a/dev_docs/getting_started/troubleshooting.mdx +++ b/dev_docs/getting_started/troubleshooting.mdx @@ -40,3 +40,12 @@ it means you are using a new Elasticsearch feature that will not work in a CCS e We added this test coverage in version `8.1` because we accidentally broke core Kibana features (for example, when Discover started using the new fields parameter) for our CCS users. CCS is not a corner case and (excluding certain experimental features) Kibana should always work for our CCS users. This setting is our way of ensuring test coverage. Please reach out to the [Kibana Operations team](https://github.com/orgs/elastic/teams/kibana-operations) if you have further questions. + +### Minified React errors + +If you experience minified React errors and want to expand them to their full error messages you will currently need to rebuild the `@kbn/ui-shared-deps-npm` package using "development" mode instead of "production", which can be done by modifying the corresponding line in `packages/kbn-ui-shared-deps-npm/webpack.config.js` and make sure to run `yarn kbn bootstrap` afterwards: + +```diff +- mode: 'production', ++ mode: 'development', +``` diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index d5208b9debfe9..a118ef1e20a1b 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -6,7 +6,6 @@ Creates a connector. -[discrete] [[create-connector-api-request]] === {api-request-title} @@ -14,14 +13,12 @@ Creates a connector. `POST :/s//api/actions/connector` -[discrete] === {api-prereq-title} You must have `all` privileges for the *Actions and Connectors* feature in the *Management* section of the <>. -[discrete] [[create-connector-api-path-params]] === {api-path-parms-title} @@ -29,34 +26,80 @@ You must have `all` privileges for the *Actions and Connectors* feature in the (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[discrete] +[role="child_attributes"] [[create-connector-api-request-body]] === {api-request-body-title} -`name`:: - (Required, string) The display name for the connector. +`config`:: +(Required, object) The configuration for the connector. Configuration properties +vary depending on the connector type. For example: ++ +.Index connectors +[%collapsible%open] +==== +`executionTimeField`:: +(Optional, string) Specifies a field that will contain the time the alert +condition was detected. The default value is `null`. + +`index`:: +(Required, string) The {es} index to be written to. + +`refresh`:: +(Optional, boolean) The {ref}/docs-refresh.html[refresh] policy for the write +request. The default value is `false`. + +For more information, refer to +{kibana-ref}/index-action-type.html[Index connector and action]. +==== ++ +.{jira} connectors +[%collapsible%open] +==== + +`apiUrl`:: +(Required, string) The {jira} instance URL. + +`projectKey`:: +(Required, string) The {jira} project key. + +For more information, refer to +{kibana-ref}/jira-action-type.html[{jira} connector and action]. +==== ++ +For more configuration properties, refer to <>. `connector_type_id`:: - (Required, string) The connector type ID for the connector. +(Required, string) The connector type ID for the connector. -`config`:: - (Required, object) The configuration for the connector. Configuration properties vary depending on - the connector type. For information about the configuration properties, refer to <>. +`name`:: +(Required, string) The display name for the connector. `secrets`:: - (Required, object) The secrets configuration for the connector. Secrets configuration properties vary - depending on the connector type. For information about the secrets configuration properties, refer to <>. +(Required, object) The secrets configuration for the connector. Secrets +configuration properties vary depending on the connector type. For information +about the secrets configuration properties, refer to <>. + +-- WARNING: Remember these values. You must provide them each time you call the <> API. +-- ++ +.{jira} connectors +[%collapsible%open] +==== +`apiToken`:: +(Required, string) The {jira} API authentication token for HTTP basic +authentication. + +`email`:: +(Required, string) The account email for HTTP Basic authentication. +==== -[discrete] [[create-connector-api-request-codes]] === {api-response-codes-title} `200`:: Indicates a successful call. -[discrete] [[create-connector-api-example]] === {api-examples-title} diff --git a/fleet_packages.json b/fleet_packages.json index 0f529f0510bfb..9fefb0aa9a2c2 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -15,7 +15,7 @@ [ { "name": "apm", - "version": "8.2.0" + "version": "8.3.0" }, { "name": "elastic_agent", diff --git a/src/plugins/discover/public/__mocks__/data_view_complex.ts b/src/plugins/discover/public/__mocks__/data_view_complex.ts new file mode 100644 index 0000000000000..ff26114ffcdaa --- /dev/null +++ b/src/plugins/discover/public/__mocks__/data_view_complex.ts @@ -0,0 +1,419 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/public'; +import { buildDataViewMock } from './index_pattern'; + +const fields = [ + { + count: 0, + name: '_id', + type: 'string', + esTypes: ['_id'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_index', + type: 'string', + esTypes: ['_index'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_score', + type: 'number', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_source', + type: '_source', + esTypes: ['_source'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_type', + type: 'string', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 2, + name: 'array_objects.description', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'array_objects.description.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'array_objects.description', + }, + }, + }, + { + count: 0, + name: 'array_objects.name', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'array_objects.name.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'array_objects.name', + }, + }, + }, + { + count: 0, + name: 'array_tags', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'array_tags.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'array_tags', + }, + }, + }, + { + count: 0, + name: 'binary_blob', + type: 'unknown', + esTypes: ['binary'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'bool_enabled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'date', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 1, + name: 'date_nanos', + type: 'date', + esTypes: ['date_nanos'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'flattened_labels', + type: 'unknown', + esTypes: ['flattened'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geo_point', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 1, + name: 'geometry', + type: 'unknown', + esTypes: ['shape'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 1, + name: 'histogram', + type: 'histogram', + esTypes: ['histogram'], + scripted: false, + searchable: false, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'ip_addr', + type: 'ip', + esTypes: ['ip'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 4, + name: 'keyword_key', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'nested_user.first', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { + nested: { + path: 'nested_user', + }, + }, + }, + { + count: 0, + name: 'nested_user.first.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'nested_user.first', + }, + nested: { + path: 'nested_user', + }, + }, + }, + { + count: 0, + name: 'nested_user.last', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { + nested: { + path: 'nested_user', + }, + }, + }, + { + count: 0, + name: 'nested_user.last.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'nested_user.last', + }, + nested: { + path: 'nested_user', + }, + }, + }, + { + count: 3, + name: 'number_amount', + type: 'number', + esTypes: ['long'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 3, + name: 'number_price', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'object_user.first', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'object_user.last', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'range_time_frame', + type: 'date_range', + esTypes: ['date_range'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 1, + name: 'rank_features', + type: 'unknown', + esTypes: ['rank_features'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 5, + name: 'text_message', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'vector', + type: 'unknown', + esTypes: ['dense_vector'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'version', + type: 'string', + esTypes: ['version'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 1, + script: 'return "hi there"', + lang: 'painless', + name: 'scripted_string', + type: 'string', + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: 'runtime_number', + type: 'number', + esTypes: ['double'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, +] as DataView['fields']; + +export const dataViewComplexMock = buildDataViewMock({ + name: 'data-view-with-various-field-types', + fields, + timeFieldName: 'data', +}); diff --git a/src/plugins/discover/public/__mocks__/es_hits_complex.ts b/src/plugins/discover/public/__mocks__/es_hits_complex.ts new file mode 100644 index 0000000000000..ecbc93a04cf22 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/es_hits_complex.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 const esHitsComplex = [ + { + _index: 'sample', + _id: '1', + _version: 2, + _score: null, + fields: { + date: ['2022-05-22T12:10:30.000Z'], + 'array_objects.description.keyword': ['programming list', 'cool stuff list'], + rank_features: [ + { + '2star': 100, + '1star': 10, + }, + ], + array_tags: ['elasticsearch', 'wow'], + 'array_objects.name.keyword': ['prog_list', 'cool_list'], + flattened_labels: [ + { + release: ['v1.2.5', 'v1.3.0'], + priority: 'urgent', + }, + ], + geo_point: [ + { + coordinates: [-71.34, 41.12], + type: 'Point', + }, + ], + binary_blob: ['U29tZSBiaW5hcnkgYmxvYg=='], + text_message: ['Hi there! I am a sample string.'], + 'object_user.first': ['John'], + keyword_key: ['abcd1'], + 'array_objects.name': ['prog_list', 'cool_list'], + vector: [0.5, 10, 6], + nested_user: [ + { + last: ['Smith'], + 'last.keyword': ['Smith'], + first: ['John'], + 'first.keyword': ['John'], + }, + { + last: ['White'], + 'last.keyword': ['White'], + first: ['Alice'], + 'first.keyword': ['Alice'], + }, + ], + number_amount: [50], + 'array_tags.keyword': ['elasticsearch', 'wow'], + bool_enabled: [false], + version: ['1.2.3'], + histogram: [ + { + counts: [3, 7, 23, 12, 6], + values: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + ], + 'array_objects.description': ['programming list', 'cool stuff list'], + range_time_frame: [ + { + gte: '2015-10-31 12:00:00', + lte: '2015-11-01 00:00:00', + }, + ], + number_price: [10.99], + 'object_user.last': ['Smith'], + geometry: [ + { + coordinates: [ + [ + [1000, -1001], + [1001, -1001], + [1001, -1000], + [1000, -1000], + [1000, -1001], + ], + ], + type: 'Polygon', + }, + ], + date_nanos: ['2022-01-01T12:10:30.123456789Z'], + ip_addr: ['192.168.1.1'], + runtime_number: [5.5], + scripted_string: ['hi there'], + }, + sort: [1653221430000], + }, + { + _index: 'sample', + _id: '2', + _version: 8, + _score: null, + fields: { + date: ['2022-05-20T00:00:00.000Z'], + 'array_objects.description.keyword': ['elastic list'], + rank_features: [ + { + '2star': 350, + '1star': 20, + }, + ], + array_tags: ['=1+2\'" ;,=1+2'], + 'array_objects.name.keyword': ['elastic_list'], + flattened_labels: [ + { + release: ['v1.4.5'], + priority: 'minor', + }, + ], + geo_point: [ + { + coordinates: [-71.34, 41.12], + type: 'Point', + }, + ], + binary_blob: ['U29tZSBiaW5hcnkgYmxvYg=='], + text_message: ["I'm multiline\n*&%$#@"], + 'object_user.first': ['Jane'], + keyword_key: ['=1+2";=1+2'], + 'array_objects.name': ['elastic_list'], + vector: [0.5, 12, 6], + nested_user: [ + { + last: ['Smith'], + 'last.keyword': ['Smith'], + first: ['Jane'], + 'first.keyword': ['Jane'], + }, + ], + number_amount: [10], + 'array_tags.keyword': ['=1+2\'" ;,=1+2'], + bool_enabled: [true], + version: ['1.3.3'], + histogram: [ + { + counts: [8, 17, 8, 7, 6, 2], + values: [0.1, 0.25, 0.35, 0.4, 0.45, 0.5], + }, + ], + 'array_objects.description': ['elastic list'], + range_time_frame: [ + { + gte: '2015-10-31 12:00:00', + lte: '2016-11-01 00:00:00', + }, + ], + number_price: [105.99], + 'object_user.last': ['Smith'], + geometry: [ + { + geometries: [ + { + coordinates: [1000, 100], + type: 'Point', + }, + { + coordinates: [ + [1001, 100], + [1002, 100], + ], + type: 'LineString', + }, + ], + type: 'GeometryCollection', + }, + ], + date_nanos: ['2022-01-02T11:10:30.123456789Z'], + ip_addr: ['192.168.1.0'], + runtime_number: [5.5], + scripted_string: ['=1+2";=1+2'], + }, + sort: [1653004800000], + }, +]; diff --git a/src/plugins/discover/public/__mocks__/grid_context.ts b/src/plugins/discover/public/__mocks__/grid_context.ts new file mode 100644 index 0000000000000..3f760bc4a4258 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/grid_context.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { flattenHit } from '@kbn/data-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { indexPatternMock } from './index_pattern'; +import { dataViewComplexMock } from './data_view_complex'; +import { esHits } from './es_hits'; +import { esHitsComplex } from './es_hits_complex'; +import { discoverServiceMock } from './services'; +import { GridContext } from '../components/discover_grid/discover_grid_context'; +import { convertValueToString } from '../utils/convert_value_to_string'; +import type { ElasticSearchHit } from '../types'; + +const buildGridContext = (dataView: DataView, rows: ElasticSearchHit[]): GridContext => { + const rowsFlattened = rows.map((hit) => + flattenHit(hit, dataView, { includeIgnoredValues: true }) + ); + + return { + expanded: undefined, + setExpanded: jest.fn(), + rows, + rowsFlattened, + onFilter: jest.fn(), + indexPattern: dataView, + isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), + valueToStringConverter: (rowIndex, columnId, options) => + convertValueToString({ + rowIndex, + columnId, + services: discoverServiceMock, + rows, + rowsFlattened, + dataView, + options, + }), + }; +}; + +export const discoverGridContextMock = buildGridContext(indexPatternMock, esHits); + +export const discoverGridContextComplexMock = buildGridContext(dataViewComplexMock, esHitsComplex); diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts index 839acfcf3ea15..93b0727cfa41a 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -64,27 +64,42 @@ const fields = [ }, ] as DataView['fields']; -fields.getByName = (name: string) => { - return fields.find((field) => field.name === name); -}; +export const buildDataViewMock = ({ + name, + fields: definedFields, + timeFieldName, +}: { + name: string; + fields: DataView['fields']; + timeFieldName?: string; +}): DataView => { + const dataViewFields = [...definedFields] as DataView['fields']; -fields.getAll = () => { - return fields; -}; + dataViewFields.getByName = (fieldName: string) => { + return dataViewFields.find((field) => field.name === fieldName); + }; + + dataViewFields.getAll = () => { + return dataViewFields; + }; -const indexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - metaFields: ['_index', '_score'], - fields, - getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }), - getSourceFiltering: () => ({}), - getFieldByName: jest.fn(() => ({})), - timeFieldName: '', - docvalueFields: [], - getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), -} as unknown as DataView; + const dataView = { + id: `${name}-id`, + title: `${name}-title`, + metaFields: ['_index', '_score'], + fields: dataViewFields, + getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }), + getSourceFiltering: () => ({}), + getFieldByName: jest.fn((fieldName: string) => dataViewFields.getByName(fieldName)), + timeFieldName: timeFieldName || '', + docvalueFields: [], + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + isTimeNanosBased: () => false, + } as unknown as DataView; -indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; + dataView.isTimeBased = () => !!timeFieldName; + + return dataView; +}; -export const indexPatternMock = indexPattern; +export const indexPatternMock = buildDataViewMock({ name: 'the-index-pattern', fields }); diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index 89a53e474b5b3..21e70bcd99e70 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -7,6 +7,7 @@ */ import { DataView } from '@kbn/data-views-plugin/public'; +import { buildDataViewMock } from './index_pattern'; const fields = [ { @@ -51,27 +52,8 @@ const fields = [ }, ] as DataView['fields']; -fields.getByName = (name: string) => { - return fields.find((field) => field.name === name); -}; -fields.getAll = () => { - return fields; -}; - -const indexPattern = { - id: 'index-pattern-with-timefield-id', - title: 'index-pattern-with-timefield', - metaFields: ['_index', '_score'], +export const indexPatternWithTimefieldMock = buildDataViewMock({ + name: 'index-pattern-with-timefield', fields, - getComputedFields: () => ({}), - getSourceFiltering: () => ({}), - getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', - getFormatterForField: () => ({ convert: (value: unknown) => value }), - isTimeNanosBased: () => false, - popularizeField: () => {}, -} as unknown as DataView; - -indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; - -export const indexPatternWithTimefieldMock = indexPattern; +}); diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 81938c8a9ca06..f1865049947b7 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -95,4 +95,8 @@ export const discoverServiceMock = { }, storage: new LocalStorageMock({}) as unknown as Storage, addBasePath: jest.fn(), + toastNotifications: { + addInfo: jest.fn(), + addWarning: jest.fn(), + }, } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/components/discover_grid/build_copy_column_button.test.tsx b/src/plugins/discover/public/components/discover_grid/build_copy_column_button.test.tsx new file mode 100644 index 0000000000000..3d3dcab8a2f96 --- /dev/null +++ b/src/plugins/discover/public/components/discover_grid/build_copy_column_button.test.tsx @@ -0,0 +1,117 @@ +/* + * 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 from 'react'; +import { EuiButton } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { discoverGridContextMock } from '../../__mocks__/grid_context'; +import { buildCopyColumnNameButton, buildCopyColumnValuesButton } from './build_copy_column_button'; + +const execCommandMock = (global.document.execCommand = jest.fn()); +const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('Build a column button to copy to clipboard', () => { + it('should copy a column name to clipboard on click', () => { + const { label, iconType, onClick } = buildCopyColumnNameButton({ + columnId: 'test-field-name', + services: discoverServiceMock, + }); + execCommandMock.mockImplementationOnce(() => true); + + const wrapper = mountWithIntl( + + {label} + + ); + + wrapper.find(EuiButton).simulate('click'); + + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should copy column values to clipboard on click', async () => { + const originalClipboard = global.window.navigator.clipboard; + + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn(), + }, + writable: true, + }); + + const { label, iconType, onClick } = buildCopyColumnValuesButton({ + columnId: 'extension', + services: discoverServiceMock, + rowsCount: 3, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + }); + + const wrapper = mountWithIntl( + + {label} + + ); + + await wrapper.find(EuiButton).simulate('click'); + + // first row out of 3 rows does not have a value + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('extension\n\njpg\ngif'); + + const { + label: labelSource, + iconType: iconTypeSource, + onClick: onClickSource, + } = buildCopyColumnValuesButton({ + columnId: '_source', + services: discoverServiceMock, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + rowsCount: 3, + }); + + const wrapperSource = mountWithIntl( + + {labelSource} + + ); + + await wrapperSource.find(EuiButton).simulate('click'); + + // first row out of 3 rows does not have a value + expect(navigator.clipboard.writeText).toHaveBeenNthCalledWith( + 2, + '"_source"\n{"bytes":20,"date":"2020-20-01T12:12:12.123","message":"test1","_index":"i","_score":1}\n' + + '{"date":"2020-20-01T12:12:12.124","extension":"jpg","name":"test2","_index":"i","_score":1}\n' + + '{"bytes":50,"date":"2020-20-01T12:12:12.124","extension":"gif","name":"test3","_index":"i","_score":1}' + ); + + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + }); + }); + + it('should not copy to clipboard on click', () => { + const { label, iconType, onClick } = buildCopyColumnNameButton({ + columnId: 'test-field-name', + services: discoverServiceMock, + }); + execCommandMock.mockImplementationOnce(() => false); + + const wrapper = mountWithIntl( + + {label} + + ); + + wrapper.find(EuiButton).simulate('click'); + + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(warn).toHaveBeenCalledWith('Unable to copy to clipboard.'); + }); +}); diff --git a/src/plugins/discover/public/components/discover_grid/build_copy_column_button.tsx b/src/plugins/discover/public/components/discover_grid/build_copy_column_button.tsx new file mode 100644 index 0000000000000..60ae5182965c1 --- /dev/null +++ b/src/plugins/discover/public/components/discover_grid/build_copy_column_button.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 from 'react'; +import { EuiListGroupItemProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + copyColumnValuesToClipboard, + copyColumnNameToClipboard, +} from '../../utils/copy_value_to_clipboard'; +import { DiscoverServices } from '../../build_services'; +import type { ValueToStringConverter } from '../../types'; + +function buildCopyColumnButton({ + label, + onCopy, + dataTestSubj, +}: { + label: EuiListGroupItemProps['label']; + onCopy: () => unknown; + dataTestSubj: string; +}) { + const copyToClipBoardButton: EuiListGroupItemProps = { + size: 'xs', + label, + iconType: 'copyClipboard', + iconProps: { size: 'm' }, + onClick: onCopy, + 'data-test-subj': dataTestSubj, + }; + + return copyToClipBoardButton; +} + +export function buildCopyColumnNameButton({ + columnId, + services, +}: { + columnId: string; + services: DiscoverServices; +}): EuiListGroupItemProps { + return buildCopyColumnButton({ + label: ( + + ), + onCopy: () => copyColumnNameToClipboard({ columnId, services }), + dataTestSubj: 'gridCopyColumnNameToClipBoardButton', + }); +} + +export function buildCopyColumnValuesButton({ + columnId, + services, + rowsCount, + valueToStringConverter, +}: { + columnId: string; + services: DiscoverServices; + rowsCount: number; + valueToStringConverter: ValueToStringConverter; +}): EuiListGroupItemProps { + return buildCopyColumnButton({ + label: ( + + ), + onCopy: () => + copyColumnValuesToClipboard({ + columnId, + services, + rowsCount, + valueToStringConverter, + }), + dataTestSubj: 'gridCopyColumnValuesToClipBoardButton', + }); +} diff --git a/src/plugins/discover/public/components/discover_grid/copy_column_name_button.test.tsx b/src/plugins/discover/public/components/discover_grid/copy_column_name_button.test.tsx deleted file mode 100644 index 66bfefe32f6a0..0000000000000 --- a/src/plugins/discover/public/components/discover_grid/copy_column_name_button.test.tsx +++ /dev/null @@ -1,49 +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 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 from 'react'; -import { buildCopyColumnNameButton } from './copy_column_name_button'; -import { EuiButton } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; - -const execCommandMock = (global.document.execCommand = jest.fn()); -const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - -describe('Copy to clipboard button', () => { - it('should copy to clipboard on click', () => { - const { label, iconType, onClick } = buildCopyColumnNameButton('test-field-name'); - execCommandMock.mockImplementationOnce(() => true); - - const wrapper = mountWithIntl( - - {label} - - ); - - wrapper.find(EuiButton).simulate('click'); - - expect(execCommandMock).toHaveBeenCalledWith('copy'); - expect(warn).not.toHaveBeenCalled(); - }); - - it('should not copy to clipboard on click', () => { - const { label, iconType, onClick } = buildCopyColumnNameButton('test-field-name'); - execCommandMock.mockImplementationOnce(() => false); - - const wrapper = mountWithIntl( - - {label} - - ); - - wrapper.find(EuiButton).simulate('click'); - - expect(execCommandMock).toHaveBeenCalledWith('copy'); - expect(warn).toHaveBeenCalledWith('Unable to copy to clipboard.'); - }); -}); diff --git a/src/plugins/discover/public/components/discover_grid/copy_column_name_button.tsx b/src/plugins/discover/public/components/discover_grid/copy_column_name_button.tsx deleted file mode 100644 index e3f972ff3df26..0000000000000 --- a/src/plugins/discover/public/components/discover_grid/copy_column_name_button.tsx +++ /dev/null @@ -1,28 +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 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 from 'react'; -import { copyToClipboard, EuiListGroupItemProps } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -export function buildCopyColumnNameButton(columnName: string) { - const copyToClipBoardButton: EuiListGroupItemProps = { - size: 'xs', - label: ( - - ), - iconType: 'copyClipboard', - iconProps: { size: 'm' }, - onClick: () => copyToClipboard(columnName), - }; - - return copyToClipBoardButton; -} diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index 64cbab5c1511b..eafc150843ef7 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -48,9 +48,10 @@ import { import { DiscoverGridDocumentToolbarBtn, getDocId } from './discover_grid_document_selection'; import { SortPairArr } from '../doc_table/lib/get_sort'; import { getFieldsToShow } from '../../utils/get_fields_to_show'; -import { ElasticSearchHit } from '../../types'; +import type { ElasticSearchHit, ValueToStringConverter } from '../../types'; import { useRowHeightsOptions } from '../../utils/use_row_heights_options'; import { useDiscoverServices } from '../../utils/use_discover_services'; +import { convertValueToString } from '../../utils/convert_value_to_string'; interface SortObj { id: string; @@ -237,6 +238,21 @@ export const DiscoverGrid = ({ }); }, [displayedRows, indexPattern]); + const valueToStringConverter: ValueToStringConverter = useCallback( + (rowIndex, columnId, options) => { + return convertValueToString({ + rowIndex, + rows: displayedRows, + rowsFlattened: displayedRowsFlattened, + dataView: indexPattern, + columnId, + services, + options, + }); + }, + [displayedRows, displayedRowsFlattened, indexPattern, services] + ); + /** * Pagination */ @@ -319,15 +335,28 @@ export const DiscoverGrid = ({ const euiGridColumns = useMemo( () => - getEuiGridColumns( - displayedColumns, + getEuiGridColumns({ + columns: displayedColumns, + rowsCount: displayedRows.length, settings, indexPattern, showTimeCol, defaultColumns, - isSortEnabled - ), - [displayedColumns, indexPattern, showTimeCol, settings, defaultColumns, isSortEnabled] + isSortEnabled, + services, + valueToStringConverter, + }), + [ + displayedColumns, + displayedRows, + indexPattern, + showTimeCol, + settings, + defaultColumns, + isSortEnabled, + services, + valueToStringConverter, + ] ); const hideTimeColumn = useMemo( @@ -452,6 +481,7 @@ export const DiscoverGrid = ({ setIsFilterActive(false); } }, + valueToStringConverter, }} > true); jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { @@ -33,22 +33,8 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { FilterInBtn, FilterOutBtn, buildCellActions, CopyBtn } from './discover_grid_cell_actions'; import { DiscoverGridContext } from './discover_grid_context'; import { EuiButton } from '@elastic/eui'; -import { indexPatternMock } from '../../__mocks__/index_pattern'; -import { esHits } from '../../__mocks__/es_hits'; +import { discoverGridContextMock } from '../../__mocks__/grid_context'; import { DataViewField } from '@kbn/data-views-plugin/public'; -import { flattenHit } from '@kbn/data-plugin/common'; - -const contextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - rowsFlattened: esHits.map((hit) => flattenHit(hit, indexPatternMock)), - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), -}; describe('Discover cell actions ', function () { it('should not show cell actions for unfilterable fields', async () => { @@ -57,7 +43,7 @@ describe('Discover cell actions ', function () { it('triggers filter function when FilterInBtn is clicked', async () => { const component = mountWithIntl( - + } @@ -70,15 +56,15 @@ describe('Discover cell actions ', function () { ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(contextMock.onFilter).toHaveBeenCalledWith( - indexPatternMock.fields.getByName('extension'), + expect(discoverGridContextMock.onFilter).toHaveBeenCalledWith( + discoverGridContextMock.indexPattern.fields.getByName('extension'), 'jpg', '+' ); }); it('triggers filter function when FilterOutBtn is clicked', async () => { const component = mountWithIntl( - + } @@ -91,15 +77,15 @@ describe('Discover cell actions ', function () { ); const button = findTestSubject(component, 'filterOutButton'); await button.simulate('click'); - expect(contextMock.onFilter).toHaveBeenCalledWith( - indexPatternMock.fields.getByName('extension'), + expect(discoverGridContextMock.onFilter).toHaveBeenCalledWith( + discoverGridContextMock.indexPattern.fields.getByName('extension'), 'jpg', '-' ); }); it('triggers clipboard copy when CopyBtn is clicked', async () => { const component = mountWithIntl( - + } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx index df07478dae5c6..8065474e49cb5 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx @@ -7,12 +7,12 @@ */ import React, { useContext } from 'react'; -import { copyToClipboard, EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataViewField } from '@kbn/data-views-plugin/public'; import { DiscoverGridContext, GridContext } from './discover_grid_context'; import { useDiscoverServices } from '../../utils/use_discover_services'; -import { formatFieldValue } from '../../utils/format_value'; +import { copyValueToClipboard } from '../../utils/copy_value_to_clipboard'; function onFilterCell( context: GridContext, @@ -86,8 +86,8 @@ export const FilterOutBtn = ({ }; export const CopyBtn = ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => { - const { indexPattern: dataView, rowsFlattened, rows } = useContext(DiscoverGridContext); - const { fieldFormats, toastNotifications } = useDiscoverServices(); + const { valueToStringConverter } = useContext(DiscoverGridContext); + const services = useDiscoverServices(); const buttonTitle = i18n.translate('discover.grid.copyClipboardButtonTitle', { defaultMessage: 'Copy value of {column}', @@ -97,22 +97,11 @@ export const CopyBtn = ({ Component, rowIndex, columnId }: EuiDataGridColumnCell return ( { - const rowFlattened = rowsFlattened[rowIndex]; - const field = dataView.fields.getByName(columnId); - const value = rowFlattened[columnId]; - - const valueFormatted = - field?.type === '_source' - ? JSON.stringify(rowFlattened, null, 2) - : formatFieldValue(value, rows[rowIndex], fieldFormats, dataView, field, 'text'); - copyToClipboard(valueFormatted); - const infoTitle = i18n.translate('discover.grid.copyClipboardToastTitle', { - defaultMessage: 'Copied value of {column} to clipboard.', - values: { column: columnId }, - }); - - toastNotifications.addInfo({ - title: infoTitle, + copyValueToClipboard({ + rowIndex, + columnId, + services, + valueToStringConverter, }); }} iconType="copyClipboard" diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx index c98db31a97f7f..bdefa126f07d8 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx @@ -9,30 +9,50 @@ import { indexPatternMock } from '../../__mocks__/index_pattern'; import { getEuiGridColumns } from './discover_grid_columns'; import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_with_timefield'; +import { discoverGridContextMock } from '../../__mocks__/grid_context'; +import { discoverServiceMock } from '../../__mocks__/services'; describe('Discover grid columns', function () { it('returns eui grid columns without time column', async () => { - const actual = getEuiGridColumns( - ['extension', 'message'], - {}, - indexPatternMock, - false, - false, - true - ); + const actual = getEuiGridColumns({ + columns: ['extension', 'message'], + settings: {}, + indexPattern: indexPatternMock, + showTimeCol: false, + defaultColumns: false, + isSortEnabled: true, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + rowsCount: 100, + services: discoverServiceMock, + }); expect(actual).toMatchInlineSnapshot(` Array [ Object { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , "onClick": [Function], @@ -46,23 +66,41 @@ describe('Discover grid columns', function () { "showMoveLeft": true, "showMoveRight": true, }, - "cellActions": undefined, - "display": undefined, + "cellActions": Array [ + [Function], + [Function], + ], + "display": "extension", "id": "extension", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, Object { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , "onClick": [Function], @@ -77,36 +115,54 @@ describe('Discover grid columns', function () { "showMoveRight": true, }, "cellActions": undefined, - "display": undefined, + "display": "message", "id": "message", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, ] `); }); it('returns eui grid columns without time column showing default columns', async () => { - const actual = getEuiGridColumns( - ['extension', 'message'], - {}, - indexPatternWithTimefieldMock, - false, - true, - true - ); + const actual = getEuiGridColumns({ + columns: ['extension', 'message'], + settings: {}, + indexPattern: indexPatternWithTimefieldMock, + showTimeCol: false, + defaultColumns: true, + isSortEnabled: true, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + rowsCount: 100, + services: discoverServiceMock, + }); expect(actual).toMatchInlineSnapshot(` Array [ Object { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , "onClick": [Function], @@ -130,13 +186,28 @@ describe('Discover grid columns', function () { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , "onClick": [Function], @@ -157,27 +228,45 @@ describe('Discover grid columns', function () { `); }); it('returns eui grid columns with time column', async () => { - const actual = getEuiGridColumns( - ['extension', 'message'], - {}, - indexPatternWithTimefieldMock, - true, - false, - true - ); + const actual = getEuiGridColumns({ + columns: ['extension', 'message'], + settings: {}, + indexPattern: indexPatternWithTimefieldMock, + showTimeCol: true, + defaultColumns: false, + isSortEnabled: true, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + rowsCount: 100, + services: discoverServiceMock, + }); expect(actual).toMatchInlineSnapshot(` Array [ Object { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , "onClick": [Function], @@ -215,13 +304,28 @@ describe('Discover grid columns', function () { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , "onClick": [Function], @@ -248,13 +352,28 @@ describe('Discover grid columns', function () { "actions": Object { "additional": Array [ Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", "iconProps": Object { "size": "m", }, "iconType": "copyClipboard", "label": , "onClick": [Function], diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx index d441c3e5aba75..1d4feefaec992 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx @@ -12,11 +12,13 @@ import { EuiDataGridColumn, EuiIconTip, EuiScreenReaderOnly } from '@elastic/eui import type { DataView } from '@kbn/data-views-plugin/public'; import { ExpandButton } from './discover_grid_expand_button'; import { DiscoverGridSettings } from './types'; +import type { ValueToStringConverter } from '../../types'; import { buildCellActions } from './discover_grid_cell_actions'; import { getSchemaByKbnType } from './discover_grid_schema'; import { SelectButton } from './discover_grid_document_selection'; import { defaultTimeColumnWidth } from './constants'; -import { buildCopyColumnNameButton } from './copy_column_name_button'; +import { buildCopyColumnNameButton, buildCopyColumnValuesButton } from './build_copy_column_button'; +import { DiscoverServices } from '../../build_services'; export function getLeadControlColumns() { return [ @@ -51,13 +53,25 @@ export function getLeadControlColumns() { ]; } -export function buildEuiGridColumn( - columnName: string, - columnWidth: number | undefined = 0, - indexPattern: DataView, - defaultColumns: boolean, - isSortEnabled: boolean -) { +export function buildEuiGridColumn({ + columnName, + columnWidth = 0, + indexPattern, + defaultColumns, + isSortEnabled, + services, + valueToStringConverter, + rowsCount, +}: { + columnName: string; + columnWidth: number | undefined; + indexPattern: DataView; + defaultColumns: boolean; + isSortEnabled: boolean; + services: DiscoverServices; + valueToStringConverter: ValueToStringConverter; + rowsCount: number; +}) { const indexPatternField = indexPattern.getFieldByName(columnName); const column: EuiDataGridColumn = { id: columnName, @@ -81,7 +95,17 @@ export function buildEuiGridColumn( }, showMoveLeft: !defaultColumns, showMoveRight: !defaultColumns, - additional: columnName === '_source' ? undefined : [buildCopyColumnNameButton(columnName)], + additional: [ + ...(columnName === '__source' + ? [] + : [buildCopyColumnNameButton({ columnId: columnName, services })]), + buildCopyColumnValuesButton({ + columnId: columnName, + services, + rowsCount, + valueToStringConverter, + }), + ], }, cellActions: indexPatternField ? buildCellActions(indexPatternField) : [], }; @@ -117,26 +141,46 @@ export function buildEuiGridColumn( return column; } -export function getEuiGridColumns( - columns: string[], - settings: DiscoverGridSettings | undefined, - indexPattern: DataView, - showTimeCol: boolean, - defaultColumns: boolean, - isSortEnabled: boolean -) { +export function getEuiGridColumns({ + columns, + rowsCount, + settings, + indexPattern, + showTimeCol, + defaultColumns, + isSortEnabled, + services, + valueToStringConverter, +}: { + columns: string[]; + rowsCount: number; + settings: DiscoverGridSettings | undefined; + indexPattern: DataView; + showTimeCol: boolean; + defaultColumns: boolean; + isSortEnabled: boolean; + services: DiscoverServices; + valueToStringConverter: ValueToStringConverter; +}) { const timeFieldName = indexPattern.timeFieldName; const getColWidth = (column: string) => settings?.columns?.[column]?.width ?? 0; + let visibleColumns = columns; if (showTimeCol && indexPattern.timeFieldName && !columns.find((col) => col === timeFieldName)) { - const usedColumns = [indexPattern.timeFieldName, ...columns]; - return usedColumns.map((column) => - buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns, isSortEnabled) - ); + visibleColumns = [indexPattern.timeFieldName, ...columns]; } - return columns.map((column) => - buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns, isSortEnabled) + return visibleColumns.map((column) => + buildEuiGridColumn({ + columnName: column, + columnWidth: getColWidth(column), + indexPattern, + defaultColumns, + isSortEnabled, + services, + valueToStringConverter, + rowsCount, + }) ); } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx index f1b21dabab86e..ca2a0ee5839cb 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx @@ -9,18 +9,19 @@ import React from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { DocViewFilterFn } from '../../services/doc_views/doc_views_types'; -import type { ElasticSearchHit } from '../../types'; +import type { ElasticSearchHit, HitsFlattened, ValueToStringConverter } from '../../types'; export interface GridContext { expanded?: ElasticSearchHit; setExpanded: (hit?: ElasticSearchHit) => void; rows: ElasticSearchHit[]; - rowsFlattened: Array>; + rowsFlattened: HitsFlattened; onFilter: DocViewFilterFn; indexPattern: DataView; isDarkMode: boolean; selectedDocs: string[]; setSelectedDocs: (selected: string[]) => void; + valueToStringConverter: ValueToStringConverter; } const defaultContext = {} as unknown as GridContext; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx index f1d8ab9fcb86d..5ca7b84789e91 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx @@ -13,22 +13,9 @@ import { getDocId, SelectButton, } from './discover_grid_document_selection'; -import { esHits } from '../../__mocks__/es_hits'; -import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { discoverGridContextMock } from '../../__mocks__/grid_context'; import { DiscoverGridContext } from './discover_grid_context'; -const baseContextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - rowsFlattened: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), -}; - describe('document selection', () => { describe('getDocId', () => { test('doc with custom routing', () => { @@ -51,7 +38,7 @@ describe('document selection', () => { describe('SelectButton', () => { test('is not checked', () => { const contextMock = { - ...baseContextMock, + ...discoverGridContextMock, }; const component = mountWithIntl( @@ -74,7 +61,7 @@ describe('document selection', () => { test('is checked', () => { const contextMock = { - ...baseContextMock, + ...discoverGridContextMock, selectedDocs: ['i::1::'], }; @@ -98,7 +85,7 @@ describe('document selection', () => { test('adding a selection', () => { const contextMock = { - ...baseContextMock, + ...discoverGridContextMock, }; const component = mountWithIntl( @@ -121,7 +108,7 @@ describe('document selection', () => { }); test('removing a selection', () => { const contextMock = { - ...baseContextMock, + ...discoverGridContextMock, selectedDocs: ['i::1::'], }; @@ -148,7 +135,7 @@ describe('document selection', () => { test('it renders a button clickable button', () => { const props = { isFilterActive: false, - rows: esHits, + rows: discoverGridContextMock.rows, selectedDocs: ['i::1::'], setIsFilterActive: jest.fn(), setSelectedDocs: jest.fn(), diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx index 903d0bc4bedcd..e95853b99b7b7 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx @@ -11,25 +11,12 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { ExpandButton } from './discover_grid_expand_button'; import { DiscoverGridContext } from './discover_grid_context'; -import { indexPatternMock } from '../../__mocks__/index_pattern'; -import { esHits } from '../../__mocks__/es_hits'; - -const baseContextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - rowsFlattened: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), -}; +import { discoverGridContextMock } from '../../__mocks__/grid_context'; describe('Discover grid view button ', function () { it('when no document is expanded, setExpanded is called with current document', async () => { const contextMock = { - ...baseContextMock, + ...discoverGridContextMock, }; const component = mountWithIntl( @@ -47,12 +34,12 @@ describe('Discover grid view button ', function () { ); const button = findTestSubject(component, 'docTableExpandToggleColumn'); await button.simulate('click'); - expect(contextMock.setExpanded).toHaveBeenCalledWith(esHits[0]); + expect(contextMock.setExpanded).toHaveBeenCalledWith(discoverGridContextMock.rows[0]); }); it('when the current document is expanded, setExpanded is called with undefined', async () => { const contextMock = { - ...baseContextMock, - expanded: esHits[0], + ...discoverGridContextMock, + expanded: discoverGridContextMock.rows[0], }; const component = mountWithIntl( @@ -74,8 +61,8 @@ describe('Discover grid view button ', function () { }); it('when another document is expanded, setExpanded is called with the current document', async () => { const contextMock = { - ...baseContextMock, - expanded: esHits[0], + ...discoverGridContextMock, + expanded: discoverGridContextMock.rows[0], }; const component = mountWithIntl( @@ -93,6 +80,6 @@ describe('Discover grid view button ', function () { ); const button = findTestSubject(component, 'docTableExpandToggleColumn'); await button.simulate('click'); - expect(contextMock.setExpanded).toHaveBeenCalledWith(esHits[1]); + expect(contextMock.setExpanded).toHaveBeenCalledWith(discoverGridContextMock.rows[1]); }); }); diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx index 4175ff1bdd7b5..8b6387a3574db 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx @@ -27,7 +27,7 @@ import { defaultMonacoEditorWidth } from './constants'; import { EsHitRecord } from '../../application/types'; import { formatFieldValue } from '../../utils/format_value'; import { formatHit } from '../../utils/format_hit'; -import { ElasticSearchHit } from '../../types'; +import { ElasticSearchHit, HitsFlattened } from '../../types'; import { useDiscoverServices } from '../../utils/use_discover_services'; import { MAX_DOC_FIELDS_DISPLAYED } from '../../../common'; @@ -37,7 +37,7 @@ export const getRenderCellValueFn = ( dataView: DataView, rows: ElasticSearchHit[] | undefined, - rowsFlattened: Array>, + rowsFlattened: HitsFlattened, useNewFieldsApi: boolean, fieldsToShow: string[], maxDocFieldsDisplayed: number, @@ -257,7 +257,6 @@ function getTopLevelObjectPairs( formatter.convert(val, 'html', { field: subField, hit: row, - indexPattern: dataView, }) ) .join(', '); diff --git a/src/plugins/discover/public/components/doc_table/lib/row_formatter.tsx b/src/plugins/discover/public/components/doc_table/lib/row_formatter.tsx index 932a952cec09f..50c1023afa211 100644 --- a/src/plugins/discover/public/components/doc_table/lib/row_formatter.tsx +++ b/src/plugins/discover/public/components/doc_table/lib/row_formatter.tsx @@ -72,7 +72,6 @@ export const formatTopLevelObject = ( formatter.convert(val, 'html', { field, hit: row, - indexPattern, }) ) .join(', '); diff --git a/src/plugins/discover/public/types.ts b/src/plugins/discover/public/types.ts index f6872e9951c37..fa8582337349d 100644 --- a/src/plugins/discover/public/types.ts +++ b/src/plugins/discover/public/types.ts @@ -9,3 +9,11 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; export type ElasticSearchHit = estypes.SearchHit; + +export type HitsFlattened = Array>; + +export type ValueToStringConverter = ( + rowIndex: number, + columnId: string, + options?: { disableMultiline?: boolean } +) => { formattedString: string; withFormula: boolean }; diff --git a/src/plugins/discover/public/utils/convert_value_to_string.test.tsx b/src/plugins/discover/public/utils/convert_value_to_string.test.tsx new file mode 100644 index 0000000000000..e77d664851a23 --- /dev/null +++ b/src/plugins/discover/public/utils/convert_value_to_string.test.tsx @@ -0,0 +1,508 @@ +/* + * 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 { discoverGridContextComplexMock, discoverGridContextMock } from '../__mocks__/grid_context'; +import { discoverServiceMock } from '../__mocks__/services'; +import { convertValueToString, convertNameToString } from './convert_value_to_string'; + +describe('convertValueToString', () => { + it('should convert a keyword value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'keyword_key', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('abcd1'); + }); + + it('should convert a text value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'text_message', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"Hi there! I am a sample string."'); + }); + + it('should convert a multiline text value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'text_message', + rowIndex: 1, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"I\'m multiline\n*&%$#@"'); + expect(result.withFormula).toBe(false); + }); + + it('should convert a number value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'number_price', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('10.99'); + }); + + it('should convert a date value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'date', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"2022-05-22T12:10:30.000Z"'); + }); + + it('should convert a date nanos value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'date_nanos', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"2022-01-01T12:10:30.123456789Z"'); + }); + + it('should convert a boolean value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'bool_enabled', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('false'); + }); + + it('should convert a binary value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'binary_blob', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"U29tZSBiaW5hcnkgYmxvYg=="'); + }); + + it('should convert an object value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'object_user.first', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('John'); + }); + + it('should convert a nested value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'nested_user', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe( + '{"last":["Smith"],"last.keyword":["Smith"],"first":["John"],"first.keyword":["John"]}, {"last":["White"],"last.keyword":["White"],"first":["Alice"],"first.keyword":["Alice"]}' + ); + }); + + it('should convert a flattened value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'flattened_labels', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('{"release":["v1.2.5","v1.3.0"],"priority":"urgent"}'); + }); + + it('should convert a range value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'range_time_frame', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe( + '{"gte":"2015-10-31 12:00:00","lte":"2015-11-01 00:00:00"}' + ); + }); + + it('should convert a rank features value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'rank_features', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('{"2star":100,"1star":10}'); + }); + + it('should convert a histogram value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'histogram', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('{"counts":[3,7,23,12,6],"values":[0.1,0.2,0.3,0.4,0.5]}'); + }); + + it('should convert a IP value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'ip_addr', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"192.168.1.1"'); + }); + + it('should convert a version value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'version', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"1.2.3"'); + }); + + it('should convert a vector value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'vector', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('0.5, 10, 6'); + }); + + it('should convert a geo point value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'geo_point', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('{"coordinates":[-71.34,41.12],"type":"Point"}'); + }); + + it('should convert a geo point object value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'geo_point', + rowIndex: 1, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('{"coordinates":[-71.34,41.12],"type":"Point"}'); + }); + + it('should convert an array value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'array_tags', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('elasticsearch, wow'); + }); + + it('should convert a shape value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'geometry', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe( + '{"coordinates":[[[1000,-1001],[1001,-1001],[1001,-1000],[1000,-1000],[1000,-1001]]],"type":"Polygon"}' + ); + }); + + it('should convert a runtime value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'runtime_number', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('5.5'); + }); + + it('should convert a scripted value to text', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'scripted_string', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"hi there"'); + }); + + it('should return an empty string and not fail', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'unknown', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe(''); + }); + + it('should return an empty string when rowIndex is out of range', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'unknown', + rowIndex: -1, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe(''); + }); + + it('should return _source value', () => { + const result = convertValueToString({ + rows: discoverGridContextMock.rows, + rowsFlattened: discoverGridContextMock.rowsFlattened, + dataView: discoverGridContextMock.indexPattern, + services: discoverServiceMock, + columnId: '_source', + rowIndex: 0, + options: { + disableMultiline: false, + }, + }); + + expect(result.formattedString).toBe( + '{\n' + + ' "bytes": 20,\n' + + ' "date": "2020-20-01T12:12:12.123",\n' + + ' "message": "test1",\n' + + ' "_index": "i",\n' + + ' "_score": 1\n' + + '}' + ); + }); + + it('should return a formatted _source value', () => { + const result = convertValueToString({ + rows: discoverGridContextMock.rows, + rowsFlattened: discoverGridContextMock.rowsFlattened, + dataView: discoverGridContextMock.indexPattern, + services: discoverServiceMock, + columnId: '_source', + rowIndex: 0, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe( + '{"bytes":20,"date":"2020-20-01T12:12:12.123","message":"test1","_index":"i","_score":1}' + ); + }); + + it('should escape formula', () => { + const result = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'array_tags', + rowIndex: 1, + options: { + disableMultiline: true, + }, + }); + + expect(result.formattedString).toBe('"\'=1+2\'"" ;,=1+2"'); + expect(result.withFormula).toBe(true); + + const result2 = convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + columnId: 'scripted_string', + rowIndex: 1, + options: { + disableMultiline: true, + }, + }); + + expect(result2.formattedString).toBe('"\'=1+2"";=1+2"'); + expect(result2.withFormula).toBe(true); + }); + + it('should return a formatted name', () => { + const result = convertNameToString('test'); + + expect(result.formattedString).toBe('test'); + expect(result.withFormula).toBe(false); + }); + + it('should return a formatted name when with a formula', () => { + const result = convertNameToString('=1+2";=1+2'); + + expect(result.formattedString).toBe('"\'=1+2"";=1+2"'); + expect(result.withFormula).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/utils/convert_value_to_string.ts b/src/plugins/discover/public/utils/convert_value_to_string.ts new file mode 100644 index 0000000000000..0f00725a69fb2 --- /dev/null +++ b/src/plugins/discover/public/utils/convert_value_to_string.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { DataView } from '@kbn/data-views-plugin/public'; +import { cellHasFormulas, createEscapeValue } from '@kbn/data-plugin/common'; +import { formatFieldValue } from './format_value'; +import { ElasticSearchHit, HitsFlattened } from '../types'; +import { DiscoverServices } from '../build_services'; + +interface ConvertedResult { + formattedString: string; + withFormula: boolean; +} + +export const convertValueToString = ({ + rowIndex, + rows, + rowsFlattened, + columnId, + dataView, + services, + options, +}: { + rowIndex: number; + rows: ElasticSearchHit[]; + rowsFlattened: HitsFlattened; + columnId: string; + dataView: DataView; + services: DiscoverServices; + options?: { + disableMultiline?: boolean; + }; +}): ConvertedResult => { + const { fieldFormats } = services; + const rowFlattened = rowsFlattened[rowIndex]; + const value = rowFlattened?.[columnId]; + const field = dataView.fields.getByName(columnId); + const valuesArray = Array.isArray(value) ? value : [value]; + const disableMultiline = options?.disableMultiline ?? false; + + if (field?.type === '_source') { + return { + formattedString: stringify(rowFlattened, disableMultiline), + withFormula: false, + }; + } + + let withFormula = false; + + const formatted = valuesArray + .map((subValue) => { + const formattedValue = formatFieldValue( + subValue, + rows[rowIndex], + fieldFormats, + dataView, + field, + 'text', + { + skipFormattingInStringifiedJSON: disableMultiline, + } + ); + + if (typeof formattedValue === 'string') { + withFormula = withFormula || cellHasFormulas(formattedValue); + return escapeFormattedValue(formattedValue); + } + + return stringify(formattedValue, disableMultiline) || ''; + }) + .join(', '); + + return { + formattedString: formatted, + withFormula, + }; +}; + +export const convertNameToString = (name: string): ConvertedResult => { + return { + formattedString: escapeFormattedValue(name), + withFormula: cellHasFormulas(name), + }; +}; + +const stringify = (val: object | string, disableMultiline: boolean) => { + // it can wrap "strings" with quotes + return disableMultiline ? JSON.stringify(val) : JSON.stringify(val, null, 2); +}; + +const escapeValueFn = createEscapeValue(true, true); + +const escapeFormattedValue = (formattedValue: string): string => { + return escapeValueFn(formattedValue); +}; diff --git a/src/plugins/discover/public/utils/copy_value_to_clipboard.test.tsx b/src/plugins/discover/public/utils/copy_value_to_clipboard.test.tsx new file mode 100644 index 0000000000000..004332f9a20cc --- /dev/null +++ b/src/plugins/discover/public/utils/copy_value_to_clipboard.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 { discoverGridContextComplexMock } from '../__mocks__/grid_context'; +import { discoverServiceMock } from '../__mocks__/services'; +import { + copyValueToClipboard, + copyColumnNameToClipboard, + copyColumnValuesToClipboard, +} from './copy_value_to_clipboard'; +import { convertValueToString } from './convert_value_to_string'; +import type { ValueToStringConverter } from '../types'; + +const execCommandMock = (global.document.execCommand = jest.fn()); +const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('copyValueToClipboard', () => { + const valueToStringConverter: ValueToStringConverter = (rowIndex, columnId, options) => + convertValueToString({ + rows: discoverGridContextComplexMock.rows, + rowsFlattened: discoverGridContextComplexMock.rowsFlattened, + dataView: discoverGridContextComplexMock.indexPattern, + services: discoverServiceMock, + rowIndex, + columnId, + options, + }); + + const originalClipboard = global.window.navigator.clipboard; + + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn(), + }, + writable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + }); + }); + + it('should copy a value to clipboard', () => { + execCommandMock.mockImplementationOnce(() => true); + const result = copyValueToClipboard({ + services: discoverServiceMock, + columnId: 'keyword_key', + rowIndex: 0, + valueToStringConverter, + }); + + expect(result).toBe('abcd1'); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(warn).not.toHaveBeenCalled(); + expect(discoverServiceMock.toastNotifications.addInfo).toHaveBeenCalledWith({ + title: 'Copied to clipboard', + }); + }); + + it('should inform when copy a value to clipboard failed', () => { + execCommandMock.mockImplementationOnce(() => false); + + const result = copyValueToClipboard({ + services: discoverServiceMock, + columnId: 'keyword_key', + rowIndex: 0, + valueToStringConverter, + }); + + expect(result).toBe(null); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(warn).toHaveBeenCalledWith('Unable to copy to clipboard.'); + expect(discoverServiceMock.toastNotifications.addWarning).toHaveBeenCalledWith({ + title: 'Unable to copy to clipboard in this browser', + }); + }); + + it('should copy a column name to clipboard', () => { + execCommandMock.mockImplementationOnce(() => true); + const result = copyColumnNameToClipboard({ + services: discoverServiceMock, + columnId: 'text_message', + }); + + expect(result).toBe('"text_message"'); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(discoverServiceMock.toastNotifications.addInfo).toHaveBeenCalledWith({ + title: 'Copied to clipboard', + }); + }); + + it('should inform when copy a column name to clipboard failed', () => { + execCommandMock.mockImplementationOnce(() => false); + const result = copyColumnNameToClipboard({ + services: discoverServiceMock, + columnId: 'text_message', + }); + + expect(result).toBe(null); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(warn).toHaveBeenCalledWith('Unable to copy to clipboard.'); + expect(discoverServiceMock.toastNotifications.addWarning).toHaveBeenCalledWith({ + title: 'Unable to copy to clipboard in this browser', + }); + }); + + it('should copy column values to clipboard', async () => { + execCommandMock.mockImplementationOnce(() => true); + + const result = await copyColumnValuesToClipboard({ + services: discoverServiceMock, + columnId: 'bool_enabled', + rowsCount: 2, + valueToStringConverter, + }); + + expect(result).toBe('"bool_enabled"\nfalse\ntrue'); + expect(global.window.navigator.clipboard.writeText).toHaveBeenCalledWith( + '"bool_enabled"\nfalse\ntrue' + ); + expect(discoverServiceMock.toastNotifications.addInfo).toHaveBeenCalledWith({ + title: 'Copied values of "bool_enabled" column to clipboard', + }); + }); + + it('should copy column values to clipboard with a warning', async () => { + execCommandMock.mockImplementationOnce(() => true); + const result = await copyColumnValuesToClipboard({ + services: discoverServiceMock, + columnId: 'scripted_string', + rowsCount: 2, + valueToStringConverter, + }); + + expect(result).toBe('"scripted_string"\n"hi there"\n"\'=1+2"";=1+2"'); + expect(discoverServiceMock.toastNotifications.addWarning).toHaveBeenCalledWith({ + title: 'Copied values of "scripted_string" column to clipboard', + text: 'It may contain formulas whose values have been escaped.', + }); + }); +}); diff --git a/src/plugins/discover/public/utils/copy_value_to_clipboard.ts b/src/plugins/discover/public/utils/copy_value_to_clipboard.ts new file mode 100644 index 0000000000000..eedaa676a4c56 --- /dev/null +++ b/src/plugins/discover/public/utils/copy_value_to_clipboard.ts @@ -0,0 +1,165 @@ +/* + * 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 { copyToClipboard } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ValueToStringConverter } from '../types'; +import { DiscoverServices } from '../build_services'; +import { convertNameToString } from './convert_value_to_string'; + +const WARNING_FOR_FORMULAS = i18n.translate( + 'discover.grid.copyEscapedValueWithFormulasToClipboardWarningText', + { + defaultMessage: 'It may contain formulas whose values have been escaped.', + } +); +const COPY_FAILED_ERROR_MESSAGE = i18n.translate('discover.grid.copyFailedErrorText', { + defaultMessage: 'Unable to copy to clipboard in this browser', +}); + +export const copyValueToClipboard = ({ + rowIndex, + columnId, + services, + valueToStringConverter, +}: { + rowIndex: number; + columnId: string; + services: DiscoverServices; + valueToStringConverter: ValueToStringConverter; +}): string | null => { + const { toastNotifications } = services; + + const result = valueToStringConverter(rowIndex, columnId); + const valueFormatted = result.formattedString; + + const copied = copyToClipboard(valueFormatted); + + if (!copied) { + toastNotifications.addWarning({ + title: COPY_FAILED_ERROR_MESSAGE, + }); + + return null; + } + + const toastTitle = i18n.translate('discover.grid.copyValueToClipboard.toastTitle', { + defaultMessage: 'Copied to clipboard', + }); + + if (result.withFormula) { + toastNotifications.addWarning({ + title: toastTitle, + text: WARNING_FOR_FORMULAS, + }); + } else { + toastNotifications.addInfo({ + title: toastTitle, + }); + } + + return valueFormatted; +}; + +export const copyColumnValuesToClipboard = async ({ + columnId, + services, + valueToStringConverter, + rowsCount, +}: { + columnId: string; + services: DiscoverServices; + valueToStringConverter: ValueToStringConverter; + rowsCount: number; +}): Promise => { + const { toastNotifications } = services; + const nameFormattedResult = convertNameToString(columnId); + let withFormula = nameFormattedResult.withFormula; + + const valuesFormatted = [...Array(rowsCount)].map((_, rowIndex) => { + const result = valueToStringConverter(rowIndex, columnId, { disableMultiline: true }); + withFormula = withFormula || result.withFormula; + return result.formattedString; + }); + + const textToCopy = `${nameFormattedResult.formattedString}\n${valuesFormatted.join('\n')}`; + + let copied; + try { + // try to copy without browser styles + await window.navigator?.clipboard?.writeText(textToCopy); + copied = true; + } catch (error) { + copied = copyToClipboard(textToCopy); + } + + if (!copied) { + toastNotifications.addWarning({ + title: COPY_FAILED_ERROR_MESSAGE, + }); + + return null; + } + + const toastTitle = i18n.translate('discover.grid.copyColumnValuesToClipboard.toastTitle', { + defaultMessage: 'Copied values of "{column}" column to clipboard', + values: { column: columnId }, + }); + + if (withFormula) { + toastNotifications.addWarning({ + title: toastTitle, + text: WARNING_FOR_FORMULAS, + }); + } else { + toastNotifications.addInfo({ + title: toastTitle, + }); + } + + return textToCopy; +}; + +export const copyColumnNameToClipboard = ({ + columnId, + services, +}: { + columnId: string; + services: DiscoverServices; +}): string | null => { + const { toastNotifications } = services; + + const nameFormattedResult = convertNameToString(columnId); + const textToCopy = nameFormattedResult.formattedString; + const copied = copyToClipboard(textToCopy); + + if (!copied) { + toastNotifications.addWarning({ + title: COPY_FAILED_ERROR_MESSAGE, + }); + + return null; + } + + const toastTitle = i18n.translate('discover.grid.copyColumnNameToClipboard.toastTitle', { + defaultMessage: 'Copied to clipboard', + }); + + if (nameFormattedResult.withFormula) { + toastNotifications.addWarning({ + title: toastTitle, + text: WARNING_FOR_FORMULAS, + }); + } else { + toastNotifications.addInfo({ + title: toastTitle, + }); + } + + return textToCopy; +}; diff --git a/src/plugins/discover/public/utils/format_value.ts b/src/plugins/discover/public/utils/format_value.ts index b7ee9af7f6873..b2d81899c4b6d 100644 --- a/src/plugins/discover/public/utils/format_value.ts +++ b/src/plugins/discover/public/utils/format_value.ts @@ -10,7 +10,11 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { FieldFormatsContentType } from '@kbn/field-formats-plugin/common/types'; +import type { + FieldFormatsContentType, + HtmlContextTypeOptions, + TextContextTypeOptions, +} from '@kbn/field-formats-plugin/common/types'; /** * Formats the value of a specific field using the appropriate field formatter if available @@ -18,8 +22,11 @@ import { FieldFormatsContentType } from '@kbn/field-formats-plugin/common/types' * * @param value The value to format * @param hit The actual search hit (required to get highlight information from) + * @param fieldFormats Field formatters * @param dataView The data view if available * @param field The field that value was from if available + * @param contentType Type of a converter + * @param options Options for the converter * @returns An sanitized HTML string, that is safe to be applied via dangerouslySetInnerHTML */ export function formatFieldValue( @@ -28,17 +35,24 @@ export function formatFieldValue( fieldFormats: FieldFormatsStart, dataView?: DataView, field?: DataViewField, - contentType?: FieldFormatsContentType | undefined + contentType?: FieldFormatsContentType, + options?: HtmlContextTypeOptions | TextContextTypeOptions ): string { const usedContentType = contentType ?? 'html'; + const converterOptions: HtmlContextTypeOptions | TextContextTypeOptions = { + hit, + field, + ...options, + }; + if (!dataView || !field) { // If either no field is available or no data view, we'll use the default // string formatter to format that field. return fieldFormats .getDefaultInstance(KBN_FIELD_TYPES.STRING) - .convert(value, usedContentType, { hit, field }); + .convert(value, usedContentType, converterOptions); } // If we have a data view and field we use that fields field formatter - return dataView.getFormatterForField(field).convert(value, usedContentType, { hit, field }); + return dataView.getFormatterForField(field).convert(value, usedContentType, converterOptions); } diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index 06cb30a74fe97..f2d156c4af681 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -154,12 +154,12 @@ describe('ExpressionLoader', () => { it('throttles partial results', async () => { testScheduler.run(({ cold, expectObservable }) => { const expressionLoader = new ExpressionLoader(element, 'var foo', { - variables: { foo: cold('a 5ms b 5ms c 10ms d', { a: 1, b: 2, c: 3, d: 4 }) }, + variables: { foo: cold('a 5ms b 5ms c 10ms (d|)', { a: 1, b: 2, c: 3, d: 4 }) }, partial: true, throttle: 20, }); - expectObservable(expressionLoader.data$).toBe('a 19ms c 19ms d', { + expectObservable(expressionLoader.data$).toBe('a 19ms c 2ms d', { a: expect.objectContaining({ result: 1 }), c: expect.objectContaining({ result: 3 }), d: expect.objectContaining({ result: 4 }), diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 7f7a96fde6f1a..afbde2ab3043a 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { BehaviorSubject, Observable, Subject, Subscription, asyncScheduler, identity } from 'rxjs'; -import { filter, map, delay, shareReplay, throttleTime } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription, identity, timer } from 'rxjs'; +import { delay, filter, finalize, map, shareReplay, takeWhile } from 'rxjs/operators'; import { defaults } from 'lodash'; import { SerializableRecord, UnwrapObservable } from '@kbn/utility-types'; import { Adapters } from '@kbn/inspector-plugin/public'; @@ -20,6 +20,61 @@ import { getExpressionsService } from './services'; type Data = unknown; +/** + * RxJS' `throttle` operator does not emit the last value immediately when the source observable is completed. + * Instead, it waits for the next throttle period to emit that. + * It might cause delays until we get the final value, even though it is already there. + * @see https://github.com/ReactiveX/rxjs/blob/master/src/internal/operators/throttle.ts#L121 + */ +function throttle(timeout: number) { + return (source: Observable): Observable => + new Observable((subscriber) => { + let latest: T | undefined; + let hasValue = false; + + const emit = () => { + if (hasValue) { + subscriber.next(latest); + hasValue = false; + latest = undefined; + } + }; + + let throttled: Subscription | undefined; + const timer$ = timer(0, timeout).pipe( + takeWhile(() => hasValue), + finalize(() => { + subscriber.remove(throttled!); + throttled = undefined; + }) + ); + + subscriber.add( + source.subscribe({ + next: (value) => { + latest = value; + hasValue = true; + + if (!throttled) { + throttled = timer$.subscribe(emit); + subscriber.add(throttled); + } + }, + error: (error) => subscriber.error(error), + complete: () => { + emit(); + subscriber.complete(); + }, + }) + ); + + subscriber.add(() => { + hasValue = false; + latest = undefined; + }); + }); +} + export class ExpressionLoader { data$: ReturnType; update$: ExpressionRenderHandler['update$']; @@ -151,9 +206,7 @@ export class ExpressionLoader { .pipe( delay(0), // delaying until the next tick since we execute the expression in the constructor filter(({ partial }) => params.partial || !partial), - params.partial && params.throttle - ? throttleTime(params.throttle, asyncScheduler, { leading: true, trailing: true }) - : identity + params.partial && params.throttle ? throttle(params.throttle) : identity ) .subscribe((value) => this.dataSubject.next(value)); }; diff --git a/src/plugins/field_formats/common/content_types/html_content_type.ts b/src/plugins/field_formats/common/content_types/html_content_type.ts index d8d664e1d1b64..f1906ab3e68f7 100644 --- a/src/plugins/field_formats/common/content_types/html_content_type.ts +++ b/src/plugins/field_formats/common/content_types/html_content_type.ts @@ -36,7 +36,7 @@ export const setup = ( const recurse: HtmlContextTypeConvert = (value, options = {}) => { if (value == null) { - return asPrettyString(value); + return asPrettyString(value, options); } if (!value || !isFunction(value.map)) { diff --git a/src/plugins/field_formats/common/converters/boolean.ts b/src/plugins/field_formats/common/converters/boolean.ts index 81fd419c9861e..a44540b639032 100644 --- a/src/plugins/field_formats/common/converters/boolean.ts +++ b/src/plugins/field_formats/common/converters/boolean.ts @@ -20,7 +20,7 @@ export class BoolFormat extends FieldFormat { }); static fieldType = [KBN_FIELD_TYPES.BOOLEAN, KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.STRING]; - textConvert: TextContextTypeConvert = (value: string | number | boolean) => { + textConvert: TextContextTypeConvert = (value: string | number | boolean, options) => { if (typeof value === 'string') { value = value.trim().toLowerCase(); } @@ -37,7 +37,7 @@ export class BoolFormat extends FieldFormat { case 'yes': return 'true'; default: - return asPrettyString(value); + return asPrettyString(value, options); } }; } diff --git a/src/plugins/field_formats/common/converters/color.tsx b/src/plugins/field_formats/common/converters/color.tsx index 197468fc1592a..9bbc66e662d9a 100644 --- a/src/plugins/field_formats/common/converters/color.tsx +++ b/src/plugins/field_formats/common/converters/color.tsx @@ -54,10 +54,10 @@ export class ColorFormat extends FieldFormat { } } - htmlConvert: HtmlContextTypeConvert = (val: string | number) => { + htmlConvert: HtmlContextTypeConvert = (val: string | number, options) => { const color = this.findColorRuleForVal(val) as typeof DEFAULT_CONVERTER_COLOR; - const displayVal = escape(asPrettyString(val)); + const displayVal = escape(asPrettyString(val, options)); if (!color) return displayVal; return ReactDOM.renderToStaticMarkup( diff --git a/src/plugins/field_formats/common/converters/geo_point.ts b/src/plugins/field_formats/common/converters/geo_point.ts index 68fbd74096d62..a5e77db35589d 100644 --- a/src/plugins/field_formats/common/converters/geo_point.ts +++ b/src/plugins/field_formats/common/converters/geo_point.ts @@ -95,14 +95,17 @@ export class GeoPointFormat extends FieldFormat { }; } - textConvert: TextContextTypeConvert = (val: Point | { lat: number; lon: number } | string) => { + textConvert: TextContextTypeConvert = ( + val: Point | { lat: number; lon: number } | string, + options + ) => { if (!val) { return ''; } const point: Point | null = isPoint(val) ? (val as Point) : toPoint(val); if (!point) { - return asPrettyString(val); + return asPrettyString(val, options); } switch (this.param('transform')) { @@ -111,7 +114,7 @@ export class GeoPointFormat extends FieldFormat { case 'wkt': return `POINT (${point.coordinates[0]} ${point.coordinates[1]})`; default: - return asPrettyString(val); + return asPrettyString(val, options); } }; } diff --git a/src/plugins/field_formats/common/converters/histogram.ts b/src/plugins/field_formats/common/converters/histogram.ts index 2f6928b2abd8e..cdfcb52cb85be 100644 --- a/src/plugins/field_formats/common/converters/histogram.ts +++ b/src/plugins/field_formats/common/converters/histogram.ts @@ -34,7 +34,7 @@ export class HistogramFormat extends FieldFormat { }; } - textConvert: TextContextTypeConvert = (val: number) => { + textConvert: TextContextTypeConvert = (val: number, options) => { if (typeof val === 'number') { const subFormatId = this.param('id'); const SubFormat = @@ -44,7 +44,7 @@ export class HistogramFormat extends FieldFormat { ? PercentFormat : NumberFormat; const converter = new SubFormat(this.param('params'), this.getConfig); - return converter.textConvert(val); + return converter.textConvert(val, options); } else { return JSON.stringify(val); } diff --git a/src/plugins/field_formats/common/converters/string.ts b/src/plugins/field_formats/common/converters/string.ts index e3700e1b52429..e6e88753e782b 100644 --- a/src/plugins/field_formats/common/converters/string.ts +++ b/src/plugins/field_formats/common/converters/string.ts @@ -109,7 +109,7 @@ export class StringFormat extends FieldFormat { }); } - textConvert: TextContextTypeConvert = (val: string | number) => { + textConvert: TextContextTypeConvert = (val: string | number, options) => { if (val === '') { return emptyLabel; } @@ -121,13 +121,13 @@ export class StringFormat extends FieldFormat { case 'title': return this.toTitleCase(String(val)); case 'short': - return asPrettyString(shortenDottedString(val)); + return asPrettyString(shortenDottedString(val), options); case 'base64': return this.base64Decode(String(val)); case 'urlparam': return decodeURIComponent(String(val)); default: - return asPrettyString(val); + return asPrettyString(val, options); } }; diff --git a/src/plugins/field_formats/common/field_format.test.ts b/src/plugins/field_formats/common/field_format.test.ts index 1068c2455f96e..41c403d8055c7 100644 --- a/src/plugins/field_formats/common/field_format.test.ts +++ b/src/plugins/field_formats/common/field_format.test.ts @@ -9,11 +9,11 @@ import { constant, trimEnd, trimStart, get } from 'lodash'; import { FieldFormat } from './field_format'; import { asPrettyString } from './utils'; -import { FieldFormatParams } from './types'; +import { FieldFormatParams, TextContextTypeOptions } from './types'; const getTestFormat = ( _params?: FieldFormatParams, - textConvert = (val: string) => asPrettyString(val), + textConvert = (val: string, options?: TextContextTypeOptions) => asPrettyString(val, options), htmlConvert?: (val: string) => string ) => new (class TestFormat extends FieldFormat { diff --git a/src/plugins/field_formats/common/types.ts b/src/plugins/field_formats/common/types.ts index 55db21ffa74ea..e8990a720eb43 100644 --- a/src/plugins/field_formats/common/types.ts +++ b/src/plugins/field_formats/common/types.ts @@ -17,7 +17,8 @@ export type FieldFormatsContentType = 'html' | 'text'; */ export interface HtmlContextTypeOptions { field?: { name: string }; - hit?: { highlight: Record }; + hit?: { highlight?: Record }; + skipFormattingInStringifiedJSON?: boolean; } /** @@ -29,9 +30,11 @@ export type HtmlContextTypeConvert = (value: any, options?: HtmlContextTypeOptio /** * Plain text converter options * @remark - * no options for now */ -export type TextContextTypeOptions = object; +export interface TextContextTypeOptions { + skipFormattingInStringifiedJSON?: boolean; + timezone?: string; +} /** * To plain text converter function diff --git a/src/plugins/field_formats/common/utils/as_pretty_string.test.ts b/src/plugins/field_formats/common/utils/as_pretty_string.test.ts index 2505a490bb79a..09d966804436a 100644 --- a/src/plugins/field_formats/common/utils/as_pretty_string.test.ts +++ b/src/plugins/field_formats/common/utils/as_pretty_string.test.ts @@ -21,6 +21,9 @@ describe('asPrettyString', () => { test('Converts objects values into presentable strings', () => { expect(asPrettyString({ key: 'value' })).toBe('{\n "key": "value"\n}'); + expect(asPrettyString({ key: 'value' }, { skipFormattingInStringifiedJSON: true })).toBe( + '{"key":"value"}' + ); }); test('Converts other non-string values into strings', () => { diff --git a/src/plugins/field_formats/common/utils/as_pretty_string.ts b/src/plugins/field_formats/common/utils/as_pretty_string.ts index 95424f00b2c44..61f80e5645623 100644 --- a/src/plugins/field_formats/common/utils/as_pretty_string.ts +++ b/src/plugins/field_formats/common/utils/as_pretty_string.ts @@ -9,13 +9,18 @@ /** * Convert a value to a presentable string */ -export function asPrettyString(val: unknown): string { +export function asPrettyString( + val: unknown, + options?: { skipFormattingInStringifiedJSON?: boolean } +): string { if (val === null || val === undefined) return ' - '; switch (typeof val) { case 'string': return val; case 'object': - return JSON.stringify(val, null, ' '); + return options?.skipFormattingInStringifiedJSON + ? JSON.stringify(val) + : JSON.stringify(val, null, ' '); default: return '' + val; } diff --git a/src/plugins/field_formats/server/lib/converters/date_nanos_server.ts b/src/plugins/field_formats/server/lib/converters/date_nanos_server.ts index 3a04dd2353b88..9c2e7f8dc75d7 100644 --- a/src/plugins/field_formats/server/lib/converters/date_nanos_server.ts +++ b/src/plugins/field_formats/server/lib/converters/date_nanos_server.ts @@ -16,7 +16,7 @@ import { import { TextContextTypeConvert } from '../../../common/types'; class DateNanosFormatServer extends DateNanosFormat { - textConvert: TextContextTypeConvert = (val: string | number, options?: { timezone?: string }) => { + textConvert: TextContextTypeConvert = (val: string | number, options) => { // don't give away our ref to converter so // we can hot-swap when config changes const pattern = this.param('pattern'); diff --git a/src/plugins/field_formats/server/lib/converters/date_server.ts b/src/plugins/field_formats/server/lib/converters/date_server.ts index 7cfd8ff7c9b4e..e5f7cad713aae 100644 --- a/src/plugins/field_formats/server/lib/converters/date_server.ts +++ b/src/plugins/field_formats/server/lib/converters/date_server.ts @@ -68,7 +68,7 @@ export class DateFormat extends FieldFormat { }; } - textConvert: TextContextTypeConvert = (val: string | number, options?: { timezone?: string }) => { + textConvert: TextContextTypeConvert = (val: string | number, options) => { // don't give away our ref to converter so we can hot-swap when config changes const pattern = this.param('pattern'); const timezone = options?.timezone || this.param('timezone'); diff --git a/test/functional/apps/discover/_data_grid_copy_to_clipboard.ts b/test/functional/apps/discover/_data_grid_copy_to_clipboard.ts new file mode 100644 index 0000000000000..f202595729cfb --- /dev/null +++ b/test/functional/apps/discover/_data_grid_copy_to_clipboard.ts @@ -0,0 +1,98 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dataGrid = getService('dataGrid'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const browser = getService('browser'); + const toasts = getService('toasts'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker', 'dashboard']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'doc_table:legacy': false, + }; + + describe('discover data grid supports copy to clipboard', function describeIndexTests() { + before(async function () { + log.debug('load kibana index with default index pattern'); + await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.uiSettings.replace(defaultSettings); + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + }); + + after(async function () { + log.debug('reset uiSettings'); + await kibanaServer.uiSettings.replace({}); + }); + + it('should be able to copy a column values to clipboard', async () => { + const canReadClipboard = await browser.checkBrowserPermission('clipboard-read'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await dataGrid.clickCopyColumnValues('@timestamp'); + + if (canReadClipboard) { + const copiedTimestampData = await browser.getClipboardValue(); + expect( + copiedTimestampData.startsWith( + '"\'@timestamp"\n"Sep 22, 2015 @ 23:50:13.253"\n"Sep 22, 2015 @ 23:43:58.175"' + ) + ).to.be(true); + } + + expect(await toasts.getToastCount()).to.be(1); + await toasts.dismissAllToasts(); + + await dataGrid.clickCopyColumnValues('_source'); + + if (canReadClipboard) { + const copiedSourceData = await browser.getClipboardValue(); + expect(copiedSourceData.startsWith('"_source"\n{"@message":["238.171.34.42')).to.be(true); + expect(copiedSourceData.endsWith('}')).to.be(true); + } + + expect(await toasts.getToastCount()).to.be(1); + await toasts.dismissAllToasts(); + }); + + it('should be able to copy a column name to clipboard', async () => { + const canReadClipboard = await browser.checkBrowserPermission('clipboard-read'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await dataGrid.clickCopyColumnName('@timestamp'); + if (canReadClipboard) { + const copiedTimestampName = await browser.getClipboardValue(); + expect(copiedTimestampName).to.be('"\'@timestamp"'); + } + + expect(await toasts.getToastCount()).to.be(1); + await toasts.dismissAllToasts(); + + await dataGrid.clickCopyColumnName('_source'); + + if (canReadClipboard) { + const copiedSourceName = await browser.getClipboardValue(); + expect(copiedSourceName).to.be('"_source"'); + } + + expect(await toasts.getToastCount()).to.be(1); + await toasts.dismissAllToasts(); + }); + }); +} diff --git a/test/functional/apps/discover/index.ts b/test/functional/apps/discover/index.ts index fa3c37fc1a694..431d77f1b4b5e 100644 --- a/test/functional/apps/discover/index.ts +++ b/test/functional/apps/discover/index.ts @@ -50,6 +50,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_field_data')); loadTestFile(require.resolve('./_data_grid_doc_navigation')); loadTestFile(require.resolve('./_data_grid_doc_table')); + loadTestFile(require.resolve('./_data_grid_copy_to_clipboard')); loadTestFile(require.resolve('./_indexpattern_with_unmapped_fields')); loadTestFile(require.resolve('./_runtime_fields_editor')); loadTestFile(require.resolve('./_huge_fields')); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 926bd5ab6b734..308ef6ace13ed 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -221,6 +221,17 @@ export class DataGridService extends FtrService { } await this.find.clickByButtonText('Remove column'); } + + public async clickCopyColumnValues(field: string) { + await this.openColMenuByField(field); + await this.find.clickByButtonText('Copy column'); + } + + public async clickCopyColumnName(field: string) { + await this.openColMenuByField(field); + await this.find.clickByButtonText('Copy name'); + } + public async getDetailsRow(): Promise { const detailRows = await this.getDetailsRows(); return detailRows[0]; diff --git a/x-pack/plugins/apm/common/agent_configuration/all_option.ts b/x-pack/plugins/apm/common/agent_configuration/all_option.ts index 6737c8fa0c24c..6f5604989bba3 100644 --- a/x-pack/plugins/apm/common/agent_configuration/all_option.ts +++ b/x-pack/plugins/apm/common/agent_configuration/all_option.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; - export const ALL_OPTION_VALUE = 'ALL_OPTION_VALUE'; // human-readable label for service and environment. The "All" option should be translated. @@ -24,3 +23,8 @@ export function getOptionLabel(value: string | undefined) { export function omitAllOption(value?: string) { return value === ALL_OPTION_VALUE ? undefined : value; } + +export const ALL_OPTION = { + value: ALL_OPTION_VALUE, + label: getOptionLabel(ALL_OPTION_VALUE), +}; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts index 1314b757c66fb..4661ea67ae2ab 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts @@ -101,4 +101,29 @@ describe('Agent configuration', () => { cy.wait('@serviceEnvironmentApi'); cy.contains('production'); }); + it('displays All label when selecting all option', () => { + cy.intercept( + 'GET', + '/api/apm/settings/agent-configuration/environments' + ).as('serviceEnvironmentApi'); + cy.contains('Create configuration').click(); + cy.get('[data-test-subj="serviceNameComboBox"]') + .click() + .type('All') + .type('{enter}'); + cy.contains('All').realClick(); + cy.wait('@serviceEnvironmentApi'); + + cy.get('[data-test-subj="serviceEnviromentComboBox"]') + .click({ force: true }) + .type('All'); + + cy.get('mark').contains('All').click(); + cy.contains('Next step').click(); + cy.contains('Service name All'); + cy.contains('Environment All'); + cy.contains('Edit').click(); + cy.wait('@serviceEnvironmentApi'); + cy.contains('All'); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx index 5e7e376334d31..c92bd14607677 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx @@ -51,7 +51,7 @@ export function ServiceGroupsList() { const sortedItems = sortBy(filteredItems, (item) => apmServiceGroupsSortType === 'alphabetical' - ? item.groupName + ? item.groupName.toLowerCase() : item.updatedAt ); diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx index 2fb481b26be2e..41e22ac840bc1 100644 --- a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx @@ -9,7 +9,10 @@ import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { SuggestionsSelect } from '../../../../../shared/suggestions_select'; -import { ENVIRONMENT_ALL } from '../../../../../../../common/environment_filter_values'; +import { + getOptionLabel, + ALL_OPTION, +} from '../../../../../../../common/agent_configuration/all_option'; interface Props { title: string; @@ -40,8 +43,8 @@ export function FormRowSuggestionsSelect({ > { const { services: { - chrome: { setBreadcrumbs }, + chrome: { setBreadcrumbs, docTitle }, application: { getUrlForApp }, }, } = useKibana(); @@ -49,15 +51,21 @@ export const useCspBreadcrumbs = (breadcrumbs: CspNavigationItem[]) => { }; }); - setBreadcrumbs([ + const nextBreadcrumbs = [ { text: CLOUD_POSTURE, - onClick: (e) => { + onClick: (e: React.MouseEvent) => { e.preventDefault(); history.push(`/`); }, }, ...additionalBreadCrumbs, - ]); - }, [match.path, getUrlForApp, setBreadcrumbs, breadcrumbs, history]); + ]; + + setBreadcrumbs(nextBreadcrumbs); + docTitle.change(getTextBreadcrumbs(nextBreadcrumbs)); + }, [match.path, getUrlForApp, setBreadcrumbs, breadcrumbs, history, docTitle]); }; + +const getTextBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => + breadcrumbs.map((breadcrumb) => breadcrumb.text).filter(string.is); diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index ae5867524cd79..9fd95d6843e12 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -12,7 +12,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; * This object is then used to validate and parse the value entered. */ export const allowedExperimentalValues = Object.freeze({ - createPackagePolicyMultiPageLayout: false, + createPackagePolicyMultiPageLayout: true, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index fee6f4c2ae4c4..f6c72ae9d2680 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -99,7 +99,7 @@ export interface CurrentUpgrade { nbAgents: number; nbAgentsAck: number; version: string; - startTime: string; + startTime?: string; } // Generated from FleetServer schema.json diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts index 12c1af65f9555..9b0c6b48d898c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts @@ -17,8 +17,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -51,8 +51,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` @@ -106,8 +106,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -127,8 +127,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -165,8 +165,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` @@ -226,8 +226,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install--url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -276,8 +276,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` --fleet-server-es=http://elasticsearch:9200 \` diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts index ed38478c3a3ee..a1cc63c5bd977 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts @@ -19,8 +19,8 @@ function getArtifact(platform: PLATFORM_TYPE, kibanaVersion: string) { { fullUrl: string; filename: string; unpackedDir: string } > = { linux: { - fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.zip`, - filename: `elastic-agent-${kibanaVersion}-linux-x86_64.zip`, + fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, + filename: `elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, unpackedDir: `elastic-agent-${kibanaVersion}-linux-x86_64`, }, mac: { @@ -29,8 +29,8 @@ function getArtifact(platform: PLATFORM_TYPE, kibanaVersion: string) { unpackedDir: `elastic-agent-${kibanaVersion}-darwin-x86_64`, }, windows: { - fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-windows-x86_64.tar.gz`, - filename: `elastic-agent-${kibanaVersion}-windows-x86_64.tar.gz`, + fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-windows-x86_64.zip`, + filename: `elastic-agent-${kibanaVersion}-windows-x86_64.zip`, unpackedDir: `elastic-agent-${kibanaVersion}-windows-x86_64`, }, deb: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx index c14a0615ceffa..0a29aace340a0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx @@ -41,43 +41,47 @@ export const CurrentBulkUpgradeCallout: React.FunctionComponent { + if (!currentUpgrade.startTime) { + return false; + } const now = Date.now(); const startDate = new Date(currentUpgrade.startTime).getTime(); return startDate > now; }, [currentUpgrade]); - const calloutTitle = isScheduled ? ( - - -   - - - ), - }} - /> - ) : ( - - ); + const calloutTitle = + isScheduled && currentUpgrade.startTime ? ( + + +   + + + ), + }} + /> + ) : ( + + ); return ( - SPECIAL_PACKAGES.some((pkgname) => pkgkey.startsWith(pkgname)); + EXCLUDED_PACKAGES.some((pkgname) => pkgkey.startsWith(pkgname)); /* * When the install package button is pressed, this fn decides which page to navigate to * by generating the options to be passed to `services.application.navigateToApp`. diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx index 304239ac77dad..4dab3b054bc8d 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx @@ -14,7 +14,7 @@ import { } from '../../applications/fleet/sections/agents/components'; import { AgentPolicyCreateInlineForm } from '../../applications/fleet/sections/agent_policy/components'; import type { AgentPolicy } from '../../types'; -import { incrementPolicyName } from '../../services'; +import { incrementPolicyName, policyHasFleetServer } from '../../services'; import { AgentPolicySelection } from '.'; @@ -48,6 +48,18 @@ export const SelectCreateAgentPolicy: React.FC = ({ ); }, [agentPolicies, excludeFleetServer]); + useEffect(() => { + // Select default value if policy has no fleet server + if ( + regularAgentPolicies.length === 1 && + !selectedPolicyId && + excludeFleetServer !== false && + !policyHasFleetServer(regularAgentPolicies[0]) + ) { + setSelectedPolicyId(regularAgentPolicies[0].id); + } + }, [regularAgentPolicies, selectedPolicyId, setSelectedPolicyId, excludeFleetServer]); + const onAgentPolicyChange = useCallback( async (key?: string, policy?: AgentPolicy) => { if (policy) { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 78c208eb5ae8e..2c839bdd7bb09 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -315,11 +315,6 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( field: 'agents', }, }, - { - exists: { - field: 'start_time', - }, - }, { range: { expiration: { gte: now }, @@ -343,7 +338,7 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( complete: false, nbAgentsAck: 0, version: hit._source.data?.version as string, - startTime: hit._source.start_time as string, + startTime: hit._source?.start_time, }; } diff --git a/x-pack/plugins/monitoring/common/http_api/_health/index.ts b/x-pack/plugins/monitoring/common/http_api/_health/index.ts new file mode 100644 index 0000000000000..50fdb597813ec --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/_health/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { numberFromStringRT, timestampFromStringRT } from '../shared'; + +export const getHealthRequestQueryRT = rt.partial({ + min: timestampFromStringRT, + max: timestampFromStringRT, + timeout: numberFromStringRT, +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/index.ts b/x-pack/plugins/monitoring/common/http_api/shared/index.ts index 4e47c7239e39f..44b103d046824 100644 --- a/x-pack/plugins/monitoring/common/http_api/shared/index.ts +++ b/x-pack/plugins/monitoring/common/http_api/shared/index.ts @@ -10,5 +10,6 @@ export * from './cluster'; export * from './literal_value'; export * from './pagination'; export * from './query_string_boolean'; +export * from './query_string_number'; export * from './sorting'; export * from './time_range'; diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts new file mode 100644 index 0000000000000..30d2167eef586 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { either } from 'fp-ts'; +import { numberFromStringRT } from './query_string_number'; + +describe('NumberFromString runtime type', () => { + it('decodes strings to numbers', () => { + expect(numberFromStringRT.decode('123')).toEqual(either.right(123)); + expect(numberFromStringRT.decode('0')).toEqual(either.right(0)); + }); + + it('rejects when not a number', () => { + expect(either.isLeft(numberFromStringRT.decode(''))).toBeTruthy(); + expect(either.isLeft(numberFromStringRT.decode('ab12'))).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.ts new file mode 100644 index 0000000000000..24305690dad08 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const numberFromStringRT = new rt.Type( + 'NumberFromString', + rt.number.is, + (value, context) => { + const nb = parseInt(value as string, 10); + return isNaN(nb) ? rt.failure(value, context) : rt.success(nb); + }, + String +); diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index a6b91f22ae563..977753de42d97 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -6,6 +6,7 @@ */ export interface ElasticsearchResponse { + timed_out?: boolean; hits?: { hits: ElasticsearchResponseHit[]; total: { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md b/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md new file mode 100644 index 0000000000000..21d6cff557e28 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md @@ -0,0 +1,13 @@ +### Stack Monitoring Health API + +A endpoint that makes a handful of pre-determined queries to determine the health/status of stack monitoring for the configured kibana. + +GET /api/monitoring/v1/_health +parameters: +- (optional) min: start date of the queries, in ms or YYYY-MM-DD hh:mm:ss +- (optional) max: end date of the queries, in ms or YYYY-MM-DD hh:mm:ss +- (optional) timeout: maximum timeout of the queries, in seconds + +The response includes sections that can provide useful informations in a debugging context: +- settings: a subset of the kibana.yml settings relevant to stack monitoring +- monitoredClusters: a representation of the monitoring documents available to the running kibana. It exposes which metricsets are collected by what collection mode and when was the last time it was ingested. The query groups the metricsets by products and can help identifying missing documents that could explain why a page is not loading or crashing diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.ts new file mode 100644 index 0000000000000..32f3bcaa90237 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.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 { LegacyRequest, MonitoringCore } from '../../../../types'; +import { MonitoringConfig } from '../../../../config'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { getHealthRequestQueryRT } from '../../../../../common/http_api/_health'; +import { TimeRange } from '../../../../../common/http_api/shared'; +import { INDEX_PATTERN, INDEX_PATTERN_ENTERPRISE_SEARCH } from '../../../../../common/constants'; + +import { fetchMonitoredClusters } from './monitored_clusters'; + +const DEFAULT_QUERY_TIMERANGE = { min: 'now-15m', max: 'now' }; +const DEFAULT_QUERY_TIMEOUT_SECONDS = 15; + +export function registerV1HealthRoute(server: MonitoringCore) { + const validateQuery = createValidationFunction(getHealthRequestQueryRT); + + const withCCS = (indexPattern: string) => { + if (server.config.ui.ccs.enabled) { + return `${indexPattern},*:${indexPattern}`; + } + return indexPattern; + }; + + server.route({ + method: 'get', + path: '/api/monitoring/v1/_health', + validate: { + query: validateQuery, + }, + async handler(req: LegacyRequest) { + const logger = req.getLogger(); + const timeRange = { + min: req.query.min || DEFAULT_QUERY_TIMERANGE.min, + max: req.query.max || DEFAULT_QUERY_TIMERANGE.max, + } as TimeRange; + const timeout = req.query.timeout || DEFAULT_QUERY_TIMEOUT_SECONDS; + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + + const settings = extractSettings(server.config); + + const monitoredClusters = await fetchMonitoredClusters({ + timeout, + timeRange, + monitoringIndex: withCCS(INDEX_PATTERN), + entSearchIndex: withCCS(INDEX_PATTERN_ENTERPRISE_SEARCH), + search: (params: any) => callWithRequest(req, 'search', params), + logger, + }).catch((err: Error) => { + logger.error(`_health: failed to retrieve monitored clusters:\n${err.stack}`); + return { error: err.message }; + }); + + return { monitoredClusters, settings }; + }, + }); +} + +function extractSettings(config: MonitoringConfig) { + return { + ccs: config.ui.ccs.enabled, + logsIndex: config.ui.logs.index, + metricbeatIndex: config.ui.metricbeat.index, + hasRemoteClusterConfigured: (config.ui.elasticsearch.hosts || []).some(Boolean), + }; +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts new file mode 100644 index 0000000000000..9248af4cae823 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import assert from 'assert'; +import sinon from 'sinon'; +import { buildMonitoredClusters } from './build_monitored_clusters'; + +describe(__filename, () => { + describe('buildMonitoringClusters', () => { + test('it should build a representation of the monitoring state', () => { + const clustersBuckets = [ + { + key: 'cluster_one', + elasticsearch: { + buckets: [ + { + key: 'node_one', + shard: { + by_index: { + buckets: [ + { + key: '.ds-.monitoring-es-8-mb.2022', + last_seen: { + value: 123, + value_as_string: '2022-01-01', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ]; + + const logger = { warn: sinon.spy() } as unknown as Logger; + const monitoredClusters = buildMonitoredClusters(clustersBuckets, logger); + assert.deepEqual(monitoredClusters, { + cluster_one: { + elasticsearch: { + node_one: { + shard: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb.2022', + lastSeen: '2022-01-01', + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts new file mode 100644 index 0000000000000..df959c499d4b2 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts @@ -0,0 +1,150 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { isEmpty, mapValues, merge, omitBy, reduce } from 'lodash'; + +enum CollectionMode { + Internal = 'internal-monitoring', + Metricbeat7 = 'metricbeat-7', + Metricbeat8 = 'metricbeat-8', + Unknown = 'unknown', +} + +enum MonitoredProduct { + Cluster = 'cluster', + Elasticsearch = 'elasticsearch', + Kibana = 'kibana', + Beats = 'beats', + Logstash = 'logstash', + EnterpriseSearch = 'enterpriseSearch', +} + +interface MonitoredMetricsets { + [metricset: string]: { + [collectionMode in CollectionMode]: { + index: string; + lastSeen: string; + }; + }; +} + +interface MonitoredEntities { + [entityId: string]: MonitoredMetricsets; +} + +type MonitoredProducts = { + [product in MonitoredProduct]: MonitoredEntities; +}; + +export interface MonitoredClusters { + [clusterUuid: string]: MonitoredProducts; +} + +const internalMonitoringPattern = /^\.monitoring-(es|kibana|beats|logstash)-7-[0-9]{4}\..*/; +const metricbeatMonitoring7Pattern = /^\.monitoring-(es|kibana|beats|logstash|ent-search)-7.*-mb.*/; +const metricbeatMonitoring8Pattern = + /^\.ds-\.monitoring-(es|kibana|beats|logstash|ent-search)-8-mb.*/; + +const getCollectionMode = (index: string): CollectionMode => { + if (internalMonitoringPattern.test(index)) return CollectionMode.Internal; + if (metricbeatMonitoring7Pattern.test(index)) return CollectionMode.Metricbeat7; + if (metricbeatMonitoring8Pattern.test(index)) return CollectionMode.Metricbeat8; + + return CollectionMode.Unknown; +}; + +/** + * builds a normalized representation of the monitoring state from the provided + * query buckets with a cluster->product->entity->metricset hierarchy where + * cluster: the monitored cluster identifier + * product: the monitored products (eg elasticsearch) + * entity: the product unit of work (eg node) + * metricset: the collected metricsets for a given entity + * + * example: + * { + * "f-05NylTQg2G7rQXHnvYbA": { + * "elasticsearch": { + * "9NXA8Ov5QCeWAalKIHWFJg": { + * "shard": { + * "metricbeat-8": { + * "index": ".ds-.monitoring-es-8-mb-2022.05.17-000001", + * "lastSeen": "2022-05-17T16:56:52.929Z" + * } + * } + * } + * } + * } + * } + */ +export const buildMonitoredClusters = ( + clustersBuckets: any[], + logger: Logger +): MonitoredClusters => { + return clustersBuckets.reduce((clusters, { key, doc_count: _, ...products }) => { + clusters[key] = buildMonitoredProducts(products, logger); + return clusters; + }, {}); +}; + +/** + * some products may not have a common identifier for their entities across the + * metricsets and can create multiple aggregations. we make sure to merge these + * so the output only includes a single product entry + * we assume each aggregation is named as /productname(_aggsuffix)?/ + */ +const buildMonitoredProducts = (rawProducts: any, logger: Logger): MonitoredProducts => { + const validProducts = Object.values(MonitoredProduct); + const products = mapValues(rawProducts, (value, key) => { + if (!validProducts.some((product) => key.startsWith(product))) { + logger.warn(`buildMonitoredProducts: ignoring unknown product aggregation key (${key})`); + return {}; + } + + return buildMonitoredEntities(value.buckets); + }); + + return reduce( + products, + (uniqProducts: any, entities: any, aggregationKey: string) => { + if (isEmpty(entities)) return uniqProducts; + + const product = aggregationKey.split('_')[0]; + uniqProducts[product] = merge(uniqProducts[product], entities); + return uniqProducts; + }, + {} + ); +}; + +const buildMonitoredEntities = (entitiesBuckets: any[]): MonitoredEntities => { + return entitiesBuckets.reduce( + (entities, { key, key_as_string: keyAsString, doc_count: _, ...metricsets }) => { + entities[keyAsString || key] = buildMonitoredMetricsets(metricsets); + return entities; + }, + {} + ); +}; + +const buildMonitoredMetricsets = (rawMetricsets: any): MonitoredMetricsets => { + const monitoredMetricsets = mapValues( + rawMetricsets, + ({ by_index: byIndex }: { by_index: { buckets: any[] } }) => { + return byIndex.buckets.reduce((metricsets, { key, last_seen: lastSeen }) => { + metricsets[getCollectionMode(key)] = { + index: key, + lastSeen: lastSeen.value_as_string, + }; + return metricsets; + }, {}); + } + ); + + return omitBy(monitoredMetricsets, isEmpty) as unknown as MonitoredMetricsets; +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts new file mode 100644 index 0000000000000..fabece8a6b207 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts @@ -0,0 +1,328 @@ +/* + * 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 assert from 'assert'; +import sinon from 'sinon'; +import type { Logger } from '@kbn/core/server'; +import { fetchMonitoredClusters } from './fetch_monitored_clusters'; + +const getMockLogger = () => + ({ + warn: sinon.spy(), + error: sinon.spy(), + } as unknown as Logger); + +describe(__filename, () => { + describe('fetchMonitoringClusters', () => { + test('it should send multiple search queries', async () => { + const searchFn = jest.fn().mockResolvedValue({ + aggregations: { + clusters: { + buckets: [], + }, + }, + }); + + await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(searchFn.mock.calls.length, 3); + }); + + test('it should report request timeouts', async () => { + const searchFn = jest + .fn() + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }) + .mockResolvedValueOnce({ + timed_out: true, + aggregations: {}, + }) + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }); + + const result = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(result.execution.timedOut, true); + }); + + test('it should report request errors', async () => { + const searchFn = jest + .fn() + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }) + .mockRejectedValueOnce(new Error('massive failure')) + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }); + + const result = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(result.execution.timedOut, false); + assert.deepEqual(result.execution.errors, ['massive failure']); + }); + + test('it should merge the query results', async () => { + const mainMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + elasticsearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'es-node-id.1', + doc_count: 540, + enrich: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + + kibana: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'kibana-node-id.1', + doc_count: 540, + stats: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-kibana-8-mb-2022.05.19-000001', + doc_count: 162, + last_seen: { + value: 1652975513680, + value_as_string: '2022-05-19T15:51:53.680Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const persistentMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + elasticsearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'es-node-id.1', + doc_count: 540, + shard: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const entsearchMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + enterpriseSearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ent-search-node-id.1', + doc_count: 540, + health: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-ent-search-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const searchFn = jest + .fn() + .mockResolvedValueOnce(mainMetricsetsResponse) + .mockResolvedValueOnce(persistentMetricsetsResponse) + .mockResolvedValueOnce(entsearchMetricsetsResponse); + + const monitoredClusters = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.deepEqual(monitoredClusters, { + execution: { + timedOut: false, + errors: [], + }, + + clusters: { + 'cluster-id.1': { + elasticsearch: { + 'es-node-id.1': { + enrich: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + shard: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + }, + }, + + kibana: { + 'kibana-node-id.1': { + stats: { + 'metricbeat-8': { + index: '.ds-.monitoring-kibana-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:53.680Z', + }, + }, + }, + }, + + enterpriseSearch: { + 'ent-search-node-id.1': { + health: { + 'metricbeat-8': { + index: '.ds-.monitoring-ent-search-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts new file mode 100644 index 0000000000000..95f56aea5f625 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts @@ -0,0 +1,98 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { merge } from 'lodash'; + +import { ElasticsearchResponse } from '../../../../../../common/types/es'; +import { TimeRange } from '../../../../../../common/http_api/shared'; + +import { buildMonitoredClusters, MonitoredClusters } from './build_monitored_clusters'; +import { + monitoredClustersQuery, + persistentMetricsetsQuery, + enterpriseSearchQuery, +} from './monitored_clusters_query'; + +type SearchFn = (params: any) => Promise; + +interface MonitoredClustersResponse { + clusters?: MonitoredClusters; + execution: { + timedOut?: boolean; + errors?: string[]; + }; +} + +/** + * executes multiple search requests to build a representation of the monitoring + * documents. the queries aggregations are built with a similar hierarchy so + * we can merge them to a single output + */ +export const fetchMonitoredClusters = async ({ + timeout, + monitoringIndex, + entSearchIndex, + timeRange, + search, + logger, +}: { + timeout: number; + timeRange: TimeRange; + monitoringIndex: string; + entSearchIndex: string; + search: SearchFn; + logger: Logger; +}): Promise => { + const getMonitoredClusters = async ( + index: string, + body: any + ): Promise => { + try { + const { aggregations, timed_out: timedOut } = await search({ + index, + body, + size: 0, + ignore_unavailable: true, + }); + + const buckets = aggregations?.clusters?.buckets ?? []; + return { + clusters: buildMonitoredClusters(buckets, logger), + execution: { timedOut }, + }; + } catch (err) { + logger.error(`fetchMonitoredClusters: failed to fetch:\n${err.stack}`); + return { execution: { errors: [err.message] } }; + } + }; + + const results = await Promise.all([ + getMonitoredClusters(monitoringIndex, monitoredClustersQuery({ timeRange, timeout })), + getMonitoredClusters(monitoringIndex, persistentMetricsetsQuery({ timeout })), + getMonitoredClusters(entSearchIndex, enterpriseSearchQuery({ timeRange, timeout })), + ]); + + return { + clusters: merge({}, ...results.map(({ clusters }) => clusters)), + + execution: results + .map(({ execution }) => execution) + .reduce( + (acc, execution) => { + return { + timedOut: Boolean(acc.timedOut || execution.timedOut), + errors: acc.errors!.concat(execution.errors || []), + }; + }, + { + timedOut: false, + errors: [], + } + ), + }; +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/index.ts new file mode 100644 index 0000000000000..b8282afe3b43d --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/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 { fetchMonitoredClusters } from './fetch_monitored_clusters'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts new file mode 100644 index 0000000000000..825cabc723294 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts @@ -0,0 +1,480 @@ +/* + * 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 { TimeRange } from '../../../../../../common/http_api/shared'; + +const MAX_BUCKET_SIZE = 100; + +interface QueryOptions { + timeRange?: TimeRange; + timeout: number; // in seconds +} + +/** + * returns a nested aggregation of the monitored products per cluster, standalone + * included. each product aggregation retrieves the related metricsets and the + * last time they were ingested. + * if a product requires multiple aggregations the key is suffixed with an identifer + * separated by an underscore. eg beats_state + */ +export const monitoredClustersQuery = ({ timeRange, timeout }: QueryOptions) => { + if (!timeRange) throw new Error('monitoredClustersQuery: missing timeRange parameter'); + + return { + timeout: `${timeout}s`, + query: { + bool: { + filter: [ + { + range: { + timestamp: { + gte: timeRange.min, + lte: timeRange.max, + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: clusterUuidTerm, + aggs: { + ...beatsAggregations, + cluster: clusterAggregation, + elasticsearch: esAggregation, + kibana: kibanaAggregation, + logstash: logstashAggregation, + }, + }, + }, + }; +}; + +/** + * some metricset documents use a stable ID to maintain a single occurence of + * the documents in the index. we query those metricsets separately without + * a time range filter + */ +export const persistentMetricsetsQuery = ({ timeout }: QueryOptions) => { + const metricsetsAggregations = { + elasticsearch: { + terms: { + field: 'source_node.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + shard: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'shards', + }, + }, + { + term: { + 'metricset.name': 'shard', + }, + }, + ], + }, + }, + }), + }, + }, + + logstash: { + terms: { + field: 'logstash_state.pipeline.id', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'logstash_state', + }, + }, + { + term: { + 'metricset.name': 'node', + }, + }, + ], + }, + }, + }), + }, + }, + }; + + return { + timeout: `${timeout}s`, + aggs: { + clusters: { + terms: clusterUuidTerm, + aggs: metricsetsAggregations, + }, + }, + }; +}; + +export const enterpriseSearchQuery = ({ timeRange, timeout }: QueryOptions) => { + if (!timeRange) throw new Error('enterpriseSearchQuery: missing timeRange parameter'); + + const timestampField = '@timestamp'; + return { + timeout: `${timeout}s`, + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: timeRange.min, + lte: timeRange.max, + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: { + field: 'enterprisesearch.cluster_uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + enterpriseSearch: { + terms: { + field: 'agent.id', + }, + aggs: { + health: lastSeenByIndex( + { + filter: { + bool: { + should: [ + { + term: { + 'metricset.name': 'health', + }, + }, + ], + }, + }, + }, + timestampField + ), + + stats: lastSeenByIndex( + { + filter: { + bool: { + should: [ + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }, + timestampField + ), + }, + }, + }, + }, + }, + }; +}; + +const clusterUuidTerm = { field: 'cluster_uuid', missing: 'standalone', size: 100 }; + +const lastSeenByIndex = (aggregation: { filter: any }, timestampField = 'timestamp') => { + return { + ...aggregation, + aggs: { + by_index: { + terms: { + field: '_index', + size: MAX_BUCKET_SIZE, + }, + aggs: { + last_seen: { + max: { + field: timestampField, + }, + }, + }, + }, + }, + }; +}; + +const clusterAggregation = { + terms: { + field: 'cluster_uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + cluster_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'cluster_stats', + }, + }, + { + term: { + 'metricset.name': 'cluster_stats', + }, + }, + ], + }, + }, + }), + + ccr: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'ccr_stats', + }, + }, + { + term: { + 'metricset.name': 'ccr', + }, + }, + ], + }, + }, + }), + + index: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'index_stats', + }, + }, + { + term: { + 'metricset.name': 'index', + }, + }, + ], + }, + }, + }), + + index_summary: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'indices_stats', + }, + }, + { + term: { + 'metricset.name': 'index_summary', + }, + }, + ], + }, + }, + }), + + index_recovery: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'index_recovery', + }, + }, + { + term: { + 'metricset.name': 'index_recovery', + }, + }, + ], + }, + }, + }), + }, +}; + +// ignore the enrich metricset since it's not used by stack monitoring +const esAggregation = { + terms: { + field: 'node_stats.node_id', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'node_stats', + }, + }, + { + term: { + 'metricset.name': 'node_stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const kibanaAggregation = { + terms: { + field: 'kibana_stats.kibana.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'kibana_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const logstashAggregation = { + terms: { + field: 'logstash_stats.logstash.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'logstash_stats', + }, + }, + { + term: { + 'metricset.name': 'node_stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const beatsAggregations = { + beats_stats: { + multi_terms: { + size: MAX_BUCKET_SIZE, + terms: [ + { + field: 'beats_stats.beat.type', + }, + { + field: 'beats_stats.beat.uuid', + }, + ], + }, + aggs: { + stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'beats_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }), + }, + }, + + beats_state: { + multi_terms: { + size: MAX_BUCKET_SIZE, + terms: [ + { + field: 'beats_state.beat.type', + }, + { + field: 'beats_state.beat.uuid', + }, + ], + }, + aggs: { + state: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'beats_state', + }, + }, + { + term: { + 'metricset.name': 'state', + }, + }, + ], + }, + }, + }), + }, + }, +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts index f2a1830006174..7ec331075655b 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts @@ -13,6 +13,7 @@ export { registerV1ClusterRoutes } from './cluster'; export { registerV1ElasticsearchRoutes } from './elasticsearch'; export { registerV1ElasticsearchSettingsRoutes } from './elasticsearch_settings'; export { registerV1EnterpriseSearchRoutes } from './enterprise_search'; +export { registerV1HealthRoute } from './_health'; export { registerV1LogstashRoutes } from './logstash'; export { registerV1SetupRoutes } from './setup'; export { registerV1KibanaRoutes } from './kibana'; diff --git a/x-pack/plugins/monitoring/server/routes/index.ts b/x-pack/plugins/monitoring/server/routes/index.ts index 32f2f06188d95..ce5d87a55f1da 100644 --- a/x-pack/plugins/monitoring/server/routes/index.ts +++ b/x-pack/plugins/monitoring/server/routes/index.ts @@ -17,6 +17,7 @@ import { registerV1ElasticsearchRoutes, registerV1ElasticsearchSettingsRoutes, registerV1EnterpriseSearchRoutes, + registerV1HealthRoute, registerV1LogstashRoutes, registerV1SetupRoutes, registerV1KibanaRoutes, @@ -39,6 +40,7 @@ export function requireUIRoutes( registerV1ElasticsearchRoutes(decoratedServer); registerV1ElasticsearchSettingsRoutes(decoratedServer, npRoute); registerV1EnterpriseSearchRoutes(decoratedServer); + registerV1HealthRoute(decoratedServer); registerV1LogstashRoutes(decoratedServer); registerV1SetupRoutes(decoratedServer); registerV1KibanaRoutes(decoratedServer); diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index ae0baaea7f586..6196a9d6e201e 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -319,7 +319,7 @@ const ResultsTableComponent: React.FC = ({ const { visibleRowIndex } = actionProps as EuiDataGridCellValueElementProps & { visibleRowIndex: number; }; - const eventId = data[visibleRowIndex]._id; + const eventId = data[visibleRowIndex]?._id; return addToTimeline({ query: ['_id', eventId], isIcon: true }); }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 475b8e9326658..61f4f829aa18f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2931,7 +2931,6 @@ "discover.fieldNameIcons.unknownFieldAriaLabel": "Champ inconnu", "discover.fieldNameIcons.versionFieldAriaLabel": "Champ de version", "discover.goToDiscoverMainViewButtonText": "Accédez à la vue principale de Discover", - "discover.grid.copyToClipBoardButton": "Copier dans le presse-papiers", "discover.grid.documentHeader": "Document", "discover.grid.filterFor": "Filtrer sur", "discover.grid.filterForAria": "Filtrer sur cette {value}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6723931c5ee68..11125cc3be63a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3025,7 +3025,6 @@ "discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "discover.fieldNameIcons.versionFieldAriaLabel": "バージョンフィールド", "discover.goToDiscoverMainViewButtonText": "Discoverメインビューに移動", - "discover.grid.copyToClipBoardButton": "クリップボードにコピー", "discover.grid.documentHeader": "ドキュメント", "discover.grid.filterFor": "フィルター", "discover.grid.filterForAria": "この{value}でフィルターを適用", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0c8178d08e9ee..2cfcf879bd72a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3035,7 +3035,6 @@ "discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "discover.fieldNameIcons.versionFieldAriaLabel": "版本字段", "discover.goToDiscoverMainViewButtonText": "前往 Discover 主视图", - "discover.grid.copyToClipBoardButton": "复制到剪贴板", "discover.grid.documentHeader": "文档", "discover.grid.filterFor": "筛留", "discover.grid.filterForAria": "筛留此 {value}", diff --git a/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json new file mode 100644 index 0000000000000..d8bb99e30fc2b --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json @@ -0,0 +1,9 @@ +{ + "monitoredClusters": { + "clusters": {}, + "execution": { + "timedOut": false, + "errors": [] + } + } +} diff --git a/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js new file mode 100644 index 0000000000000..2b9ae96ba034b --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +export const esBeatsResponse = (date = moment().format('YYYY.MM.DD')) => { + const esIndex = `.ds-.monitoring-es-8-mb-${date}-000001`; + const beatsIndex = `.ds-.monitoring-beats-8-mb-${date}-000001`; + + return { + monitoredClusters: { + clusters: { + tqiiSubfSgWrl68VJn4y2g: { + cluster: { + tqiiSubfSgWrl68VJn4y2g: { + index_summary: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:15.135Z', + }, + }, + index_recovery: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:13.584Z', + }, + }, + index: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:11.994Z', + }, + }, + cluster_stats: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:18.917Z', + }, + }, + }, + }, + elasticsearch: { + QR7smK2oReK_jWHtt5UOSQ: { + node_stats: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:14.268Z', + }, + }, + shard: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:09:48.321Z', + }, + }, + }, + }, + beats: { + 'metricbeat|726039e5-67fe-4e78-837f-072cf2ba0fe7': { + stats: { + 'metricbeat-8': { + index: beatsIndex, + lastSeen: '2022-05-23T22:17:10.622Z', + }, + }, + state: { + 'metricbeat-8': { + index: beatsIndex, + lastSeen: '2022-05-23T22:17:08.837Z', + }, + }, + }, + }, + }, + }, + execution: { + timedOut: false, + errors: [], + }, + }, + }; +}; diff --git a/x-pack/test/api_integration/apis/monitoring/_health/index.js b/x-pack/test/api_integration/apis/monitoring/_health/index.js new file mode 100644 index 0000000000000..cae6da2b3a13c --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/index.js @@ -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. + */ + +import { mapValues } from 'lodash'; +import expect from '@kbn/expect'; + +import { getLifecycleMethods } from '../data_stream'; + +import emptyResponse from './fixtures/response_empty.json'; +import { esBeatsResponse } from './fixtures/response_es_beats'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + + describe('_health endpoint', () => { + const timeRange = { + min: '2022-05-23T22:00:00.000Z', + max: '2022-05-23T22:30:00.000Z', + }; + + describe('no data', () => { + it('returns an empty state when no data', async () => { + const { body } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + delete body.settings; + expect(body).to.eql(emptyResponse); + }); + }); + + describe('with data', () => { + const archives = [ + 'x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8', + 'x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8', + ]; + const { setup, tearDown } = getLifecycleMethods(getService); + + before('load archive', () => { + return Promise.all(archives.map(setup)); + }); + + after('unload archive', () => { + return tearDown(); + }); + + it('returns the state of the monitoring documents', async () => { + const { body } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + delete body.settings; + expect(body).to.eql(esBeatsResponse()); + }); + + it('returns relevant settings', async () => { + const { + body: { settings }, + } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + // we only test the structure of the settings and not the actual values + // to avoid coupling our tests with any underlying changes to default + // configuration + const settingsType = mapValues(settings, (value) => typeof value); + expect(settingsType).to.eql({ + ccs: 'boolean', + logsIndex: 'string', + metricbeatIndex: 'string', + hasRemoteClusterConfigured: 'boolean', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz new file mode 100644 index 0000000000000..9fbc5a31fe704 Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz new file mode 100644 index 0000000000000..5d47ccc83c1df Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz new file mode 100644 index 0000000000000..55de0a6e215d7 Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz new file mode 100644 index 0000000000000..740d9400460e2 Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/index.js b/x-pack/test/api_integration/apis/monitoring/index.js index 476911c904ea6..ebbf9c3af16e7 100644 --- a/x-pack/test/api_integration/apis/monitoring/index.js +++ b/x-pack/test/api_integration/apis/monitoring/index.js @@ -18,5 +18,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./standalone_cluster')); loadTestFile(require.resolve('./logs')); loadTestFile(require.resolve('./setup')); + loadTestFile(require.resolve('./_health')); }); } 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 index 8d060666cd1ee..9d7e7e655c7a1 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts @@ -150,13 +150,26 @@ export default function (providerContext: FtrProviderContext) { }, }, }); + + // Action 6 1 agent with not start time + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action6', + agents: ['agent1'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); }); 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).length(3); expect(actionIds).contain('action1'); expect(actionIds).contain('action2'); + expect(actionIds).contain('action6'); }); }); });